Merge branch 'master' into save-last-manual-connect-port-to-config

This commit is contained in:
Eric Froemling 2023-01-17 16:11:35 -08:00 committed by GitHub
commit f762d75024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 2412 additions and 945 deletions

View File

@ -420,41 +420,42 @@
"assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/60/ad/38269b7f1c7dc20cb9a506cd0681", "assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/60/ad/38269b7f1c7dc20cb9a506cd0681",
"assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/72/85/d6fc4d16b7081d91fba2850b5b10", "assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/72/85/d6fc4d16b7081d91fba2850b5b10",
"assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/e9/ae/1d674d0c086eaa0bd1c3b1db0505", "assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/e9/ae/1d674d0c086eaa0bd1c3b1db0505",
"assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/89/ec/d472036fbb09f310891761beb39a", "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/9b/ec/d11f3e0238ff648bce3657fe5d50",
"assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/b0/05/e530acaba539f040ce61e22561dc", "assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/92/43/36b34307575f6d6219bdf4898e18",
"assets/build/ba_data/data/languages/belarussian.json": "https://files.ballistica.net/cache/ba1/61/03/89070ca765e06da3a419a579f503", "assets/build/ba_data/data/languages/belarussian.json": "https://files.ballistica.net/cache/ba1/61/03/89070ca765e06da3a419a579f503",
"assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/1f/7f/af259ba9b41556e5e667ad4c646d", "assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/96/96/1390940b8457b477113194acbb41",
"assets/build/ba_data/data/languages/chinesetraditional.json": "https://files.ballistica.net/cache/ba1/3c/22/78a56fc40426ab19ad4e76924b78", "assets/build/ba_data/data/languages/chinesetraditional.json": "https://files.ballistica.net/cache/ba1/f7/b0/191439142c6d6da4a056edc98b38",
"assets/build/ba_data/data/languages/croatian.json": "https://files.ballistica.net/cache/ba1/c9/73/01a1343af814131b1ee96af0b687", "assets/build/ba_data/data/languages/croatian.json": "https://files.ballistica.net/cache/ba1/c9/73/01a1343af814131b1ee96af0b687",
"assets/build/ba_data/data/languages/czech.json": "https://files.ballistica.net/cache/ba1/61/20/01291c2cb72b22f204730c0d7574", "assets/build/ba_data/data/languages/czech.json": "https://files.ballistica.net/cache/ba1/4e/8c/72ddb584856a15dfb11df95f9283",
"assets/build/ba_data/data/languages/danish.json": "https://files.ballistica.net/cache/ba1/6a/fa/fcf4a804beaff927b0f12c179eaa", "assets/build/ba_data/data/languages/danish.json": "https://files.ballistica.net/cache/ba1/6a/fa/fcf4a804beaff927b0f12c179eaa",
"assets/build/ba_data/data/languages/dutch.json": "https://files.ballistica.net/cache/ba1/68/93/da8e9874f41a786edf52ba4ccaad", "assets/build/ba_data/data/languages/dutch.json": "https://files.ballistica.net/cache/ba1/68/93/da8e9874f41a786edf52ba4ccaad",
"assets/build/ba_data/data/languages/english.json": "https://files.ballistica.net/cache/ba1/a0/1d/5fbc922d01521142c2a347b1b024", "assets/build/ba_data/data/languages/english.json": "https://files.ballistica.net/cache/ba1/ad/85/3caf0c981389e46835862248386a",
"assets/build/ba_data/data/languages/esperanto.json": "https://files.ballistica.net/cache/ba1/4c/c7/0184b8178869d1a3827a1bfcd5bb", "assets/build/ba_data/data/languages/esperanto.json": "https://files.ballistica.net/cache/ba1/ac/f5/c0922a99e40dfc9f5e026d43b533",
"assets/build/ba_data/data/languages/filipino.json": "https://files.ballistica.net/cache/ba1/c7/2e/e0520f58206da01b829e02ff4576", "assets/build/ba_data/data/languages/filipino.json": "https://files.ballistica.net/cache/ba1/dc/50/c109f475599608a7e8fdacea667c",
"assets/build/ba_data/data/languages/french.json": "https://files.ballistica.net/cache/ba1/e8/84/6c9f123e9a0d82fc595c8f55ac7c", "assets/build/ba_data/data/languages/french.json": "https://files.ballistica.net/cache/ba1/4f/4d/b259b145a69db1d34052281bb3bb",
"assets/build/ba_data/data/languages/german.json": "https://files.ballistica.net/cache/ba1/8a/09/3e0fa9e44913b53f4dab195d3fae", "assets/build/ba_data/data/languages/german.json": "https://files.ballistica.net/cache/ba1/cb/7b/b881356f22e5f8293a5e4b8976ae",
"assets/build/ba_data/data/languages/gibberish.json": "https://files.ballistica.net/cache/ba1/5f/51/c15d74d2fe4e88ee1e3db0986500", "assets/build/ba_data/data/languages/gibberish.json": "https://files.ballistica.net/cache/ba1/ee/fa/6eb38e7d4cfd13702c66d37b9723",
"assets/build/ba_data/data/languages/greek.json": "https://files.ballistica.net/cache/ba1/aa/da/dfc8d710af960d7300c7090faeab", "assets/build/ba_data/data/languages/greek.json": "https://files.ballistica.net/cache/ba1/8a/82/392f2a4e0e6e5a5e367f19e49538",
"assets/build/ba_data/data/languages/hindi.json": "https://files.ballistica.net/cache/ba1/09/55/b50104638f60636af2263877bb7f", "assets/build/ba_data/data/languages/hindi.json": "https://files.ballistica.net/cache/ba1/2e/d1/b506ddf3d27af7be1ae6159531a3",
"assets/build/ba_data/data/languages/hungarian.json": "https://files.ballistica.net/cache/ba1/d8/f2/aa16bc336bd7660cc86c3264bfc4", "assets/build/ba_data/data/languages/hungarian.json": "https://files.ballistica.net/cache/ba1/b2/81/53c8cd7617d649403e539c3a6171",
"assets/build/ba_data/data/languages/indonesian.json": "https://files.ballistica.net/cache/ba1/3a/6b/34714586cb4e9f1b12f8ae54cac8", "assets/build/ba_data/data/languages/indonesian.json": "https://files.ballistica.net/cache/ba1/30/91/9dc870d35ddca657bf07d0f88a82",
"assets/build/ba_data/data/languages/italian.json": "https://files.ballistica.net/cache/ba1/12/62/862228b229057877e89fb195d41d", "assets/build/ba_data/data/languages/italian.json": "https://files.ballistica.net/cache/ba1/dd/be/1ae65f073d563e69c0c5246d20d7",
"assets/build/ba_data/data/languages/korean.json": "https://files.ballistica.net/cache/ba1/7c/38/d4a44c481757d355836f292ede48", "assets/build/ba_data/data/languages/korean.json": "https://files.ballistica.net/cache/ba1/32/97/4c61425d7c200a5c4c9ae3a94a0e",
"assets/build/ba_data/data/languages/persian.json": "https://files.ballistica.net/cache/ba1/10/13/1228836444f7557211f0058ef9bd", "assets/build/ba_data/data/languages/malay.json": "https://files.ballistica.net/cache/ba1/b4/02/f686ce008312e74d5953a511c8b2",
"assets/build/ba_data/data/languages/polish.json": "https://files.ballistica.net/cache/ba1/19/e9/59c891b1fb85f3ba9f19283c233d", "assets/build/ba_data/data/languages/persian.json": "https://files.ballistica.net/cache/ba1/70/07/7864c07c7177dd751780ccc85b34",
"assets/build/ba_data/data/languages/portuguese.json": "https://files.ballistica.net/cache/ba1/61/5b/847c03407d1c3a85866833323676", "assets/build/ba_data/data/languages/polish.json": "https://files.ballistica.net/cache/ba1/38/31/fd930431c7612d0ab0e73e6bc74f",
"assets/build/ba_data/data/languages/portuguese.json": "https://files.ballistica.net/cache/ba1/cb/96/86bb139f20e126593f7fdba9e9e1",
"assets/build/ba_data/data/languages/romanian.json": "https://files.ballistica.net/cache/ba1/d7/06/9d70642d0a4d1e3b1c2149d7a17c", "assets/build/ba_data/data/languages/romanian.json": "https://files.ballistica.net/cache/ba1/d7/06/9d70642d0a4d1e3b1c2149d7a17c",
"assets/build/ba_data/data/languages/russian.json": "https://files.ballistica.net/cache/ba1/34/ed/b97350983272e4b23bf140d7a5f4", "assets/build/ba_data/data/languages/russian.json": "https://files.ballistica.net/cache/ba1/6c/62/06869ed55a656b6e51b4d22e6fa8",
"assets/build/ba_data/data/languages/serbian.json": "https://files.ballistica.net/cache/ba1/4e/91/6f2a9a3ce733908e91377a6ddb9a", "assets/build/ba_data/data/languages/serbian.json": "https://files.ballistica.net/cache/ba1/4e/91/6f2a9a3ce733908e91377a6ddb9a",
"assets/build/ba_data/data/languages/slovak.json": "https://files.ballistica.net/cache/ba1/f9/4b/d9f01814224066856695452ef57c", "assets/build/ba_data/data/languages/slovak.json": "https://files.ballistica.net/cache/ba1/20/a9/163d189884edf802636bf291e432",
"assets/build/ba_data/data/languages/spanish.json": "https://files.ballistica.net/cache/ba1/ce/be/2f06c3436871fd464ff3a62597d9", "assets/build/ba_data/data/languages/spanish.json": "https://files.ballistica.net/cache/ba1/0b/21/a4d09ca1fea8bbf347ed7091c8a2",
"assets/build/ba_data/data/languages/swedish.json": "https://files.ballistica.net/cache/ba1/91/0a/35c4baf539d5951fc03a794c0e0b", "assets/build/ba_data/data/languages/swedish.json": "https://files.ballistica.net/cache/ba1/3b/9f/d40c1423d260784970fd7364ca30",
"assets/build/ba_data/data/languages/tamil.json": "https://files.ballistica.net/cache/ba1/64/22/7bc899ecbec52cf978a1faf1c127", "assets/build/ba_data/data/languages/tamil.json": "https://files.ballistica.net/cache/ba1/3d/83/e1bb0a664d1c14c41b1a083acf0d",
"assets/build/ba_data/data/languages/thai.json": "https://files.ballistica.net/cache/ba1/f7/df/7ba5f99c5c2c4c86fc0503fcf0b7", "assets/build/ba_data/data/languages/thai.json": "https://files.ballistica.net/cache/ba1/d6/16/523c643358880b03b233ed88e557",
"assets/build/ba_data/data/languages/turkish.json": "https://files.ballistica.net/cache/ba1/ee/08/1f77c7c320d8d8504a11ee495db3", "assets/build/ba_data/data/languages/turkish.json": "https://files.ballistica.net/cache/ba1/63/c8/6cfbfd6702c80aa9df490e4629d7",
"assets/build/ba_data/data/languages/ukrainian.json": "https://files.ballistica.net/cache/ba1/f3/92/fd7ee5fa8a92fcc8fd2219a88a2f", "assets/build/ba_data/data/languages/ukrainian.json": "https://files.ballistica.net/cache/ba1/3e/b6/052f1faed0264bf7135feb5c4cc3",
"assets/build/ba_data/data/languages/venetian.json": "https://files.ballistica.net/cache/ba1/2e/86/10d3e39d35014d039cc9ea886ca7", "assets/build/ba_data/data/languages/venetian.json": "https://files.ballistica.net/cache/ba1/a6/ed/416638d46950c9ab4f6155b9c334",
"assets/build/ba_data/data/languages/vietnamese.json": "https://files.ballistica.net/cache/ba1/1f/ae/abe3f105b3c4b51f6b7942773305", "assets/build/ba_data/data/languages/vietnamese.json": "https://files.ballistica.net/cache/ba1/1f/ae/abe3f105b3c4b51f6b7942773305",
"assets/build/ba_data/data/maps/big_g.json": "https://files.ballistica.net/cache/ba1/47/0a/a617cc85d927b576c4e6fc1091ed", "assets/build/ba_data/data/maps/big_g.json": "https://files.ballistica.net/cache/ba1/47/0a/a617cc85d927b576c4e6fc1091ed",
"assets/build/ba_data/data/maps/bridgit.json": "https://files.ballistica.net/cache/ba1/03/4b/57ee9b42854b26f23f81bd8c58ef", "assets/build/ba_data/data/maps/bridgit.json": "https://files.ballistica.net/cache/ba1/03/4b/57ee9b42854b26f23f81bd8c58ef",
@ -1858,6 +1859,10 @@
"assets/build/ba_data/textures/menuIcon.ktx": "https://files.ballistica.net/cache/ba1/e7/8c/dde40b132e096841299505e2b665", "assets/build/ba_data/textures/menuIcon.ktx": "https://files.ballistica.net/cache/ba1/e7/8c/dde40b132e096841299505e2b665",
"assets/build/ba_data/textures/menuIcon.pvr": "https://files.ballistica.net/cache/ba1/91/d3/6e3b38a70caa2bc68ddff50e3421", "assets/build/ba_data/textures/menuIcon.pvr": "https://files.ballistica.net/cache/ba1/91/d3/6e3b38a70caa2bc68ddff50e3421",
"assets/build/ba_data/textures/menuIcon_preview.png": "https://files.ballistica.net/cache/ba1/1f/22/69a013729949501405ea342a1a8b", "assets/build/ba_data/textures/menuIcon_preview.png": "https://files.ballistica.net/cache/ba1/1f/22/69a013729949501405ea342a1a8b",
"assets/build/ba_data/textures/merch.dds": "https://files.ballistica.net/cache/ba1/da/6a/87779603f295cf30fcec2d3a4e9f",
"assets/build/ba_data/textures/merch.ktx": "https://files.ballistica.net/cache/ba1/5b/c4/2df30594a9e7c1988d92435d7bd0",
"assets/build/ba_data/textures/merch.pvr": "https://files.ballistica.net/cache/ba1/20/76/b8da7a2dded8c137c8388bcb6986",
"assets/build/ba_data/textures/merch_preview.png": "https://files.ballistica.net/cache/ba1/31/ad/9ffc829f1fcff718106d129ac062",
"assets/build/ba_data/textures/meter.dds": "https://files.ballistica.net/cache/ba1/52/a8/00244236f0e6126e2e14bd1f3106", "assets/build/ba_data/textures/meter.dds": "https://files.ballistica.net/cache/ba1/52/a8/00244236f0e6126e2e14bd1f3106",
"assets/build/ba_data/textures/meter.ktx": "https://files.ballistica.net/cache/ba1/b5/e7/3ba3d4c7c7e05081eb033ae5da8f", "assets/build/ba_data/textures/meter.ktx": "https://files.ballistica.net/cache/ba1/b5/e7/3ba3d4c7c7e05081eb033ae5da8f",
"assets/build/ba_data/textures/meter.pvr": "https://files.ballistica.net/cache/ba1/58/de/ab83ad9e0d9b434ca42811030ef9", "assets/build/ba_data/textures/meter.pvr": "https://files.ballistica.net/cache/ba1/58/de/ab83ad9e0d9b434ca42811030ef9",
@ -4003,51 +4008,51 @@
"assets/src/ba_data/python/ba/_generated/__init__.py": "https://files.ballistica.net/cache/ba1/ee/e8/cad05aa531c7faf7ff7b96db7f6e", "assets/src/ba_data/python/ba/_generated/__init__.py": "https://files.ballistica.net/cache/ba1/ee/e8/cad05aa531c7faf7ff7b96db7f6e",
"assets/src/ba_data/python/ba/_generated/enums.py": "https://files.ballistica.net/cache/ba1/1c/77/ac670a5118abdf8a7687af0e159b", "assets/src/ba_data/python/ba/_generated/enums.py": "https://files.ballistica.net/cache/ba1/1c/77/ac670a5118abdf8a7687af0e159b",
"ballisticacore-windows/Generic/BallisticaCore.ico": "https://files.ballistica.net/cache/ba1/89/c0/e32c7d2a35dc9aef57cc73b0911a", "ballisticacore-windows/Generic/BallisticaCore.ico": "https://files.ballistica.net/cache/ba1/89/c0/e32c7d2a35dc9aef57cc73b0911a",
"build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/bd/82/98ea775b22a1113323a1ddf12b6a", "build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/a2/53/595db696c112a17513214a62eee7",
"build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/e1/0d/0a431edcdee394a4e4d5b18608d7", "build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/27/db/57461eaa99ad29c86aab36b26a82",
"build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e8/94/162a944636170ac881d3f3dfd805", "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/63/39/ad16cba9e1efb90fb90ae4d564d2",
"build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/f8/96/fea047474c276064176b65f7e48a", "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/53/1e/40e9ac482c8d04a56e340100fba6",
"build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/12/eb/226aba01e295a807614c64c44d40", "build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/52/6c/5cf1aa6b12d251faea83d2f63377",
"build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/23/71/f60c8e90699d887979c4ad26a2e2", "build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/65/91/1e7e14afff621cb2070644f57bbc",
"build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/8d/80/05e3d712c67d0fcde0e8605d0be7", "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/0e/81/2cd5d6fbe320802b90d81768bd73",
"build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/7f/05/498a538fd28fcd1de964c046b8f2", "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/9a/02/d7393257500edfd7879baeea431d",
"build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d1/6e/01e46632d17db0597d1aef3394d1", "build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/96/a2/6d45db766e7299b743156cce615b",
"build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/aa/12/7992a25feae2b0bf2c8fe2023187", "build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/52/b8/99e7cb5fa71615795f272ed2c518",
"build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/99/a9/2b251e46b2e9e4ef143a0308d9f3", "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/5b/c7/6d474e85577975383f1ae1fc7377",
"build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/6a/93/faee77acd35111d083998df65aaa", "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a1/df/9bd1a6b76c8bf77917753959195f",
"build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/25/c1/9b6efb09c364beae30a40510bfcf", "build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/cb/da/9d073b52363b6bc0240fe5e79bf1",
"build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/18/76/3f8e144f6727ee8f5f5f4b0b6ddc", "build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/c9/23/7060ae125ba189320190b321d947",
"build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/3c/e8/02284ba36b4de9ca68cdd7c3e689", "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/0f/43/49d1bee57cb412e0b7ba59dd9769",
"build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/15/cf/c02041bdc6fa5d0042408e591dcf", "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e0/38/826f664787217ca44d569a5723d7",
"build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/62/98/ee64d80a6332d7d9fc57a2605a2d", "build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/36/57/0c4fc5b9294b04a312ccd8832254",
"build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/54/e6/c4c75d29a7c19c34ba5876c4c350", "build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/98/90/f34fd62f7d512cffd3c9534da10b",
"build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/69/3c/96b8690702f596182a305b5b4489", "build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/cb/a5/5d01935a74a86efcf1885975cfbf",
"build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/b4/b4/81668b3afad33372276a46545aae", "build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/c7/78/a8765f7463f3651c71cd5ca71cb6",
"build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/c3/1a/ae199ada4bf5a649f73fe1663868", "build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/cb/3b/83e6150eebf4fa7b5e8a7b863219",
"build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/a2/4f/2cf4047fdbac4a661ca99d4aedb8", "build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/44/70/2878a73f2d55849cd6a75e401575",
"build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/06/f5/b0fdcf55008fc53e1660f7bc841f", "build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/24/49/4baca475df5c7f87e6ed17d46696",
"build/prefab/lib/linux_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/2c/af/159cc0021a3751da19f4d6832602", "build/prefab/lib/linux_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b9/46/1c6b679ef9db6807100bc0bba261",
"build/prefab/lib/linux_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/cc/4d/0586cbf47105ba1224a445cd72f4", "build/prefab/lib/linux_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/da/b9/427dfd7ae8efbe6009964fe34beb",
"build/prefab/lib/linux_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/f8/33/ae7f84447a19e465c134355b359e", "build/prefab/lib/linux_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/ac/2c/fc0a576c3d957896bfd3de792af6",
"build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/40/eb/004a4ea3094210114fd739cb9fcb", "build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/39/08/4033ab823798c48b3446c032a72c",
"build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/45/ed/36918bf061396d6e1f9814afce8c", "build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/3a/c6/189744027136a7411d5dfa5c5cf4",
"build/prefab/lib/mac_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/8f/28/7c06af16187bf2db92cf99763f4e", "build/prefab/lib/mac_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/7a/c6/bb533c59368fdf45d65812f37a27",
"build/prefab/lib/mac_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/44/69/aeba19cb88e6b57c4ab9325f5877", "build/prefab/lib/mac_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/3c/6e/88180b8e905df7453e0f989da027",
"build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/69/4e/b8354e50de6f2afb45b342919868", "build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/2c/99/e35a71c9c410da4035a0456314b6",
"build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/70/65/f35430e7328bc7ac30de3960dfc7", "build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/47/b2/bb092304cd5e5f3bdb6e085197de",
"build/prefab/lib/mac_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/d8/47/c89b62ea5a71854b303f9e85e5d7", "build/prefab/lib/mac_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/9c/ce/523a32e2dce1174df21373ab5765",
"build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/f2/19/280f6773b3563e012ad6bfad33dc", "build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b2/ee/b908410d85c763d5bca09a3bc893",
"build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1e/17/e5ef6ce0e41360a43c63ef9c0974", "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/fd/24/8c51e5752064d4b487dff42a7ffa",
"build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/cd/78/1adf82e5c3e456d2ea2d4290c61c", "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/93/c6/40e9e47dd33c88646510212ff321",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/6b/5f/c5dc0b2a2809bc1d3ea57fb985d0", "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/a1/25/ca39d9309b63ed38bee3be1f9e67",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/56/ee/49a4a2d7940fd239a66c04657c90", "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/b3/ef/a6240c67194508ac7bd58ba73391",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/39/72/fb9b3400c5d128ad156818ede03d", "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/c2/cc/b081c59aa873304086d2e44c1f30",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/22/65/550d27a960822b8846c0c0a440be", "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/d3/cb/8c1ed9ee3e8f1b0d866160257506",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/33/3b/9b38515580edd4616f9955f7e33c", "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/f0/f8/fb0aa403b29d5c5a9ebb5aec66fb",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/fe/1a/80ddcd73df9985ab768e617a6c2b", "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/0b/03/ed35c58e80013f47b57c838c12d4",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/1d/27/0d69901bb721f986fdbfc490100f", "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/18/1d/cfc42a4939904783c897322207aa",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/e9/59/ab5278ceeae3656f91c6d3c68c83", "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/68/82/c02d830bdb12f57ae976c5acc4fb",
"src/ballistica/generated/python_embedded/binding.inc": "https://files.ballistica.net/cache/ba1/25/77/8093dfffddaa80cd513ddaa61867", "src/ballistica/generated/python_embedded/binding.inc": "https://files.ballistica.net/cache/ba1/23/ce/68396b1b7ec6d2f8425902148140",
"src/ballistica/generated/python_embedded/bootstrap.inc": "https://files.ballistica.net/cache/ba1/2d/4f/f4fe67827f36cd59cd5193333a02", "src/ballistica/generated/python_embedded/bootstrap.inc": "https://files.ballistica.net/cache/ba1/2d/4f/f4fe67827f36cd59cd5193333a02",
"src/ballistica/generated/python_embedded/bootstrap_monolithic.inc": "https://files.ballistica.net/cache/ba1/ef/c1/aa5f1aa10af89f5c0b1e616355fd" "src/ballistica/generated/python_embedded/bootstrap_monolithic.inc": "https://files.ballistica.net/cache/ba1/ef/c1/aa5f1aa10af89f5c0b1e616355fd"
} }

View File

@ -16,9 +16,9 @@ jobs:
check_linux: check_linux:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: '3.10' python-version: '3.10'
- name: Install dependencies - name: Install dependencies
@ -31,9 +31,9 @@ jobs:
compile_linux: compile_linux:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: '3.10' python-version: '3.10'
- name: Compile binary - name: Compile binary
@ -47,9 +47,9 @@ jobs:
check_and_compile_windows: check_and_compile_windows:
runs-on: windows-2019 runs-on: windows-2019
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: '3.10' python-version: '3.10'
- name: Install dependencies - name: Install dependencies

View File

@ -73,6 +73,7 @@
<w>allsettings</w> <w>allsettings</w>
<w>allteams</w> <w>allteams</w>
<w>allwarnings</w> <w>allwarnings</w>
<w>alogins</w>
<w>aman</w> <w>aman</w>
<w>amazonaws</w> <w>amazonaws</w>
<w>aname</w> <w>aname</w>
@ -114,6 +115,7 @@
<w>appmode</w> <w>appmode</w>
<w>appname</w> <w>appname</w>
<w>appnameupper</w> <w>appnameupper</w>
<w>appnow</w>
<w>appspot</w> <w>appspot</w>
<w>appstate</w> <w>appstate</w>
<w>appstore</w> <w>appstore</w>
@ -184,6 +186,7 @@
<w>availplug</w> <w>availplug</w>
<w>aval</w> <w>aval</w>
<w>awaitable</w> <w>awaitable</w>
<w>awaitables</w>
<w>axismotion</w> <w>axismotion</w>
<w>bacfg</w> <w>bacfg</w>
<w>backgrounded</w> <w>backgrounded</w>
@ -374,6 +377,7 @@
<w>certifi</w> <w>certifi</w>
<w>cfconfig</w> <w>cfconfig</w>
<w>cfenv</w> <w>cfenv</w>
<w>cfgdict</w>
<w>cfgdir</w> <w>cfgdir</w>
<w>cfgkey</w> <w>cfgkey</w>
<w>cfgkeys</w> <w>cfgkeys</w>
@ -677,6 +681,7 @@
<w>dosomething</w> <w>dosomething</w>
<w>dostar</w> <w>dostar</w>
<w>dostuff</w> <w>dostuff</w>
<w>downcasting</w>
<w>downmix</w> <w>downmix</w>
<w>dpad</w> <w>dpad</w>
<w>dpath</w> <w>dpath</w>
@ -718,6 +723,7 @@
<w>dummyret</w> <w>dummyret</w>
<w>dummytoken</w> <w>dummytoken</w>
<w>dummyval</w> <w>dummyval</w>
<w>dumpminlog</w>
<w>dups</w> <w>dups</w>
<w>dval</w> <w>dval</w>
<w>dxml</w> <w>dxml</w>
@ -726,6 +732,7 @@
<w>eaddrnotavail</w> <w>eaddrnotavail</w>
<w>easteregghunt</w> <w>easteregghunt</w>
<w>echofile</w> <w>echofile</w>
<w>echotime</w>
<w>edcc</w> <w>edcc</w>
<w>editcontroller</w> <w>editcontroller</w>
<w>editgame</w> <w>editgame</w>
@ -848,6 +855,7 @@
<w>fakeshake</w> <w>fakeshake</w>
<w>fallbacks</w> <w>fallbacks</w>
<w>farthestpt</w> <w>farthestpt</w>
<w>faulthandler</w>
<w>fback</w> <w>fback</w>
<w>fbase</w> <w>fbase</w>
<w>fclose</w> <w>fclose</w>
@ -858,6 +866,7 @@
<w>fcontents</w> <w>fcontents</w>
<w>fcount</w> <w>fcount</w>
<w>fdata</w> <w>fdata</w>
<w>fdcount</w>
<w>fdesc</w> <w>fdesc</w>
<w>fdict</w> <w>fdict</w>
<w>fdout</w> <w>fdout</w>
@ -1271,6 +1280,7 @@
<w>iprof</w> <w>iprof</w>
<w>isatty</w> <w>isatty</w>
<w>iscale</w> <w>iscale</w>
<w>iscoroutinefunction</w>
<w>iserverget</w> <w>iserverget</w>
<w>iserverput</w> <w>iserverput</w>
<w>ispunch</w> <w>ispunch</w>
@ -1372,6 +1382,7 @@
<w>levelmodule</w> <w>levelmodule</w>
<w>levelname</w> <w>levelname</w>
<w>levelno</w> <w>levelno</w>
<w>levelnos</w>
<w>levelstr</w> <w>levelstr</w>
<w>lfull</w> <w>lfull</w>
<w>lfval</w> <w>lfval</w>
@ -1522,6 +1533,7 @@
<w>masterhash</w> <w>masterhash</w>
<w>masterver</w> <w>masterver</w>
<w>mastervers</w> <w>mastervers</w>
<w>matchlines</w>
<w>mathmodule</w> <w>mathmodule</w>
<w>mathnode</w> <w>mathnode</w>
<w>mathutils</w> <w>mathutils</w>
@ -1537,6 +1549,7 @@
<w>mdiv</w> <w>mdiv</w>
<w>mdocs</w> <w>mdocs</w>
<w>mdocslines</w> <w>mdocslines</w>
<w>mdpath</w>
<w>mdpi</w> <w>mdpi</w>
<w>megalint</w> <w>megalint</w>
<w>memfunctions</w> <w>memfunctions</w>
@ -1564,6 +1577,7 @@
<w>minigame</w> <w>minigame</w>
<w>minigames</w> <w>minigames</w>
<w>miniplayer</w> <w>miniplayer</w>
<w>minlog</w>
<w>minping</w> <w>minping</w>
<w>minusbutton</w> <w>minusbutton</w>
<w>minval</w> <w>minval</w>
@ -2337,6 +2351,7 @@
<w>shobs</w> <w>shobs</w>
<w>shortname</w> <w>shortname</w>
<w>shouldn</w> <w>shouldn</w>
<w>showbuffer</w>
<w>showpoints</w> <w>showpoints</w>
<w>showstats</w> <w>showstats</w>
<w>showsubseconds</w> <w>showsubseconds</w>
@ -2513,6 +2528,7 @@
<w>svne</w> <w>svne</w>
<w>svvv</w> <w>svvv</w>
<w>swht</w> <w>swht</w>
<w>swidth</w>
<w>swiftc</w> <w>swiftc</w>
<w>swip</w> <w>swip</w>
<w>swipsound</w> <w>swipsound</w>
@ -2549,6 +2565,9 @@
<w>targs</w> <w>targs</w>
<w>tasklabel</w> <w>tasklabel</w>
<w>tbegin</w> <w>tbegin</w>
<w>tbfile</w>
<w>tbfiles</w>
<w>tbpath</w>
<w>tbtcolor</w> <w>tbtcolor</w>
<w>tbtn</w> <w>tbtn</w>
<w>tbttxt</w> <w>tbttxt</w>
@ -2850,6 +2869,7 @@
<w>wanttype</w> <w>wanttype</w>
<w>warntype</w> <w>warntype</w>
<w>wasdead</w> <w>wasdead</w>
<w>wasn</w>
<w>wavenum</w> <w>wavenum</w>
<w>weakref</w> <w>weakref</w>
<w>weakrefs</w> <w>weakrefs</w>
@ -2859,7 +2879,9 @@
<w>webpage</w> <w>webpage</w>
<w>webpages</w> <w>webpages</w>
<w>weeeird</w> <w>weeeird</w>
<w>whatarev</w>
<w>whatevs</w> <w>whatevs</w>
<w>whatisv</w>
<w>wheee</w> <w>wheee</w>
<w>whos</w> <w>whos</w>
<w>widgetdeathtime</w> <w>widgetdeathtime</w>

View File

@ -1,12 +1,46 @@
### 1.7.14 (build 20934, api 7, 2022-11-16) ### 1.7.19 (build 20993, api 7, 2023-01-17)
### 1.7.18 (build 20989, api 7, 2023-01-16)
- Reworked some low level asynchronous messaging functionality in efro.message and efro.rpc. Previously these were a little *too* asynchronous which could lead to messages being received in a different order than they were sent, which is not desirable.
- Added a way to suppress 'Your build is outdated' messages at launch (see `ba._hooks.show_client_too_old_error()`).
### 1.7.17 (build 20983, api 7, 2023-01-09)
- V2 accounts now show a 'Unlink Legacy (V1) Accounts' button in account settings if they have any old V1 links present. This can be used to clear out old links to replace them with V2 links which work correctly with V2 accounts.
- `ba.internal.dump_tracebacks()` is now `ba.internal.dump_app_state()` and `ba.internal.log_dumped_tracebacks()` is now `ba.internal.log_dumped_app_state()`. This reflects the fact that these calls may be expanded to include other app state in the future (C++ layer thread states, etc.).
- Added `ba.app.health_monitor` which will dump app state if the logic thread ever stops responding for 5+ seconds while the app is running (to help diagnose deadlock situations).
- Various extra logging and bug fixes related to V2 accounts and master server communication (trying to get this stuff working as smoothly as possible now that it is feature-complete).
### 1.7.16 (build 20969, api 7, 2022-12-18)
- Fixed a bug where profile names encased in curly brackets could cause harmless error messages.
- Android will no longer log errors on ba.open_url() calls if a browser is not available (it still just falls back to the in-app dialog in that case).
- The 'Upgrade' button for device accounts now signs you out and closes the upgrade window to hopefully make it more clear that you need to sign in with your newly created/upgraded BombSquad account.
- Fixed a bug where the remote app could not connect for the first 5 seconds after launching the app.
- Added Malay language. Ick; apparently its been sitting done for a while and I hadn't realized it wasn't added to the game yet. Apologies!. And thanks to all contributors!
- Added 'enable_queue' server config setting. This defaults to True but can be turned off as a workaround for server owners targeted by queue spam attacks.
- The public party list no longer sorts servers without queues at the end of the list. This sorting was put there long ago to prioritize fancy new queue-supporting servers but now it would just make the few that opt out of queues hard to find. Doh. So opting out of queues is probably not a great idea until this build is widespread.
- Public uuids now only change once every 6 months or so instead of with every version bump. This way periods of heavy development won't put added strain on server owners trying to keep ban lists up to date and whatnot.
- Added a merch button in the in-game store that goes to the ballistica.net merch page (though it only shows up in the few countries where merch is available).
### 1.7.15 (build 20960, api 7, 2022-12-04)
- The cancel button on the 'Sign in with a Bombsquad Account' popup no longer respond to system cancel buttons (escape key, android back button, etc). Turns out some Android people were pressing back repeatedly to come back from a browser after signing in and immediately canceling their sign in attempts in the game before they completed. Hopefully this will avoid some frustration.
- Fixed an issue where back presses could result in multiple main menu windows appearing.
### 1.7.14 (build 20958, api 7, 2022-12-03)
- Android Google Play logins now provide V2 accounts with access to all V2 features such as a globally-unique account tag, cloud-console, and workspaces. They should still retain their V1 data as well. - Android Google Play logins now provide V2 accounts with access to all V2 features such as a globally-unique account tag, cloud-console, and workspaces. They should still retain their V1 data as well.
- V2 accounts now have a 'Manage Account' button in the app account window which will sign you into a browser with your current account. - V2 accounts now have a 'Manage Account' button in the app account window which will sign you into a browser with your current account.
- Removed Google App Invite functionality which has been deprecated for a while now. Google Play users can still get tickets by sharing the app via codes (same as other platforms). - Removed Google App Invite functionality which has been deprecated for a while now. Google Play users can still get tickets by sharing the app via codes (same as other platforms).
- Updated Android root-detection library to the latest version. Please holler if you are getting new false 'your device is rooted' errors when trying to play tournaments or anything like that. - Updated Android root-detection library to the latest version. Please holler if you are getting new false 'your device is rooted' errors when trying to play tournaments or anything like that.
- Removed a few obsolete internal functions: `_ba.is_ouya_build()`, `_ba.android_media_scan_file()`. - Removed a few obsolete internal functions: `_ba.is_ouya_build()`, `_ba.android_media_scan_file()`.
- Renaming some methods/data to disambiguate 'login' vs 'sign-in', both in the app and on ballistica.net. Those two terms are somewhat ambiguous and interchangeable in English and can either be a verb or a noun. I'd like to keep things clear in Ballistica by always using 'sign-in' for the verb form and 'login' for the noun. For example: 'You can now sign in to your account using your Google Play login'. - Renaming some methods/data to disambiguate 'login' vs 'sign-in', both in the app and on ballistica.net. Those two terms are somewhat ambiguous and interchangeable in English and can either be a verb or a noun. I'd like to keep things clear in Ballistica by always using 'sign-in' for the verb form and 'login' for the noun. For example: 'You can now sign in to your account using your Google Play login'.
- WARNING: There are currently some rough edges with Google Play V2 accounts; for example Google Play achievements and leaderboards UIs are not currently showing up. I will be cleaning all of this up before the official 1.7.14 release. - Fixed the 'your config is broken' dialog that shows on desktop builds if the game's config file is corrupt and can't be read. It should let you edit the config or replace it with a default.
- Commit Last Manual Party Connect Port to config. Previously, it always assumed the port to be 43210. - `ba.printobjects()` is now `ba.ls_objects()`. It technically logs and doesn't print so the former name was a bit misleading.
- Added `ba.ls_input_devices()` to dump debug info about the current set of input devices. Can be helpful to diagnose mysterious devices joining games unintentionally and things like that.
- Added 'raw' bool arg to `ba.pushcall()`. Passing True for it disables context save/restore and thread checks.
- Added `ba.internal.dump_tracebacks()` which can be used to dump the stack state of all Python threads after some delay. Useful for debugging deadlock; just call right before said deadlock occurs. Results will be logged on the next app launch if they cannot be immediately.
- Fixed a low level event-loop issue that in some cases was preventing the Android version from properly pausing/resuming the app or managing connections while in the background. If you look at the devices section on ballistica.net you should now see your device disappear when you background the app and reappear when you foreground it. Please holler if not.
- Device accounts are now marked as deprecated, and signing in with one now brings up an 'upgrade' UI which allows converting it to a V2 account. It is my hope to push the entire client ecosystem to V2 accounts as quickly as possible since trying to support both independent V1 accounts and V2 accounts is a substantial technical burden.
- Fixed an issue where Log calls made within `Thread::PushThreadMessage()` could result in deadlock.
- Fixed an issue where some Android hardware buttons could theoretically cause rogue game controller button presses (due to downcasting int values > 255 into a uint8 value).
### 1.7.13 (build 20919, api 7, 2022-11-03) ### 1.7.13 (build 20919, api 7, 2022-11-03)
- Android target-sdk has been updated to 33 (Android 13). Please holler if anything seems broken or is behaving differently than before on Android. - Android target-sdk has been updated to 33 (Android 13). Please holler if anything seems broken or is behaving differently than before on Android.

View File

@ -39,3 +39,7 @@
### Vishal332008 ### Vishal332008
- Bug Fixer - Bug Fixer
- Modder - Modder
### Era0S
- Fixed a single bug
- Modder

View File

@ -441,6 +441,7 @@
"ba_data/data/languages/indonesian.json", "ba_data/data/languages/indonesian.json",
"ba_data/data/languages/italian.json", "ba_data/data/languages/italian.json",
"ba_data/data/languages/korean.json", "ba_data/data/languages/korean.json",
"ba_data/data/languages/malay.json",
"ba_data/data/languages/persian.json", "ba_data/data/languages/persian.json",
"ba_data/data/languages/polish.json", "ba_data/data/languages/polish.json",
"ba_data/data/languages/portuguese.json", "ba_data/data/languages/portuguese.json",
@ -1880,6 +1881,10 @@
"ba_data/textures/menuIcon.ktx", "ba_data/textures/menuIcon.ktx",
"ba_data/textures/menuIcon.pvr", "ba_data/textures/menuIcon.pvr",
"ba_data/textures/menuIcon_preview.png", "ba_data/textures/menuIcon_preview.png",
"ba_data/textures/merch.dds",
"ba_data/textures/merch.ktx",
"ba_data/textures/merch.pvr",
"ba_data/textures/merch_preview.png",
"ba_data/textures/meter.dds", "ba_data/textures/meter.dds",
"ba_data/textures/meter.ktx", "ba_data/textures/meter.ktx",
"ba_data/textures/meter.pvr", "ba_data/textures/meter.pvr",

View File

@ -356,6 +356,7 @@
"ba_data/python/bastd/ui/__pycache__/tournamentscores.cpython-310.opt-1.pyc", "ba_data/python/bastd/ui/__pycache__/tournamentscores.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/__pycache__/trophies.cpython-310.opt-1.pyc", "ba_data/python/bastd/ui/__pycache__/trophies.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/__pycache__/url.cpython-310.opt-1.pyc", "ba_data/python/bastd/ui/__pycache__/url.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/__pycache__/v2upgrade.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/__pycache__/watch.cpython-310.opt-1.pyc", "ba_data/python/bastd/ui/__pycache__/watch.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/account/__init__.py", "ba_data/python/bastd/ui/account/__init__.py",
"ba_data/python/bastd/ui/account/__pycache__/__init__.cpython-310.opt-1.pyc", "ba_data/python/bastd/ui/account/__pycache__/__init__.cpython-310.opt-1.pyc",
@ -514,6 +515,7 @@
"ba_data/python/bastd/ui/tournamentscores.py", "ba_data/python/bastd/ui/tournamentscores.py",
"ba_data/python/bastd/ui/trophies.py", "ba_data/python/bastd/ui/trophies.py",
"ba_data/python/bastd/ui/url.py", "ba_data/python/bastd/ui/url.py",
"ba_data/python/bastd/ui/v2upgrade.py",
"ba_data/python/bastd/ui/watch.py", "ba_data/python/bastd/ui/watch.py",
"ba_data/python/efro/__init__.py", "ba_data/python/efro/__init__.py",
"ba_data/python/efro/__pycache__/__init__.cpython-310.opt-1.pyc", "ba_data/python/efro/__pycache__/__init__.cpython-310.opt-1.pyc",

View File

@ -381,6 +381,7 @@ SCRIPT_TARGETS_PY_PUBLIC = \
build/ba_data/python/bastd/ui/tournamentscores.py \ build/ba_data/python/bastd/ui/tournamentscores.py \
build/ba_data/python/bastd/ui/trophies.py \ build/ba_data/python/bastd/ui/trophies.py \
build/ba_data/python/bastd/ui/url.py \ build/ba_data/python/bastd/ui/url.py \
build/ba_data/python/bastd/ui/v2upgrade.py \
build/ba_data/python/bastd/ui/watch.py \ build/ba_data/python/bastd/ui/watch.py \
build/server/ballisticacore_server.py build/server/ballisticacore_server.py
@ -633,6 +634,7 @@ SCRIPT_TARGETS_PYC_PUBLIC = \
build/ba_data/python/bastd/ui/__pycache__/tournamentscores.cpython-310.opt-1.pyc \ build/ba_data/python/bastd/ui/__pycache__/tournamentscores.cpython-310.opt-1.pyc \
build/ba_data/python/bastd/ui/__pycache__/trophies.cpython-310.opt-1.pyc \ build/ba_data/python/bastd/ui/__pycache__/trophies.cpython-310.opt-1.pyc \
build/ba_data/python/bastd/ui/__pycache__/url.cpython-310.opt-1.pyc \ build/ba_data/python/bastd/ui/__pycache__/url.cpython-310.opt-1.pyc \
build/ba_data/python/bastd/ui/__pycache__/v2upgrade.cpython-310.opt-1.pyc \
build/ba_data/python/bastd/ui/__pycache__/watch.cpython-310.opt-1.pyc \ build/ba_data/python/bastd/ui/__pycache__/watch.cpython-310.opt-1.pyc \
build/server/__pycache__/ballisticacore_server.cpython-310.opt-1.pyc build/server/__pycache__/ballisticacore_server.cpython-310.opt-1.pyc
@ -4915,6 +4917,7 @@ DATA_TARGETS = \
build/ba_data/data/languages/indonesian.json \ build/ba_data/data/languages/indonesian.json \
build/ba_data/data/languages/italian.json \ build/ba_data/data/languages/italian.json \
build/ba_data/data/languages/korean.json \ build/ba_data/data/languages/korean.json \
build/ba_data/data/languages/malay.json \
build/ba_data/data/languages/persian.json \ build/ba_data/data/languages/persian.json \
build/ba_data/data/languages/polish.json \ build/ba_data/data/languages/polish.json \
build/ba_data/data/languages/portuguese.json \ build/ba_data/data/languages/portuguese.json \
@ -5595,6 +5598,7 @@ TEX2D_DDS_TARGETS = \
build/ba_data/textures/menuBG.dds \ build/ba_data/textures/menuBG.dds \
build/ba_data/textures/menuButton.dds \ build/ba_data/textures/menuButton.dds \
build/ba_data/textures/menuIcon.dds \ build/ba_data/textures/menuIcon.dds \
build/ba_data/textures/merch.dds \
build/ba_data/textures/meter.dds \ build/ba_data/textures/meter.dds \
build/ba_data/textures/monkeyFaceLevelColor.dds \ build/ba_data/textures/monkeyFaceLevelColor.dds \
build/ba_data/textures/monkeyFacePreview.dds \ build/ba_data/textures/monkeyFacePreview.dds \
@ -6000,6 +6004,7 @@ TEX2D_PVR_TARGETS = \
build/ba_data/textures/menuBG.pvr \ build/ba_data/textures/menuBG.pvr \
build/ba_data/textures/menuButton.pvr \ build/ba_data/textures/menuButton.pvr \
build/ba_data/textures/menuIcon.pvr \ build/ba_data/textures/menuIcon.pvr \
build/ba_data/textures/merch.pvr \
build/ba_data/textures/meter.pvr \ build/ba_data/textures/meter.pvr \
build/ba_data/textures/monkeyFaceLevelColor.pvr \ build/ba_data/textures/monkeyFaceLevelColor.pvr \
build/ba_data/textures/monkeyFacePreview.pvr \ build/ba_data/textures/monkeyFacePreview.pvr \
@ -6405,6 +6410,7 @@ TEX2D_KTX_TARGETS = \
build/ba_data/textures/menuBG.ktx \ build/ba_data/textures/menuBG.ktx \
build/ba_data/textures/menuButton.ktx \ build/ba_data/textures/menuButton.ktx \
build/ba_data/textures/menuIcon.ktx \ build/ba_data/textures/menuIcon.ktx \
build/ba_data/textures/merch.ktx \
build/ba_data/textures/meter.ktx \ build/ba_data/textures/meter.ktx \
build/ba_data/textures/monkeyFaceLevelColor.ktx \ build/ba_data/textures/monkeyFaceLevelColor.ktx \
build/ba_data/textures/monkeyFacePreview.ktx \ build/ba_data/textures/monkeyFacePreview.ktx \
@ -6810,6 +6816,7 @@ TEX2D_PREVIEW_PNG_TARGETS = \
build/ba_data/textures/menuBG_preview.png \ build/ba_data/textures/menuBG_preview.png \
build/ba_data/textures/menuButton_preview.png \ build/ba_data/textures/menuButton_preview.png \
build/ba_data/textures/menuIcon_preview.png \ build/ba_data/textures/menuIcon_preview.png \
build/ba_data/textures/merch_preview.png \
build/ba_data/textures/meter_preview.png \ build/ba_data/textures/meter_preview.png \
build/ba_data/textures/monkeyFaceLevelColor_preview.png \ build/ba_data/textures/monkeyFaceLevelColor_preview.png \
build/ba_data/textures/monkeyFacePreview_preview.png \ build/ba_data/textures/monkeyFacePreview_preview.png \

View File

@ -1 +1 @@
136821726394202151644063370854718971574 71864217068887285722858773141608052966

View File

@ -2429,12 +2429,42 @@ def lock_all_input() -> None:
return None return None
def login_adapter_back_end_active_change(login_type: str, active: bool) -> None:
"""(internal)"""
return None
def login_adapter_get_sign_in_token(login_type: str, attempt_id: int) -> None: def login_adapter_get_sign_in_token(login_type: str, attempt_id: int) -> None:
"""(internal)""" """(internal)"""
return None return None
def ls_input_devices() -> None:
"""Print debugging info about game objects.
Category: **General Utility Functions**
This call only functions in debug builds of the game.
It prints various info about the current object count, etc.
"""
return None
def ls_objects() -> None:
"""Log debugging info about C++ level objects.
Category: **General Utility Functions**
This call only functions in debug builds of the game.
It prints various info about the current object count, etc.
"""
return None
def mac_music_app_get_library_source() -> None: def mac_music_app_get_library_source() -> None:
"""(internal)""" """(internal)"""
@ -2653,32 +2683,17 @@ def printnodes() -> None:
return None return None
def printobjects() -> None:
"""Print debugging info about game objects.
Category: **General Utility Functions**
This call only functions in debug builds of the game.
It prints various info about the current object count, etc.
"""
return None
def pushcall( def pushcall(
call: Callable, call: Callable,
from_other_thread: bool = False, from_other_thread: bool = False,
suppress_other_thread_warning: bool = False, suppress_other_thread_warning: bool = False,
other_thread_use_fg_context: bool = False, other_thread_use_fg_context: bool = False,
raw: bool = False,
) -> None: ) -> None:
"""Pushes a call onto the event loop to be run during the next cycle. """Push a call to the logic event-loop.
Category: **General Utility Functions** Category: **General Utility Functions**
This can be handy for calls that are disallowed from within other
callbacks, etc.
This call expects to be used in the logic thread, and will automatically This call expects to be used in the logic thread, and will automatically
save and restore the ba.Context to behave seamlessly. save and restore the ba.Context to behave seamlessly.
@ -2687,6 +2702,7 @@ def pushcall(
the call will always run in the UI context on the logic thread the call will always run in the UI context on the logic thread
or whichever context is in the foreground if or whichever context is in the foreground if
other_thread_use_fg_context is True. other_thread_use_fg_context is True.
Passing raw=True will disable thread checks and context sets/restores.
""" """
return None return None
@ -3009,6 +3025,12 @@ def set_public_party_name(name: str) -> None:
return None return None
def set_public_party_queue_enabled(max_size: bool) -> None:
"""(internal)"""
return None
def set_public_party_stats_url(url: str | None) -> None: def set_public_party_stats_url(url: str | None) -> None:
"""(internal)""" """(internal)"""

View File

@ -252,7 +252,6 @@ def submit_score(
name: Any, name: Any,
score: int | None, score: int | None,
callback: Callable, callback: Callable,
friend_callback: Callable | None,
order: str = 'increasing', order: str = 'increasing',
tournament_id: str | None = None, tournament_id: str | None = None,
score_type: str = 'points', score_type: str = 'points',

View File

@ -43,7 +43,8 @@ from _ba import (
newnode, newnode,
playsound, playsound,
printnodes, printnodes,
printobjects, ls_objects,
ls_input_devices,
pushcall, pushcall,
quit, quit,
rowwidget, rowwidget,
@ -316,7 +317,8 @@ __all__ = [
'print_error', 'print_error',
'print_exception', 'print_exception',
'printnodes', 'printnodes',
'printobjects', 'ls_objects',
'ls_input_devices',
'pushcall', 'pushcall',
'quit', 'quit',
'rowwidget', 'rowwidget',

View File

@ -48,6 +48,9 @@ class AccountV1Subsystem:
_ba.pushcall(do_auto_sign_in) _ba.pushcall(do_auto_sign_in)
def on_app_pause(self) -> None:
"""Should be called when app is pausing."""
def on_app_resume(self) -> None: def on_app_resume(self) -> None:
"""Should be called when the app is resumed.""" """Should be called when the app is resumed."""

View File

@ -4,17 +4,24 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from efro.call import tpartial
from efro.error import CommunicationError
from bacommon.login import LoginType
import _ba import _ba
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any
from bacommon.login import LoginType
from ba._login import LoginAdapter from ba._login import LoginAdapter
DEBUG_LOG = False
class AccountV2Subsystem: class AccountV2Subsystem:
"""Subsystem for modern account handling in the app. """Subsystem for modern account handling in the app.
@ -24,20 +31,20 @@ class AccountV2Subsystem:
""" """
def __init__(self) -> None: def __init__(self) -> None:
from bacommon.login import LoginType
# Whether or not everything related to an initial login # Whether or not everything related to an initial login
# (or lack thereof) has completed. This includes things like # (or lack thereof) has completed. This includes things like
# workspace syncing. Completion of this is what flips the app # workspace syncing. Completion of this is what flips the app
# into 'running' state. # into 'running' state.
self._initial_login_completed = False self._initial_sign_in_completed = False
self._kicked_off_workspace_load = False self._kicked_off_workspace_load = False
self.login_adapters: dict[LoginType, LoginAdapter] = {} self.login_adapters: dict[LoginType, LoginAdapter] = {}
self._implicit_signed_in_adapter: LoginAdapter | None = None self._implicit_signed_in_adapter: LoginAdapter | None = None
self._auto_signed_in = False self._implicit_state_changed = False
self._can_do_auto_sign_in = True
if _ba.app.platform == 'android' and _ba.app.subplatform == 'google': if _ba.app.platform == 'android' and _ba.app.subplatform == 'google':
from ba._login import LoginAdapterGPGS from ba._login import LoginAdapterGPGS
@ -91,7 +98,7 @@ class AccountV2Subsystem:
if account.workspaceid is not None: if account.workspaceid is not None:
assert account.workspacename is not None assert account.workspacename is not None
if ( if (
not self._initial_login_completed not self._initial_sign_in_completed
and not self._kicked_off_workspace_load and not self._kicked_off_workspace_load
): ):
self._kicked_off_workspace_load = True self._kicked_off_workspace_load = True
@ -114,9 +121,9 @@ class AccountV2Subsystem:
return return
# Ok; no workspace to worry about; carry on. # Ok; no workspace to worry about; carry on.
if not self._initial_login_completed: if not self._initial_sign_in_completed:
self._initial_login_completed = True self._initial_sign_in_completed = True
_ba.app.on_initial_login_completed() _ba.app.on_initial_sign_in_completed()
def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None: def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
"""Should be called when logins for the active account change.""" """Should be called when logins for the active account change."""
@ -127,7 +134,7 @@ class AccountV2Subsystem:
def on_implicit_sign_in( def on_implicit_sign_in(
self, login_type: LoginType, login_id: str, display_name: str self, login_type: LoginType, login_id: str, display_name: str
) -> None: ) -> None:
"""An implicit login happened.""" """An implicit sign-in happened (called by native layer)."""
from ba._login import LoginAdapter from ba._login import LoginAdapter
with _ba.Context('ui'): with _ba.Context('ui'):
@ -138,7 +145,7 @@ class AccountV2Subsystem:
) )
def on_implicit_sign_out(self, login_type: LoginType) -> None: def on_implicit_sign_out(self, login_type: LoginType) -> None:
"""An implicit logout happened.""" """An implicit sign-out happened (called by native layer)."""
with _ba.Context('ui'): with _ba.Context('ui'):
self.login_adapters[login_type].set_implicit_login_state(None) self.login_adapters[login_type].set_implicit_login_state(None)
@ -149,9 +156,15 @@ class AccountV2Subsystem:
within a few seconds of app launch; the app can move forward within a few seconds of app launch; the app can move forward
with the startup sequence at that point. with the startup sequence at that point.
""" """
if not self._initial_login_completed: if not self._initial_sign_in_completed:
self._initial_login_completed = True self._initial_sign_in_completed = True
_ba.app.on_initial_login_completed() _ba.app.on_initial_sign_in_completed()
@staticmethod
def _hashstr(val: str) -> str:
md5 = hashlib.md5()
md5.update(val.encode())
return md5.hexdigest()
def on_implicit_login_state_changed( def on_implicit_login_state_changed(
self, self,
@ -160,18 +173,75 @@ class AccountV2Subsystem:
) -> None: ) -> None:
"""Called when implicit login state changes. """Called when implicit login state changes.
Logins that tend to sign themselves in/out in the background are Login systems that tend to sign themselves in/out in the
considered implicit. We may choose to honor or ignore their states, background are considered implicit. We may choose to honor or
allowing the user to opt for other login types even if the default ignore their states, allowing the user to opt for other login
implicit one can't be explicitly logged out or otherwise controlled. types even if the default implicit one can't be explicitly
logged out or otherwise controlled.
""" """
from ba._language import Lstr
assert _ba.in_logic_thread() assert _ba.in_logic_thread()
cfg = _ba.app.config
cfgkey = 'ImplicitLoginStates'
cfgdict = _ba.app.config.setdefault(cfgkey, {})
# Store which (if any) adapter is currently implicitly signed in. # Store which (if any) adapter is currently implicitly signed in.
# Making the assumption there will only ever be one implicit
# adapter at a time; may need to update this if that changes.
prev_state = cfgdict.get(login_type.value)
if state is None: if state is None:
self._implicit_signed_in_adapter = None self._implicit_signed_in_adapter = None
new_state = cfgdict[login_type.value] = None
else: else:
self._implicit_signed_in_adapter = self.login_adapters[login_type] self._implicit_signed_in_adapter = self.login_adapters[login_type]
new_state = cfgdict[login_type.value] = self._hashstr(
state.login_id
)
# Special case: if the user is already signed in but not with
# this implicit login, we may want to let them know that the
# 'Welcome back FOO' they likely just saw is not actually
# accurate.
if (
self.primary is not None
and not self.login_adapters[login_type].is_back_end_active()
):
if login_type is LoginType.GPGS:
service_str = Lstr(resource='googlePlayText')
else:
service_str = None
if service_str is not None:
_ba.timer(
2.0,
tpartial(
_ba.screenmessage,
Lstr(
resource='notUsingAccountText',
subs=[
('${ACCOUNT}', state.display_name),
('${SERVICE}', service_str),
],
),
(1, 0.5, 0),
),
)
cfg.commit()
# We want to respond any time the implicit state changes;
# generally this means the user has explicitly signed in/out or
# switched accounts within that back-end.
if prev_state != new_state:
if DEBUG_LOG:
logging.debug(
'AccountV2: Implicit state changed (%s -> %s);'
' will update app sign-in state accordingly.',
prev_state,
new_state,
)
self._implicit_state_changed = True
# We may want to auto-sign-in based on this new state. # We may want to auto-sign-in based on this new state.
self._update_auto_sign_in() self._update_auto_sign_in()
@ -187,12 +257,59 @@ class AccountV2Subsystem:
def _update_auto_sign_in(self) -> None: def _update_auto_sign_in(self) -> None:
from ba._internal import get_v1_account_state from ba._internal import get_v1_account_state
# We attempt auto-sign-in only once. # If implicit state has changed, try to respond.
if self._auto_signed_in: if self._implicit_state_changed:
if self._implicit_signed_in_adapter is None:
# If implicit back-end is signed out, follow suit
# immediately; no need to wait for network connectivity.
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing out as result'
' of implicit state change...',
)
_ba.app.accounts_v2.set_primary_credentials(None)
self._implicit_state_changed = False
# Once we've made a move here we don't want to
# do any more automatic stuff.
self._can_do_auto_sign_in = False
else:
# Ok; we've got a new implicit state. If we've got
# connectivity, let's attempt to sign in with it.
# Consider this an 'explicit' sign in because the
# implicit-login state change presumably was triggered
# by some user action (signing in, signing out, or
# switching accounts via the back-end).
# NOTE: should test case where we don't have
# connectivity here.
if _ba.app.cloud.is_connected():
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing in as result'
' of implicit state change...',
)
self._implicit_signed_in_adapter.sign_in(
self._on_explicit_sign_in_completed,
description='implicit state change',
)
self._implicit_state_changed = False
# Once we've made a move here we don't want to
# do any more automatic stuff.
self._can_do_auto_sign_in = False
if not self._can_do_auto_sign_in:
return return
# If we're not currently signed in, we have connectivity, and # If we're not currently signed in, we have connectivity, and
# we have an available implicit adapter, do an auto-sign-in. # we have an available implicit login, auto-sign-in with it once.
# The implicit-state-change logic above should keep things
# mostly in-sync, but that might not always be the case due to
# connectivity or other issues. We prefer to keep people signed
# in as a rule, even if there are corner cases where this might
# not be what they want (A user signing out and then restarting
# may be auto-signed back in).
connected = _ba.app.cloud.is_connected() connected = _ba.app.cloud.is_connected()
signed_in_v1 = get_v1_account_state() == 'signed_in' signed_in_v1 = get_v1_account_state() == 'signed_in'
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials() signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
@ -202,24 +319,78 @@ class AccountV2Subsystem:
and not signed_in_v2 and not signed_in_v2
and self._implicit_signed_in_adapter is not None and self._implicit_signed_in_adapter is not None
): ):
self._auto_signed_in = True # Only attempt this once if DEBUG_LOG:
self._implicit_signed_in_adapter.sign_in(self._on_sign_in_completed) logging.debug(
'AccountV2: Signing in due to on-launch-auto-sign-in...',
)
self._can_do_auto_sign_in = False # Only ATTEMPT once
self._implicit_signed_in_adapter.sign_in(
self._on_implicit_sign_in_completed, description='auto-sign-in'
)
def _on_sign_in_completed( def _on_explicit_sign_in_completed(
self, self,
adapter: LoginAdapter, adapter: LoginAdapter,
result: LoginAdapter.SignInResult | Exception, result: LoginAdapter.SignInResult | Exception,
) -> None: ) -> None:
"""A sign-in has completed that the user asked for explicitly."""
from ba._language import Lstr
del adapter # Unused.
# Make some noise on errors since the user knows a
# sign-in attempt is happening in this case (the 'explicit' part).
if isinstance(result, Exception):
# We expect the occasional communication errors;
# Log a full exception for anything else though.
if not isinstance(result, CommunicationError):
logging.warning(
'Error on explicit accountv2 sign in attempt.',
exc_info=result,
)
# For now just show 'error'. Should do better than this.
with _ba.Context('ui'):
_ba.screenmessage(
Lstr(resource='internal.signInErrorText'),
color=(1, 0, 0),
)
_ba.playsound(_ba.getsound('error'))
# Also I suppose we should sign them out in this case since
# it could be misleading to be still signed in with the old
# account.
_ba.app.accounts_v2.set_primary_credentials(None)
return
_ba.app.accounts_v2.set_primary_credentials(result.credentials)
def _on_implicit_sign_in_completed(
self,
adapter: LoginAdapter,
result: LoginAdapter.SignInResult | Exception,
) -> None:
"""A sign-in has completed that the user didn't ask for explicitly."""
from ba._internal import get_v1_account_state from ba._internal import get_v1_account_state
del adapter # Unused. del adapter # Unused.
# Silently ignore errors. # Log errors but don't inform the user; they're not aware of this
# attempt and ignorance is bliss.
if isinstance(result, Exception): if isinstance(result, Exception):
# We expect the occasional communication errors;
# Log a full exception for anything else though.
if not isinstance(result, CommunicationError):
logging.warning(
'Error on implicit accountv2 sign in attempt.',
exc_info=result,
)
return return
# If we're still connected and still not signed in, # If we're still connected and still not signed in,
# plug in the credentials we got. # plug in the credentials we got. We want to be extra cautious
# in case the user has since explicitly signed in since we
# kicked off.
connected = _ba.app.cloud.is_connected() connected = _ba.app.cloud.is_connected()
signed_in_v1 = get_v1_account_state() == 'signed_in' signed_in_v1 = get_v1_account_state() == 'signed_in'
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials() signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
@ -227,9 +398,9 @@ class AccountV2Subsystem:
_ba.app.accounts_v2.set_primary_credentials(result.credentials) _ba.app.accounts_v2.set_primary_credentials(result.credentials)
def _on_set_active_workspace_completed(self) -> None: def _on_set_active_workspace_completed(self) -> None:
if not self._initial_login_completed: if not self._initial_sign_in_completed:
self._initial_login_completed = True self._initial_sign_in_completed = True
_ba.app.on_initial_login_completed() _ba.app.on_initial_sign_in_completed()
class AccountV2Handle: class AccountV2Handle:

View File

@ -32,6 +32,7 @@ if TYPE_CHECKING:
from bastd.actor import spazappearance from bastd.actor import spazappearance
from ba._accountv2 import AccountV2Subsystem from ba._accountv2 import AccountV2Subsystem
from ba._level import Level from ba._level import Level
from ba._apputils import AppHealthMonitor
class App: class App:
@ -50,7 +51,9 @@ class App:
# Implementations for these will be filled in by internal libs. # Implementations for these will be filled in by internal libs.
accounts_v2: AccountV2Subsystem accounts_v2: AccountV2Subsystem
cloud: CloudSubsystem cloud: CloudSubsystem
log_handler: efro.log.LogHandler log_handler: efro.log.LogHandler
health_monitor: AppHealthMonitor
class State(Enum): class State(Enum):
"""High level state the app can be in.""" """High level state the app can be in."""
@ -232,7 +235,7 @@ class App:
self.state = self.State.LAUNCHING self.state = self.State.LAUNCHING
self._launch_completed = False self._launch_completed = False
self._initial_login_completed = False self._initial_sign_in_completed = False
self._meta_scan_completed = False self._meta_scan_completed = False
self._called_on_app_running = False self._called_on_app_running = False
self._app_paused = False self._app_paused = False
@ -346,7 +349,6 @@ class App:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
from ba import _asyncio from ba import _asyncio
from ba import _apputils
from ba import _appconfig from ba import _appconfig
from ba import _map from ba import _map
from ba import _campaign from ba import _campaign
@ -354,10 +356,16 @@ class App:
from bastd import maps as stdmaps from bastd import maps as stdmaps
from bastd.actor import spazappearance from bastd.actor import spazappearance
from ba._generated.enums import TimeType from ba._generated.enums import TimeType
from ba._apputils import (
log_dumped_app_state,
handle_leftover_v1_cloud_log_file,
AppHealthMonitor,
)
assert _ba.in_logic_thread() assert _ba.in_logic_thread()
self._aioloop = _asyncio.setup_asyncio() self._aioloop = _asyncio.setup_asyncio()
self.health_monitor = AppHealthMonitor()
cfg = self.config cfg = self.config
@ -401,15 +409,15 @@ class App:
# If there's a leftover log file, attempt to upload it to the # If there's a leftover log file, attempt to upload it to the
# master-server and/or get rid of it. # master-server and/or get rid of it.
_apputils.handle_leftover_v1_cloud_log_file() handle_leftover_v1_cloud_log_file()
# Only do this stuff if our config file is healthy so we don't # Only do this stuff if our config file is healthy so we don't
# overwrite a broken one or whatnot and wipe out data. # overwrite a broken one or whatnot and wipe out data.
if not self.config_file_healthy: if not self.config_file_healthy:
if self.platform in ('mac', 'linux', 'windows'): if self.platform in ('mac', 'linux', 'windows'):
from bastd.ui import configerror from bastd.ui.configerror import ConfigErrorWindow
configerror.ConfigErrorWindow() _ba.pushcall(ConfigErrorWindow)
return return
# For now on other systems we just overwrite the bum config. # For now on other systems we just overwrite the bum config.
@ -459,6 +467,9 @@ class App:
'on_app_launch found state %s; expected LAUNCHING.', self.state 'on_app_launch found state %s; expected LAUNCHING.', self.state
) )
# If any traceback dumps happened last run, log and clear them.
log_dumped_app_state()
self._launch_completed = True self._launch_completed = True
self._update_state() self._update_state()
@ -483,9 +494,24 @@ class App:
assert _ba.in_logic_thread() assert _ba.in_logic_thread()
if self._app_paused: if self._app_paused:
self.state = self.State.PAUSED # Entering paused state:
if self.state is not self.State.PAUSED:
self.state = self.State.PAUSED
self.cloud.on_app_pause()
self.accounts_v1.on_app_pause()
self.plugins.on_app_pause()
self.health_monitor.on_app_pause()
else: else:
if self._initial_login_completed and self._meta_scan_completed: # Leaving paused state:
if self.state is self.State.PAUSED:
self.fg_state += 1
self.cloud.on_app_resume()
self.accounts_v1.on_app_resume()
self.music.on_app_resume()
self.plugins.on_app_resume()
self.health_monitor.on_app_resume()
if self._initial_sign_in_completed and self._meta_scan_completed:
self.state = self.State.RUNNING self.state = self.State.RUNNING
if not self._called_on_app_running: if not self._called_on_app_running:
self._called_on_app_running = True self._called_on_app_running = True
@ -498,19 +524,16 @@ class App:
def on_app_pause(self) -> None: def on_app_pause(self) -> None:
"""Called when the app goes to a suspended state.""" """Called when the app goes to a suspended state."""
assert not self._app_paused # Should avoid redundant calls.
self._app_paused = True self._app_paused = True
self._update_state() self._update_state()
self.plugins.on_app_pause()
def on_app_resume(self) -> None: def on_app_resume(self) -> None:
"""Run when the app resumes from a suspended state.""" """Run when the app resumes from a suspended state."""
assert self._app_paused # Should avoid redundant calls.
self._app_paused = False self._app_paused = False
self._update_state() self._update_state()
self.fg_state += 1
self.accounts_v1.on_app_resume()
self.music.on_app_resume()
self.plugins.on_app_resume()
def on_app_shutdown(self) -> None: def on_app_shutdown(self) -> None:
"""(internal)""" """(internal)"""
@ -701,8 +724,8 @@ class App:
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) _ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
_ba.playsound(_ba.getsound('error')) _ba.playsound(_ba.getsound('error'))
def on_initial_login_completed(self) -> None: def on_initial_sign_in_completed(self) -> None:
"""Callback to be run after initial login process (or lack thereof). """Callback to be run after initial sign-in (or lack thereof).
This period includes things such as syncing account workspaces This period includes things such as syncing account workspaces
or other data so it may take a substantial amount of time. or other data so it may take a substantial amount of time.
@ -713,5 +736,5 @@ class App:
# (account workspaces). # (account workspaces).
self.meta.start_extra_scan() self.meta.start_extra_scan()
self._initial_login_completed = True self._initial_sign_in_completed = True
self._update_state() self._update_state()

View File

@ -5,12 +5,17 @@ from __future__ import annotations
import gc import gc
import os import os
import logging
from threading import Thread
from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from efro.log import LogLevel
from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
import _ba import _ba
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any, TextIO
import ba import ba
@ -260,3 +265,245 @@ def print_corrupt_file_error() -> None:
_ba.timer( _ba.timer(
2.0, Call(_ba.playsound, _ba.getsound('error')), timetype=TimeType.REAL 2.0, Call(_ba.playsound, _ba.getsound('error')), timetype=TimeType.REAL
) )
_tbfiles: list[TextIO] = []
@ioprepped
@dataclass
class DumpedAppStateMetadata:
"""High level info about a dumped app state."""
reason: str
app_time: float
log_level: LogLevel
def dump_app_state(
delay: float = 0.0,
reason: str = 'Unspecified',
log_level: LogLevel = LogLevel.WARNING,
) -> None:
"""Dump various app state for debugging purposes.
This includes stack traces for all Python threads (and potentially
other info in the future).
This is intended for use debugging deadlock situations. It will dump
to preset file location(s) in the app config dir, and will attempt to
log and clear the results after dumping. If that should fail (due to
a hung app, etc.), then the results will be logged and cleared on the
next app run.
Do not use this call during regular smooth operation of the app; it
is should only be used for debugging or in response to confirmed
problems as it can leak file descriptors, cause hitches, etc.
"""
# pylint: disable=consider-using-with
import faulthandler
from ba._generated.enums import TimeType
# Dump our metadata immediately. If a delay is passed, it generally
# means we expect things to hang momentarily, so we should not delay
# writing our metadata or it will likely not happen. Though we
# should remember that metadata doesn't line up perfectly in time with
# the dump in that case.
try:
mdpath = os.path.join(
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
)
with open(mdpath, 'w', encoding='utf-8') as outfile:
outfile.write(
dataclass_to_json(
DumpedAppStateMetadata(
reason=reason,
app_time=_ba.time(TimeType.REAL),
log_level=log_level,
)
)
)
except Exception:
# Abandon whole dump if we can't write metadata.
logging.exception('Error writing app state dump metadata.')
return
tbpath = os.path.join(
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_tb'
)
# faulthandler needs the raw file descriptor to still be valid when
# it fires, so stuff this into a global var to make sure it doesn't get
# cleaned up.
tbfile = open(tbpath, 'w', encoding='utf-8')
_tbfiles.append(tbfile)
if delay > 0.0:
faulthandler.dump_traceback_later(delay, file=tbfile)
else:
faulthandler.dump_traceback(file=tbfile)
# Attempt to log shortly after dumping.
# Allow sufficient time since we don't know how long the dump takes.
# We want this to work from any thread, so need to kick this part
# over to the logic thread so timer works.
_ba.pushcall(
lambda: _ba.timer(
delay + 1.0, log_dumped_app_state, timetype=TimeType.REAL
),
from_other_thread=True,
suppress_other_thread_warning=True,
)
def log_dumped_app_state() -> None:
"""If an app-state dump exists, log it and clear it. No-op otherwise."""
try:
out = ''
mdpath = os.path.join(
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
)
if os.path.exists(mdpath):
with open(mdpath, 'r', encoding='utf-8') as infile:
metadata = dataclass_from_json(
DumpedAppStateMetadata, infile.read()
)
os.unlink(mdpath)
out += (
f'App state dump:\nReason: {metadata.reason}\n'
f'Time: {metadata.app_time:.2f}'
)
tbpath = os.path.join(
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_tb'
)
if os.path.exists(tbpath):
with open(tbpath, 'r', encoding='utf-8') as infile:
out += '\nPython tracebacks:\n' + infile.read()
os.unlink(tbpath)
logging.log(metadata.log_level.python_logging_level, out)
except Exception:
logging.exception('Error logging dumped app state.')
class AppHealthMonitor:
"""Logs things like app-not-responding issues."""
def __init__(self) -> None:
assert _ba.in_logic_thread()
self._running = True
self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
self._thread.start()
self._response = False
self._first_check = True
def _app_monitor_thread_main(self) -> None:
try:
self._monitor_app()
except Exception:
logging.exception('Error in AppHealthMonitor thread.')
def _set_response(self) -> None:
assert _ba.in_logic_thread()
self._response = True
def _check_running(self) -> bool:
# Workaround for the fact that mypy assumes _running
# doesn't change during the course of a function.
return self._running
def _monitor_app(self) -> None:
import time
while bool(True):
# Always sleep a bit between checks.
time.sleep(1.234)
# Do nothing while backgrounded.
while not self._running:
time.sleep(2.3456)
# Wait for the logic thread to run something we send it.
starttime = time.monotonic()
self._response = False
_ba.pushcall(self._set_response, raw=True)
while not self._response:
# Abort this check if we went into the background.
if not self._check_running():
break
# Wait a bit longer the first time through since the app
# could still be starting up; we generally don't want to
# report that.
threshold = 10 if self._first_check else 5
# If we've been waiting too long (and the app is running)
# dump the app state and bail. Make an exception for the
# first check though since the app could just be taking
# a while to get going; we don't want to report that.
duration = time.monotonic() - starttime
if duration > threshold:
dump_app_state(
reason=f'Logic thread unresponsive'
f' for {threshold} seconds.'
)
# We just do one alert for now.
return
time.sleep(1.042)
self._first_check = False
def on_app_pause(self) -> None:
"""Should be called when the app pauses."""
assert _ba.in_logic_thread()
self._running = False
def on_app_resume(self) -> None:
"""Should be called when the app resumes."""
assert _ba.in_logic_thread()
self._running = True
def on_too_many_file_descriptors() -> None:
"""Called when too many file descriptors are open; trying to debug."""
from ba._generated.enums import TimeType
real_time = _ba.time(TimeType.REAL)
def _do_log() -> None:
pid = os.getpid()
try:
fdcount: int | str = len(os.listdir(f'/proc/{pid}/fd'))
except Exception as exc:
fdcount = f'? ({exc})'
logging.warning(
'TOO MANY FDS at %.2f. We are pid %d. FDCount is %s.',
real_time,
pid,
fdcount,
)
Thread(target=_do_log, daemon=True).start()
# import io
# from efro.debug import printtypes
# with io.StringIO() as fstr:
# fstr.write('Too many FDs.\n')
# printtypes(file=fstr)
# fstr.seek(0)
# logging.warning(fstr.read())
# import socket
# objs: list[Any] = []
# for obj in gc.get_objects():
# if isinstance(obj, socket.socket):
# objs.append(obj)
# test = open('/Users/ericf/.zshrc', 'r', encoding='utf-8')
# reveal_type(test)
# print('FOUND', len(objs))

View File

@ -47,7 +47,7 @@ def bootstrap() -> None:
# Give a soft warning if we're being used with a different binary # Give a soft warning if we're being used with a different binary
# version than we expect. # version than we expect.
expected_build = 20934 expected_build = 20993
running_build: int = env['build_number'] running_build: int = env['build_number']
if running_build != expected_build: if running_build != expected_build:
print( print(
@ -120,7 +120,8 @@ def bootstrap() -> None:
import __main__ import __main__
# Clear out the standard quit/exit messages since they don't # Clear out the standard quit/exit messages since they don't
# work for us. # work in our embedded situation (should revisit this once we're
# usable from a standard interpreter).
del __main__.__builtins__.quit del __main__.__builtins__.quit
del __main__.__builtins__.exit del __main__.__builtins__.exit

View File

@ -33,6 +33,12 @@ class CloudSubsystem:
""" """
return False # Needs to be overridden return False # Needs to be overridden
def on_app_pause(self) -> None:
"""Should be called when the app pauses."""
def on_app_resume(self) -> None:
"""Should be called when the app resumes."""
def on_connectivity_changed(self, connected: bool) -> None: def on_connectivity_changed(self, connected: bool) -> None:
"""Called when cloud connectivity state changes.""" """Called when cloud connectivity state changes."""
if DEBUG_LOG: if DEBUG_LOG:
@ -125,6 +131,12 @@ class CloudSubsystem:
) -> bacommon.cloud.WorkspaceFetchResponse: ) -> bacommon.cloud.WorkspaceFetchResponse:
... ...
@overload
def send_message(
self, msg: bacommon.cloud.MerchAvailabilityMessage
) -> bacommon.cloud.MerchAvailabilityResponse:
...
@overload @overload
def send_message( def send_message(
self, msg: bacommon.cloud.TestMessage self, msg: bacommon.cloud.TestMessage

View File

@ -11,7 +11,7 @@ from ba._gameactivity import GameActivity
from ba._general import WeakCall from ba._general import WeakCall
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any, Sequence from typing import Sequence
from bastd.actor.playerspaz import PlayerSpaz from bastd.actor.playerspaz import PlayerSpaz
import ba import ba
@ -56,56 +56,6 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
# Preload achievement images in case we get some. # Preload achievement images in case we get some.
_ba.timer(2.0, WeakCall(self._preload_achievements)) _ba.timer(2.0, WeakCall(self._preload_achievements))
def _show_standard_scores_to_beat_ui(
self, scores: list[dict[str, Any]]
) -> None:
from efro.util import asserttype
from ba._gameutils import timestring, animate
from ba._nodeactor import NodeActor
from ba._generated.enums import TimeFormat
display_type = self.get_score_type()
if scores is not None:
# Sort by originating date so that the most recent is first.
scores.sort(reverse=True, key=lambda s: asserttype(s['time'], int))
# Now make a display for the most recent challenge.
for score in scores:
if score['type'] == 'score_challenge':
tval = (
score['player']
+ ': '
+ timestring(
int(score['value']) * 10,
timeformat=TimeFormat.MILLISECONDS,
).evaluate()
if display_type == 'time'
else str(score['value'])
)
hattach = 'center' if display_type == 'time' else 'left'
halign = 'center' if display_type == 'time' else 'left'
pos = (20, -70) if display_type == 'time' else (20, -130)
txt = NodeActor(
_ba.newnode(
'text',
attrs={
'v_attach': 'top',
'h_attach': hattach,
'h_align': halign,
'color': (0.7, 0.4, 1, 1),
'shadow': 0.5,
'flatness': 1.0,
'position': pos,
'scale': 0.6,
'text': tval,
},
)
).autoretain()
assert txt.node is not None
animate(txt.node, 'scale', {1.0: 0.0, 1.1: 0.7, 1.2: 0.6})
break
# FIXME: this is now redundant with activityutils.getscoreconfig(); # FIXME: this is now redundant with activityutils.getscoreconfig();
# need to kill this. # need to kill this.
def get_score_type(self) -> str: def get_score_type(self) -> str:

View File

@ -1,13 +1,13 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Snippets of code for use by the internal C++ layer. """Snippets of code for use by the internal layer.
History: originally I would dynamically compile/eval bits of Python text History: originally the engine would dynamically compile/eval various Python
from within C++ code, but the major downside there was that none of that was code from within C++ code, but the major downside there was that none of it
type-checked so if names or arguments changed I would never catch code breakage was type-checked so if names or arguments changed it would go unnoticed
until the code was next run. By defining all snippets I use here and then until it broke at runtime. By instead defining such snippets here and then
capturing references to them all at launch I can immediately verify everything capturing references to them all at launch it is possible to allow linting
I'm looking for exists and pylint/mypy can do their magic on this file. and type-checking magic to happen and most issues will be caught immediately.
""" """
# (most of these are self-explanatory) # (most of these are self-explanatory)
# pylint: disable=missing-function-docstring # pylint: disable=missing-function-docstring
@ -461,12 +461,42 @@ def login_adapter_get_sign_in_token_response(
) -> None: ) -> None:
"""Login adapter do-sign-in completed.""" """Login adapter do-sign-in completed."""
from bacommon.login import LoginType from bacommon.login import LoginType
from ba._login import LoginAdapterGPGS from ba._login import LoginAdapterNative
login_type = LoginType(login_type_str) login_type = LoginType(login_type_str)
attempt_id = int(attempt_id_str) attempt_id = int(attempt_id_str)
result = None if result_str == '' else result_str result = None if result_str == '' else result_str
with _ba.Context('ui'): with _ba.Context('ui'):
adapter = _ba.app.accounts_v2.login_adapters[login_type] adapter = _ba.app.accounts_v2.login_adapters[login_type]
assert isinstance(adapter, LoginAdapterGPGS) assert isinstance(adapter, LoginAdapterNative)
adapter.on_sign_in_complete(attempt_id=attempt_id, result=result) adapter.on_sign_in_complete(attempt_id=attempt_id, result=result)
def show_client_too_old_error() -> None:
"""Called at launch if the server tells us we're too old to talk to it."""
from ba._language import Lstr
# If you are using an old build of the app and would like to stop
# seeing this error at launch, do:
# ba.app.config['SuppressClientTooOldErrorForBuild'] = ba.app.build_number
# ba.app.config.commit()
# Note that you will have to do that again later if you update to
# a newer build.
if (
_ba.app.config.get('SuppressClientTooOldErrorForBuild')
== _ba.app.build_number
):
return
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(
translate=(
'serverResponses',
'Server functionality is no longer supported'
' in this version of the game;\n'
'Please update to a newer version.',
)
),
color=(1, 0, 0),
)

View File

@ -104,7 +104,6 @@ def submit_score(
name: Any, name: Any,
score: int | None, score: int | None,
callback: Callable, callback: Callable,
friend_callback: Callable | None,
order: str = 'increasing', order: str = 'increasing',
tournament_id: str | None = None, tournament_id: str | None = None,
score_type: str = 'points', score_type: str = 'points',
@ -125,7 +124,6 @@ def submit_score(
name=name, name=name,
score=score, score=score,
callback=callback, callback=callback,
friend_callback=friend_callback,
order=order, order=order,
tournament_id=tournament_id, tournament_id=tournament_id,
score_type=score_type, score_type=score_type,

View File

@ -67,38 +67,39 @@ class LanguageSubsystem:
def _get_default_language(self) -> str: def _get_default_language(self) -> str:
languages = { languages = {
'ar': 'Arabic',
'be': 'Belarussian',
'zh': 'Chinese',
'hr': 'Croatian',
'cs': 'Czech',
'da': 'Danish',
'nl': 'Dutch',
'eo': 'Esperanto',
'fil': 'Filipino',
'fr': 'French',
'de': 'German', 'de': 'German',
'el': 'Greek',
'hi': 'Hindi',
'hu': 'Hungarian',
'id': 'Indonesian',
'it': 'Italian',
'ko': 'Korean',
'ms': 'Malay',
'fa': 'Persian',
'pl': 'Polish',
'pt': 'Portuguese',
'ro': 'Romanian',
'ru': 'Russian',
'sr': 'Serbian',
'es': 'Spanish', 'es': 'Spanish',
'sk': 'Slovak', 'sk': 'Slovak',
'it': 'Italian',
'nl': 'Dutch',
'da': 'Danish',
'pt': 'Portuguese',
'fr': 'French',
'el': 'Greek',
'ru': 'Russian',
'pl': 'Polish',
'sv': 'Swedish', 'sv': 'Swedish',
'eo': 'Esperanto',
'cs': 'Czech',
'hr': 'Croatian',
'hu': 'Hungarian',
'be': 'Belarussian',
'ro': 'Romanian',
'ko': 'Korean',
'fa': 'Persian',
'ar': 'Arabic',
'zh': 'Chinese',
'tr': 'Turkish',
'th': 'Thai',
'id': 'Indonesian',
'sr': 'Serbian',
'uk': 'Ukrainian',
'vi': 'Vietnamese',
'vec': 'Venetian',
'hi': 'Hindi',
'ta': 'Tamil', 'ta': 'Tamil',
'fil': 'Filipino', 'th': 'Thai',
'tr': 'Turkish',
'uk': 'Ukrainian',
'vec': 'Venetian',
'vi': 'Vietnamese',
} }
# Special case for Chinese: map specific variations to traditional. # Special case for Chinese: map specific variations to traditional.

View File

@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import time
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, final from typing import TYPE_CHECKING, final
@ -57,6 +58,9 @@ class LoginAdapter:
# current active primary account. # current active primary account.
self._active_login_id: str | None = None self._active_login_id: str | None = None
self._last_sign_in_time: float | None = None
self._last_sign_in_desc: str | None = None
def on_app_launch(self) -> None: def on_app_launch(self) -> None:
"""Should be called for each adapter in on_app_launch.""" """Should be called for each adapter in on_app_launch."""
@ -102,6 +106,9 @@ class LoginAdapter:
# (possibly) push it to the app for handling. # (possibly) push it to the app for handling.
self._update_implicit_login_state() self._update_implicit_login_state()
# This might affect whether we consider that back-end as 'active'.
self._update_back_end_active()
def set_active_logins(self, logins: dict[LoginType, str]) -> None: def set_active_logins(self, logins: dict[LoginType, str]) -> None:
"""Keep the adapter informed of actively used logins. """Keep the adapter informed of actively used logins.
@ -116,7 +123,7 @@ class LoginAdapter:
logging.debug( logging.debug(
'LoginAdapter: %s adapter got active logins %s.', 'LoginAdapter: %s adapter got active logins %s.',
self.login_type.name, self.login_type.name,
logins, {k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
) )
self._active_login_id = logins.get(self.login_type) self._active_login_id = logins.get(self.login_type)
@ -139,6 +146,7 @@ class LoginAdapter:
def sign_in( def sign_in(
self, self,
result_cb: Callable[[LoginAdapter, SignInResult | Exception], None], result_cb: Callable[[LoginAdapter, SignInResult | Exception], None],
description: str,
) -> None: ) -> None:
"""Attempt an explicit sign in via this adapter. """Attempt an explicit sign in via this adapter.
@ -148,6 +156,38 @@ class LoginAdapter:
""" """
assert _ba.in_logic_thread() assert _ba.in_logic_thread()
from ba._general import Call from ba._general import Call
from ba._generated.enums import TimeType
# Have been seeing multiple sign-in attempts come through
# nearly simultaneously which can be problematic server-side.
# Let's error if a sign-in attempt is made within a few seconds
# of the last one to address this.
now = time.monotonic()
appnow = _ba.time(TimeType.REAL)
if self._last_sign_in_time is not None:
since_last = now - self._last_sign_in_time
if since_last < 1.0:
logging.warning(
'LoginAdapter: %s adapter sign_in() called too soon'
' (%.2fs) after last; this-desc="%s", last-desc="%s",'
' ba-real-time=%.2f.',
self.login_type.name,
since_last,
description,
self._last_sign_in_desc,
appnow,
)
_ba.pushcall(
Call(
result_cb,
self,
RuntimeError('sign_in called too soon after last.'),
)
)
return
self._last_sign_in_desc = description
self._last_sign_in_time = now
if DEBUG_LOG: if DEBUG_LOG:
logging.debug( logging.debug(
@ -171,7 +211,7 @@ class LoginAdapter:
Call( Call(
result_cb, result_cb,
self, self,
RuntimeError('fetch-sign-in-token failed'), RuntimeError('fetch-sign-in-token failed.'),
) )
) )
return return
@ -189,7 +229,6 @@ class LoginAdapter:
def _got_sign_in_response( def _got_sign_in_response(
response: bacommon.cloud.SignInResponse | Exception, response: bacommon.cloud.SignInResponse | Exception,
) -> None: ) -> None:
# from ba._language import Lstr
if isinstance(response, Exception): if isinstance(response, Exception):
if DEBUG_LOG: if DEBUG_LOG:
@ -199,10 +238,6 @@ class LoginAdapter:
self.login_type.name, self.login_type.name,
response, response,
) )
# _ba.screenmessage(
# Lstr(resource='errorText'), color=(1, 0, 0)
# )
# _ba.playsound(_ba.getsound('error'))
_ba.pushcall(Call(result_cb, self, response)) _ba.pushcall(Call(result_cb, self, response))
else: else:
if DEBUG_LOG: if DEBUG_LOG:
@ -213,7 +248,10 @@ class LoginAdapter:
) )
if response.credentials is None: if response.credentials is None:
result2: LoginAdapter.SignInResult | Exception = ( result2: LoginAdapter.SignInResult | Exception = (
RuntimeError('No credentials returned.') RuntimeError(
'No credentials returned after'
' submitting sign-in-token.'
)
) )
else: else:
result2 = self.SignInResult( result2 = self.SignInResult(
@ -222,13 +260,22 @@ class LoginAdapter:
_ba.pushcall(Call(result_cb, self, result2)) _ba.pushcall(Call(result_cb, self, result2))
_ba.app.cloud.send_message_cb( _ba.app.cloud.send_message_cb(
bacommon.cloud.SignInMessage(self.login_type, result), bacommon.cloud.SignInMessage(
self.login_type,
result,
description=description,
apptime=appnow,
),
on_response=_got_sign_in_response, on_response=_got_sign_in_response,
) )
# Kick off the process by fetching a sign-in token. # Kick off the process by fetching a sign-in token.
self.get_sign_in_token(completion_cb=_got_sign_in_token_result) self.get_sign_in_token(completion_cb=_got_sign_in_token_result)
def is_back_end_active(self) -> bool:
"""Is this adapter's back-end currently active?"""
return self._back_end_active
def get_sign_in_token( def get_sign_in_token(
self, completion_cb: Callable[[str | None], None] self, completion_cb: Callable[[str | None], None]
) -> None: ) -> None:
@ -289,8 +336,8 @@ class LoginAdapter:
self._back_end_active = is_active self._back_end_active = is_active
class LoginAdapterGPGS(LoginAdapter): class LoginAdapterNative(LoginAdapter):
"""Google Play Game Services adapter.""" """A login adapter that does its work in the native layer."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(LoginType.GPGS) super().__init__(LoginType.GPGS)
@ -308,6 +355,9 @@ class LoginAdapterGPGS(LoginAdapter):
self._sign_in_attempt_num += 1 self._sign_in_attempt_num += 1
_ba.login_adapter_get_sign_in_token(self.login_type.value, attempt_id) _ba.login_adapter_get_sign_in_token(self.login_type.value, attempt_id)
def on_back_end_active_change(self, active: bool) -> None:
_ba.login_adapter_back_end_active_change(self.login_type.value, active)
def on_sign_in_complete(self, attempt_id: int, result: str | None) -> None: def on_sign_in_complete(self, attempt_id: int, result: str | None) -> None:
"""Called by the native layer on a completed attempt.""" """Called by the native layer on a completed attempt."""
assert _ba.in_logic_thread() assert _ba.in_logic_thread()
@ -316,3 +366,7 @@ class LoginAdapterGPGS(LoginAdapter):
return return
callback = self._sign_in_attempts.pop(attempt_id) callback = self._sign_in_attempts.pop(attempt_id)
callback(result) callback(result)
class LoginAdapterGPGS(LoginAdapterNative):
"""Google Play Game Services adapter."""

View File

@ -173,7 +173,7 @@ class PluginSubsystem:
color=(1, 1, 0), color=(1, 1, 0),
) )
plugnames = ', '.join(disappeared_plugs) plugnames = ', '.join(disappeared_plugs)
logging.warning( logging.info(
'%d plugin(s) no longer found: %s.', '%d plugin(s) no longer found: %s.',
len(disappeared_plugs), len(disappeared_plugs),
plugnames, plugnames,

View File

@ -418,6 +418,7 @@ class ServerController:
# Call set-enabled last (will push state to the cloud). # Call set-enabled last (will push state to the cloud).
_ba.set_public_party_max_size(self._config.max_party_size) _ba.set_public_party_max_size(self._config.max_party_size)
_ba.set_public_party_queue_enabled(self._config.enable_queue)
_ba.set_public_party_name(self._config.party_name) _ba.set_public_party_name(self._config.party_name)
_ba.set_public_party_stats_url(self._config.stats_url) _ba.set_public_party_stats_url(self._config.stats_url)
_ba.set_public_party_enabled(self._config.party_is_public) _ba.set_public_party_enabled(self._config.party_is_public)

View File

@ -30,6 +30,8 @@ def get_store_item_name_translated(item_name: str) -> ba.Lstr:
return _language.Lstr( return _language.Lstr(
translate=('characterNames', item_info['character']) translate=('characterNames', item_info['character'])
) )
if item_name in ['merch']:
return _language.Lstr(resource='merchText')
if item_name in ['upgrades.pro', 'pro']: if item_name in ['upgrades.pro', 'pro']:
return _language.Lstr( return _language.Lstr(
resource='store.bombSquadProNameText', resource='store.bombSquadProNameText',
@ -50,8 +52,17 @@ def get_store_item_display_size(item_name: str) -> tuple[float, float]:
"""(internal)""" """(internal)"""
if item_name.startswith('characters.'): if item_name.startswith('characters.'):
return 340 * 0.6, 430 * 0.6 return 340 * 0.6, 430 * 0.6
if item_name in ['pro', 'upgrades.pro']: if item_name in ['pro', 'upgrades.pro', 'merch']:
return 650 * 0.9, 500 * 0.85 from ba._generated.enums import UIScale
return 650 * 0.9, 500 * (
0.72
if (
_ba.app.config.get('Merch Link')
and _ba.app.ui.uiscale is UIScale.SMALL
)
else 0.85
)
if item_name.startswith('maps.'): if item_name.startswith('maps.'):
return 510 * 0.6, 450 * 0.6 return 510 * 0.6, 450 * 0.6
if item_name.startswith('icons.'): if item_name.startswith('icons.'):
@ -96,6 +107,7 @@ def get_store_items() -> dict[str, dict]:
'characters.taobaomascot': {'character': 'Taobao Mascot'}, 'characters.taobaomascot': {'character': 'Taobao Mascot'},
'characters.santa': {'character': 'Santa Claus'}, 'characters.santa': {'character': 'Santa Claus'},
'characters.bunny': {'character': 'Easter Bunny'}, 'characters.bunny': {'character': 'Easter Bunny'},
'merch': {},
'pro': {}, 'pro': {},
'maps.lake_frigid': {'map_type': maps.LakeFrigid}, 'maps.lake_frigid': {'map_type': maps.LakeFrigid},
'games.ninja_fight': { 'games.ninja_fight': {
@ -193,9 +205,7 @@ def get_store_items() -> dict[str, dict]:
'icons.fireball': {'icon': _ba.charstr(SpecialChar.FIREBALL)}, 'icons.fireball': {'icon': _ba.charstr(SpecialChar.FIREBALL)},
'icons.mikirog': {'icon': _ba.charstr(SpecialChar.MIKIROG)}, 'icons.mikirog': {'icon': _ba.charstr(SpecialChar.MIKIROG)},
} }
store_items = _ba.app.store_items return _ba.app.store_items
assert store_items is not None
return store_items
def get_store_layout() -> dict[str, list[dict[str, Any]]]: def get_store_layout() -> dict[str, list[dict[str, Any]]]:
@ -261,7 +271,6 @@ def get_store_layout() -> dict[str, list[dict[str, Any]]]:
], ],
} }
store_layout = _ba.app.store_layout store_layout = _ba.app.store_layout
assert store_layout is not None
store_layout['characters'] = [ store_layout['characters'] = [
{ {
'items': [ 'items': [
@ -302,6 +311,12 @@ def get_store_layout() -> dict[str, list[dict[str, Any]]]:
'items': ['games.easter_egg_hunt'], 'items': ['games.easter_egg_hunt'],
} }
) )
# This will cause merch to show only if the master-server has
# given us a link (which means merch is available in our region).
store_layout['extras'] = [{'items': ['pro']}]
if _ba.app.config.get('Merch Link'):
store_layout['extras'][0]['items'].append('merch')
return store_layout return store_layout

View File

@ -40,6 +40,7 @@ from _ba import (
get_public_party_max_size, get_public_party_max_size,
set_public_party_name, set_public_party_name,
set_public_party_max_size, set_public_party_max_size,
set_public_party_queue_enabled,
set_authenticate_clients, set_authenticate_clients,
set_public_party_enabled, set_public_party_enabled,
reset_random_player_names, reset_random_player_names,
@ -100,6 +101,8 @@ from ba._apputils import (
is_browser_likely_available, is_browser_likely_available,
get_remote_app_name, get_remote_app_name,
should_submit_debug_info, should_submit_debug_info,
dump_app_state,
log_dumped_app_state,
) )
from ba._benchmark import ( from ba._benchmark import (
run_gpu_benchmark, run_gpu_benchmark,
@ -211,6 +214,7 @@ __all__ = [
'get_public_party_max_size', 'get_public_party_max_size',
'set_public_party_name', 'set_public_party_name',
'set_public_party_max_size', 'set_public_party_max_size',
'set_public_party_queue_enabled',
'set_authenticate_clients', 'set_authenticate_clients',
'set_public_party_enabled', 'set_public_party_enabled',
'reset_random_player_names', 'reset_random_player_names',
@ -330,4 +334,6 @@ __all__ = [
'sign_out_v1', 'sign_out_v1',
'sign_in_v1', 'sign_in_v1',
'mark_config_dirty', 'mark_config_dirty',
'dump_app_state',
'log_dumped_app_state',
] ]

View File

@ -116,7 +116,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
self._newly_complete: bool | None = None self._newly_complete: bool | None = None
self._is_more_levels: bool | None = None self._is_more_levels: bool | None = None
self._next_level_name: str | None = None self._next_level_name: str | None = None
self._show_friend_scores: bool | None = None
self._show_info: dict[str, Any] | None = None self._show_info: dict[str, Any] | None = None
self._name_str: str | None = None self._name_str: str | None = None
self._friends_loading_status: ba.Actor | None = None self._friends_loading_status: ba.Actor | None = None
@ -177,12 +176,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
.replace(' ', '_') .replace(' ', '_')
) )
# If game-center/etc scores are available we show our friends'
# scores. Otherwise we show our local high scores.
self._show_friend_scores = ba.internal.game_service_has_leaderboard(
self._game_name_str, self._game_config_str
)
try: try:
self._old_best_rank = self._campaign.getlevel( self._old_best_rank = self._campaign.getlevel(
self._level_name self._level_name
@ -366,21 +359,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
ba.internal.set_ui_input_device(None) # Menu is up for grabs. ba.internal.set_ui_input_device(None) # Menu is up for grabs.
if self._show_friend_scores:
ba.buttonwidget(
parent=rootc,
color=(0.45, 0.4, 0.5),
position=(h_offs - 520, v_offs + 480),
size=(300, 60),
label=ba.Lstr(resource='topFriendsText'),
on_activate_call=ba.WeakCall(self._ui_gc),
transition_delay=delay + 0.5,
icon=self._game_service_leaderboards_texture,
icon_color=self._game_service_icon_color,
autoselect=True,
selectable=can_select_extra_buttons,
)
if self._have_achievements and self._account_has_achievements: if self._have_achievements and self._account_has_achievements:
ba.buttonwidget( ba.buttonwidget(
parent=rootc, parent=rootc,
@ -773,18 +751,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
[p.name for p in self._playerinfos] [p.name for p in self._playerinfos]
) )
if self._show_friend_scores:
self._friends_loading_status = Text(
ba.Lstr(
value='${A}...',
subs=[('${A}', ba.Lstr(resource='loadingText'))],
),
position=(-405, 150 + 30),
color=(1, 1, 1, 0.4),
transition=Text.Transition.FADE_IN,
scale=0.7,
transition_delay=2.0,
)
self._score_loading_status = Text( self._score_loading_status = Text(
ba.Lstr( ba.Lstr(
value='${A}...', value='${A}...',
@ -850,8 +816,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
# We expect this only in kiosk mode; complain otherwise. # We expect this only in kiosk mode; complain otherwise.
if not (ba.app.demo_mode or ba.app.arcade_mode): if not (ba.app.demo_mode or ba.app.arcade_mode):
print('got not-signed-in at score-submit; unexpected') print('got not-signed-in at score-submit; unexpected')
if self._show_friend_scores:
ba.pushcall(ba.WeakCall(self._got_friend_score_results, None))
ba.pushcall(ba.WeakCall(self._got_score_results, None)) ba.pushcall(ba.WeakCall(self._got_score_results, None))
else: else:
assert self._game_name_str is not None assert self._game_name_str is not None
@ -862,9 +826,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
name_str, name_str,
self._score, self._score,
ba.WeakCall(self._got_score_results), ba.WeakCall(self._got_score_results),
ba.WeakCall(self._got_friend_score_results)
if self._show_friend_scores
else None,
order=self._score_order, order=self._score_order,
tournament_id=self.session.tournament_id, tournament_id=self.session.tournament_id,
score_type=self._score_type, score_type=self._score_type,
@ -899,138 +860,118 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
assert txt.node assert txt.node
txt.node.client_only = True txt.node.client_only = True
# If we have no friend scores, display local best scores. ts_height = 300
if self._show_friend_scores: ts_h_offs = -480
v_offs = 40
Text(
ba.Lstr(resource='yourBestScoresText')
if self._score_type == 'points'
else ba.Lstr(resource='yourBestTimesText'),
maxwidth=210,
position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
transition=Text.Transition.IN_RIGHT,
v_align=Text.VAlign.CENTER,
scale=1.2,
transition_delay=1.8,
).autoretain()
# Host has a button, so we need client-only text. display_scores = list(our_high_scores)
ts_height = 300 display_count = 5
ts_h_offs = -480
v_offs = 40
txt = Text(
ba.Lstr(resource='topFriendsText'),
maxwidth=210,
position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
transition=Text.Transition.IN_RIGHT,
v_align=Text.VAlign.CENTER,
scale=1.2,
transition_delay=1.8,
).autoretain()
assert txt.node
txt.node.client_only = True
else:
ts_height = 300 while len(display_scores) < display_count:
ts_h_offs = -480 display_scores.append((0, None))
v_offs = 40
Text(
ba.Lstr(resource='yourBestScoresText')
if self._score_type == 'points'
else ba.Lstr(resource='yourBestTimesText'),
maxwidth=210,
position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
transition=Text.Transition.IN_RIGHT,
v_align=Text.VAlign.CENTER,
scale=1.2,
transition_delay=1.8,
).autoretain()
display_scores = list(our_high_scores) showed_ours = False
display_count = 5 h_offs_extra = 85 if self._score_type == 'points' else 130
v_offs_extra = 20
while len(display_scores) < display_count: v_offs_names = 0
display_scores.append((0, None)) scale = 1.0
p_count = len(self._playerinfos)
showed_ours = False h_offs_extra -= 75
h_offs_extra = 85 if self._score_type == 'points' else 130 if p_count > 1:
v_offs_extra = 20 h_offs_extra -= 20
v_offs_names = 0 if p_count == 2:
scale = 1.0 scale = 0.9
p_count = len(self._playerinfos) elif p_count == 3:
h_offs_extra -= 75 scale = 0.65
if p_count > 1: elif p_count == 4:
h_offs_extra -= 20 scale = 0.5
if p_count == 2: times: list[tuple[float, float]] = []
scale = 0.9 for i in range(display_count):
elif p_count == 3: times.insert(
scale = 0.65 random.randrange(0, len(times) + 1),
elif p_count == 4: (1.9 + i * 0.05, 2.3 + i * 0.05),
scale = 0.5 )
times: list[tuple[float, float]] = [] for i in range(display_count):
for i in range(display_count): try:
times.insert( if display_scores[i][1] is None:
random.randrange(0, len(times) + 1),
(1.9 + i * 0.05, 2.3 + i * 0.05),
)
for i in range(display_count):
try:
if display_scores[i][1] is None:
name_str = '-'
else:
# noinspection PyUnresolvedReferences
name_str = ', '.join(
[p['name'] for p in display_scores[i][1]['players']]
)
except Exception:
ba.print_exception(
f'Error calcing name_str for {display_scores}'
)
name_str = '-' name_str = '-'
if display_scores[i] == our_score and not showed_ours:
flash = True
color0 = (0.6, 0.4, 0.1, 1.0)
color1 = (0.6, 0.6, 0.6, 1.0)
tdelay1 = 3.7
tdelay2 = 3.7
showed_ours = True
else: else:
flash = False # noinspection PyUnresolvedReferences
color0 = (0.6, 0.4, 0.1, 1.0) name_str = ', '.join(
color1 = (0.6, 0.6, 0.6, 1.0) [p['name'] for p in display_scores[i][1]['players']]
tdelay1 = times[i][0] )
tdelay2 = times[i][1] except Exception:
Text( ba.print_exception(
str(display_scores[i][0]) f'Error calcing name_str for {display_scores}'
if self._score_type == 'points' )
else ba.timestring( name_str = '-'
display_scores[i][0] * 10, if display_scores[i] == our_score and not showed_ours:
timeformat=ba.TimeFormat.MILLISECONDS, flash = True
suppress_format_warning=True, color0 = (0.6, 0.4, 0.1, 1.0)
), color1 = (0.6, 0.6, 0.6, 1.0)
position=( tdelay1 = 3.7
ts_h_offs + 20 + h_offs_extra, tdelay2 = 3.7
v_offs_extra showed_ours = True
+ ts_height / 2 else:
+ -ts_height * (i + 1) / 10 flash = False
+ v_offs color0 = (0.6, 0.4, 0.1, 1.0)
+ 11.0, color1 = (0.6, 0.6, 0.6, 1.0)
), tdelay1 = times[i][0]
h_align=Text.HAlign.RIGHT, tdelay2 = times[i][1]
v_align=Text.VAlign.CENTER, Text(
color=color0, str(display_scores[i][0])
flash=flash, if self._score_type == 'points'
transition=Text.Transition.IN_RIGHT, else ba.timestring(
transition_delay=tdelay1, display_scores[i][0] * 10,
).autoretain() timeformat=ba.TimeFormat.MILLISECONDS,
suppress_format_warning=True,
),
position=(
ts_h_offs + 20 + h_offs_extra,
v_offs_extra
+ ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
+ 11.0,
),
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=color0,
flash=flash,
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay1,
).autoretain()
Text( Text(
ba.Lstr(value=name_str), ba.Lstr(value=name_str),
position=( position=(
ts_h_offs + 35 + h_offs_extra, ts_h_offs + 35 + h_offs_extra,
v_offs_extra v_offs_extra
+ ts_height / 2 + ts_height / 2
+ -ts_height * (i + 1) / 10 + -ts_height * (i + 1) / 10
+ v_offs_names + v_offs_names
+ v_offs + v_offs
+ 11.0, + 11.0,
), ),
maxwidth=80.0 + 100.0 * len(self._playerinfos), maxwidth=80.0 + 100.0 * len(self._playerinfos),
v_align=Text.VAlign.CENTER, v_align=Text.VAlign.CENTER,
color=color1, color=color1,
flash=flash, flash=flash,
scale=scale, scale=scale,
transition=Text.Transition.IN_RIGHT, transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay2, transition_delay=tdelay2,
).autoretain() ).autoretain()
# Show achievements for this level. # Show achievements for this level.
ts_height = -150 ts_height = -150

View File

@ -180,6 +180,7 @@ class Spaz(ba.Actor):
self._bomb_wear_off_flash_timer: ba.Timer | None = None self._bomb_wear_off_flash_timer: ba.Timer | None = None
self._multi_bomb_wear_off_timer: ba.Timer | None = None self._multi_bomb_wear_off_timer: ba.Timer | None = None
self._multi_bomb_wear_off_flash_timer: ba.Timer | None = None self._multi_bomb_wear_off_flash_timer: ba.Timer | None = None
self._curse_timer: ba.Timer | None = None
self.bomb_count = self.default_bomb_count self.bomb_count = self.default_bomb_count
self._max_bomb_count = self.default_bomb_count self._max_bomb_count = self.default_bomb_count
self.bomb_type_default = self.default_bomb_type self.bomb_type_default = self.default_bomb_type
@ -620,7 +621,8 @@ class Spaz(ba.Actor):
self.node.curse_death_time = int( self.node.curse_death_time = int(
1000.0 * (tval + self.curse_time) 1000.0 * (tval + self.curse_time)
) )
ba.timer(5.0, ba.WeakCall(self.curse_explode)) self._curse_timer = ba.Timer(5.0,
ba.WeakCall(self.curse_explode))
def equip_boxing_gloves(self) -> None: def equip_boxing_gloves(self) -> None:
""" """

View File

@ -616,9 +616,6 @@ class FootballCoopGame(ba.CoopGameActivity[Player, Team]):
for bottype in self._bot_types_initial: for bottype in self._bot_types_initial:
self._spawn_bot(bottype) self._spawn_bot(bottype)
def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
self._show_standard_scores_to_beat_ui(scores)
def _on_bot_spawn(self, spaz: SpazBot) -> None: def _on_bot_spawn(self, spaz: SpazBot) -> None:
# We want to move to the left by default. # We want to move to the left by default.
spaz.target_point_default = ba.Vec3(0, 0, 0) spaz.target_point_default = ba.Vec3(0, 0, 0)

View File

@ -682,9 +682,6 @@ class OnslaughtGame(ba.CoopGameActivity[Player, Team]):
self._bots = SpazBotSet() self._bots = SpazBotSet()
ba.timer(4.0, self._start_updating_waves) ba.timer(4.0, self._start_updating_waves)
def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
self._show_standard_scores_to_beat_ui(scores)
def _get_dist_grp_totals(self, grps: list[Any]) -> tuple[int, int]: def _get_dist_grp_totals(self, grps: list[Any]) -> tuple[int, int]:
totalpts = 0 totalpts = 0
totaldudes = 0 totaldudes = 0

View File

@ -684,9 +684,6 @@ class RunaroundGame(ba.CoopGameActivity[Player, Team]):
}, },
) )
def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
self._show_standard_scores_to_beat_ui(scores)
def _update_waves(self) -> None: def _update_waves(self) -> None:
# pylint: disable=too-many-branches # pylint: disable=too-many-branches

View File

@ -326,9 +326,6 @@ class TheLastStandGame(ba.CoopGameActivity[Player, Team]):
else: else:
super().handlemessage(msg) super().handlemessage(msg)
def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
self._show_standard_scores_to_beat_ui(scores)
def end_game(self) -> None: def end_game(self) -> None:
# Tell our bots to celebrate just to rub it in. # Tell our bots to celebrate just to rub it in.
self._bots.final_celebrate() self._bots.final_celebrate()

View File

@ -17,6 +17,11 @@ import ba.internal
if TYPE_CHECKING: if TYPE_CHECKING:
from ba.internal import LoginAdapter from ba.internal import LoginAdapter
# These days we're directing people to the web based account settings
# for V2 account linking and trying to get them to disconnect remaining
# V1 links, but leaving this escape hatch here in case needed.
FORCE_ENABLE_V1_LINKING = False
class AccountSettingsWindow(ba.Window): class AccountSettingsWindow(ba.Window):
"""Window for account related functionality.""" """Window for account related functionality."""
@ -33,10 +38,14 @@ class AccountSettingsWindow(ba.Window):
self._sign_in_v2_proxy_button: ba.Widget | None = None self._sign_in_v2_proxy_button: ba.Widget | None = None
self._sign_in_device_button: ba.Widget | None = None self._sign_in_device_button: ba.Widget | None = None
self._show_legacy_unlink_button = False
self._signing_in_adapter: LoginAdapter | None = None self._signing_in_adapter: LoginAdapter | None = None
self._close_once_signed_in = close_once_signed_in self._close_once_signed_in = close_once_signed_in
ba.set_analytics_screen('Account Window') ba.set_analytics_screen('Account Window')
self._explicitly_signed_out_of_gpgs = False
# If they provided an origin-widget, scale up from that. # If they provided an origin-widget, scale up from that.
scale_origin: tuple[float, float] | None scale_origin: tuple[float, float] | None
if origin_widget is not None: if origin_widget is not None:
@ -50,14 +59,8 @@ class AccountSettingsWindow(ba.Window):
self._r = 'accountSettingsWindow' self._r = 'accountSettingsWindow'
self._modal = modal self._modal = modal
self._needs_refresh = False self._needs_refresh = False
self._signed_in = ba.internal.get_v1_account_state() == 'signed_in' self._v1_signed_in = ba.internal.get_v1_account_state() == 'signed_in'
self._account_state_num = ba.internal.get_v1_account_state_num() self._v1_account_state_num = ba.internal.get_v1_account_state_num()
self._show_linked = (
self._signed_in
and ba.internal.get_v1_account_misc_read_val(
'allowAccountLinking2', False
)
)
self._check_sign_in_timer = ba.Timer( self._check_sign_in_timer = ba.Timer(
1.0, 1.0,
ba.WeakCall(self._update), ba.WeakCall(self._update),
@ -66,12 +69,12 @@ class AccountSettingsWindow(ba.Window):
) )
# Currently we can only reset achievements on game-center. # Currently we can only reset achievements on game-center.
account_type: str | None v1_account_type: str | None
if self._signed_in: if self._v1_signed_in:
account_type = ba.internal.get_v1_account_type() v1_account_type = ba.internal.get_v1_account_type()
else: else:
account_type = None v1_account_type = None
self._can_reset_achievements = account_type == 'Game Center' self._can_reset_achievements = v1_account_type == 'Game Center'
app = ba.app app = ba.app
uiscale = app.ui.uiscale uiscale = app.ui.uiscale
@ -104,7 +107,7 @@ class AccountSettingsWindow(ba.Window):
# Legacy v1 device accounts are currently always available # Legacy v1 device accounts are currently always available
# (though we need to start phasing them out at some point). # (though we need to start phasing them out at some point).
self._show_sign_in_buttons.append('Local') self._show_sign_in_buttons.append('Device')
top_extra = 15 if uiscale is ba.UIScale.SMALL else 0 top_extra = 15 if uiscale is ba.UIScale.SMALL else 0
super().__init__( super().__init__(
@ -183,31 +186,25 @@ class AccountSettingsWindow(ba.Window):
def _update(self) -> None: def _update(self) -> None:
# If they want us to close once we're signed in, do so. # If they want us to close once we're signed in, do so.
if self._close_once_signed_in and self._signed_in: if self._close_once_signed_in and self._v1_signed_in:
self._back() self._back()
return return
# Hmm should update this to use get_account_state_num. # Hmm should update this to use get_account_state_num.
# Theoretically if we switch from one signed-in account to another # Theoretically if we switch from one signed-in account to another
# in the background this would break. # in the background this would break.
account_state_num = ba.internal.get_v1_account_state_num() v1_account_state_num = ba.internal.get_v1_account_state_num()
account_state = ba.internal.get_v1_account_state() v1_account_state = ba.internal.get_v1_account_state()
show_legacy_unlink_button = self._should_show_legacy_unlink_button()
show_linked = (
self._signed_in
and ba.internal.get_v1_account_misc_read_val(
'allowAccountLinking2', False
)
)
if ( if (
account_state_num != self._account_state_num v1_account_state_num != self._v1_account_state_num
or self._show_linked != show_linked or show_legacy_unlink_button != self._show_legacy_unlink_button
or self._needs_refresh or self._needs_refresh
): ):
self._show_linked = show_linked self._v1_account_state_num = v1_account_state_num
self._account_state_num = account_state_num self._v1_signed_in = v1_account_state == 'signed_in'
self._signed_in = account_state == 'signed_in' self._show_legacy_unlink_button = show_legacy_unlink_button
self._refresh() self._refresh()
# Go ahead and refresh some individual things # Go ahead and refresh some individual things
@ -229,22 +226,27 @@ class AccountSettingsWindow(ba.Window):
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bastd.ui import confirm from bastd.ui import confirm
primary_v2_account = ba.app.accounts_v2.primary
v1_state = ba.internal.get_v1_account_state() v1_state = ba.internal.get_v1_account_state()
account_type = ( v1_account_type = (
ba.internal.get_v1_account_type() ba.internal.get_v1_account_type()
if v1_state == 'signed_in' if v1_state == 'signed_in'
else 'unknown' else 'unknown'
) )
is_google = account_type == 'Google Play' # We expose GPGS-specific functionality only if it is 'active'
# (meaning the current GPGS player matches one of our account's
# logins).
gpgs_adapter = ba.app.accounts_v2.login_adapters.get(LoginType.GPGS)
is_gpgs = (
False if gpgs_adapter is None else gpgs_adapter.is_back_end_active()
)
show_local_signed_in_as = False show_signed_in_as = self._v1_signed_in
local_signed_in_as_space = 50.0
show_signed_in_as = self._signed_in
signed_in_as_space = 95.0 signed_in_as_space = 95.0
show_sign_in_benefits = not self._signed_in show_sign_in_benefits = not self._v1_signed_in
sign_in_benefits_space = 80.0 sign_in_benefits_space = 80.0
show_signing_in_text = ( show_signing_in_text = (
@ -257,32 +259,30 @@ class AccountSettingsWindow(ba.Window):
and self._signing_in_adapter is None and self._signing_in_adapter is None
and 'Google Play' in self._show_sign_in_buttons and 'Google Play' in self._show_sign_in_buttons
) )
show_device_sign_in_button = (
v1_state == 'signed_out'
and self._signing_in_adapter is None
and 'Local' in self._show_sign_in_buttons
)
show_v2_proxy_sign_in_button = ( show_v2_proxy_sign_in_button = (
v1_state == 'signed_out' v1_state == 'signed_out'
and self._signing_in_adapter is None and self._signing_in_adapter is None
and 'V2Proxy' in self._show_sign_in_buttons and 'V2Proxy' in self._show_sign_in_buttons
) )
show_device_sign_in_button = (
v1_state == 'signed_out'
and self._signing_in_adapter is None
and 'Device' in self._show_sign_in_buttons
)
sign_in_button_space = 70.0 sign_in_button_space = 70.0
deprecated_space = 60
show_game_service_button = self._signed_in and account_type in [ show_game_service_button = self._v1_signed_in and v1_account_type in [
'Game Center' 'Game Center'
] ]
game_service_button_space = 60.0 game_service_button_space = 60.0
show_linked_accounts_text = ( show_what_is_v2 = self._v1_signed_in and v1_account_type == 'V2'
self._signed_in
and ba.internal.get_v1_account_misc_read_val( show_linked_accounts_text = self._v1_signed_in
'allowAccountLinking2', False
)
)
linked_accounts_text_space = 60.0 linked_accounts_text_space = 60.0
show_achievements_button = self._signed_in and account_type in ( show_achievements_button = self._v1_signed_in and v1_account_type in (
'Google Play', 'Google Play',
'Local', 'Local',
'V2', 'V2',
@ -290,42 +290,46 @@ class AccountSettingsWindow(ba.Window):
achievements_button_space = 60.0 achievements_button_space = 60.0
show_achievements_text = ( show_achievements_text = (
self._signed_in and not show_achievements_button self._v1_signed_in and not show_achievements_button
) )
achievements_text_space = 27.0 achievements_text_space = 27.0
show_leaderboards_button = self._signed_in and is_google show_leaderboards_button = self._v1_signed_in and is_gpgs
leaderboards_button_space = 60.0 leaderboards_button_space = 60.0
show_campaign_progress = self._signed_in show_campaign_progress = self._v1_signed_in
campaign_progress_space = 27.0 campaign_progress_space = 27.0
show_tickets = self._signed_in show_tickets = self._v1_signed_in
tickets_space = 27.0 tickets_space = 27.0
show_reset_progress_button = False show_reset_progress_button = False
reset_progress_button_space = 70.0 reset_progress_button_space = 70.0
show_manage_v2_account_button = self._signed_in and account_type == 'V2' show_manage_v2_account_button = (
self._v1_signed_in and v1_account_type == 'V2'
)
manage_v2_account_button_space = 100.0 manage_v2_account_button_space = 100.0
show_player_profiles_button = self._signed_in show_player_profiles_button = self._v1_signed_in
player_profiles_button_space = ( player_profiles_button_space = (
70.0 if show_manage_v2_account_button else 100.0 70.0 if show_manage_v2_account_button else 100.0
) )
show_link_accounts_button = ( show_link_accounts_button = self._v1_signed_in and (
self._signed_in primary_v2_account is None or FORCE_ENABLE_V1_LINKING
and ba.internal.get_v1_account_misc_read_val(
'allowAccountLinking2', False
)
) )
link_accounts_button_space = 70.0 link_accounts_button_space = 70.0
show_unlink_accounts_button = show_link_accounts_button show_unlink_accounts_button = show_link_accounts_button
unlink_accounts_button_space = 90.0 unlink_accounts_button_space = 90.0
show_sign_out_button = self._signed_in and account_type in [ show_v2_link_info = self._v1_signed_in and not show_link_accounts_button
v2_link_info_space = 70.0
legacy_unlink_button_space = 120.0
show_sign_out_button = self._v1_signed_in and v1_account_type in [
'Local', 'Local',
'Google Play', 'Google Play',
'V2', 'V2',
@ -337,25 +341,23 @@ class AccountSettingsWindow(ba.Window):
# to be verified. # to be verified.
show_cancel_sign_in_button = self._signing_in_adapter is not None or ( show_cancel_sign_in_button = self._signing_in_adapter is not None or (
ba.app.accounts_v2.have_primary_credentials() ba.app.accounts_v2.have_primary_credentials()
and ba.app.accounts_v2.primary is None and primary_v2_account is None
) )
cancel_sign_in_button_space = 70.0 cancel_sign_in_button_space = 70.0
if self._subcontainer is not None: if self._subcontainer is not None:
self._subcontainer.delete() self._subcontainer.delete()
self._sub_height = 60.0 self._sub_height = 60.0
if show_local_signed_in_as:
self._sub_height += local_signed_in_as_space
if show_signed_in_as: if show_signed_in_as:
self._sub_height += signed_in_as_space self._sub_height += signed_in_as_space
if show_signing_in_text: if show_signing_in_text:
self._sub_height += signing_in_text_space self._sub_height += signing_in_text_space
if show_google_play_sign_in_button: if show_google_play_sign_in_button:
self._sub_height += sign_in_button_space self._sub_height += sign_in_button_space
if show_device_sign_in_button:
self._sub_height += sign_in_button_space
if show_v2_proxy_sign_in_button: if show_v2_proxy_sign_in_button:
self._sub_height += sign_in_button_space self._sub_height += sign_in_button_space
if show_device_sign_in_button:
self._sub_height += sign_in_button_space + deprecated_space
if show_game_service_button: if show_game_service_button:
self._sub_height += game_service_button_space self._sub_height += game_service_button_space
if show_linked_accounts_text: if show_linked_accounts_text:
@ -382,6 +384,10 @@ class AccountSettingsWindow(ba.Window):
self._sub_height += link_accounts_button_space self._sub_height += link_accounts_button_space
if show_unlink_accounts_button: if show_unlink_accounts_button:
self._sub_height += unlink_accounts_button_space self._sub_height += unlink_accounts_button_space
if show_v2_link_info:
self._sub_height += v2_link_info_space
if self._show_legacy_unlink_button:
self._sub_height += legacy_unlink_button_space
if show_sign_out_button: if show_sign_out_button:
self._sub_height += sign_out_button_space self._sub_height += sign_out_button_space
if show_cancel_sign_in_button: if show_cancel_sign_in_button:
@ -398,27 +404,8 @@ class AccountSettingsWindow(ba.Window):
first_selectable = None first_selectable = None
v = self._sub_height - 10.0 v = self._sub_height - 10.0
if show_local_signed_in_as: self._account_name_what_is_text: ba.Widget | None
v -= local_signed_in_as_space * 0.6 self._account_name_what_is_y = 0.0
ba.textwidget(
parent=self._subcontainer,
position=(self._sub_width * 0.5, v),
size=(0, 0),
text=ba.Lstr(
resource='accountSettingsWindow.deviceSpecificAccountText',
subs=[
('${NAME}', ba.internal.get_v1_account_display_string())
],
),
scale=0.7,
color=(0.5, 0.5, 0.6),
maxwidth=self._sub_width * 0.9,
flatness=1.0,
h_align='center',
v_align='center',
)
v -= local_signed_in_as_space * 0.4
self._account_name_text: ba.Widget | None self._account_name_text: ba.Widget | None
if show_signed_in_as: if show_signed_in_as:
v -= signed_in_as_space * 0.2 v -= signed_in_as_space * 0.2
@ -437,7 +424,7 @@ class AccountSettingsWindow(ba.Window):
h_align='center', h_align='center',
v_align='center', v_align='center',
) )
v -= signed_in_as_space * 0.4 v -= signed_in_as_space * 0.5
self._account_name_text = ba.textwidget( self._account_name_text = ba.textwidget(
parent=self._subcontainer, parent=self._subcontainer,
position=(self._sub_width * 0.5, v), position=(self._sub_width * 0.5, v),
@ -449,10 +436,39 @@ class AccountSettingsWindow(ba.Window):
h_align='center', h_align='center',
v_align='center', v_align='center',
) )
if show_what_is_v2:
self._account_name_what_is_y = v - 23.0
self._account_name_what_is_text = ba.textwidget(
parent=self._subcontainer,
position=(0.0, self._account_name_what_is_y),
size=(200.0, 60),
text=ba.Lstr(
value='${WHAT} -->',
subs=[('${WHAT}', ba.Lstr(resource='whatIsThisText'))],
),
scale=0.6,
color=(0.3, 0.7, 0.05),
maxwidth=200.0,
h_align='right',
v_align='center',
autoselect=True,
selectable=True,
on_activate_call=show_what_is_v2_page,
click_activate=True,
)
if first_selectable is None:
first_selectable = self._account_name_what_is_text
else:
self._account_name_what_is_text = None
self._refresh_account_name_text() self._refresh_account_name_text()
v -= signed_in_as_space * 0.4 v -= signed_in_as_space * 0.4
else: else:
self._account_name_text = None self._account_name_text = None
self._account_name_what_is_text = None
if self._back_button is None: if self._back_button is None:
bbtn = ba.internal.get_special_widget('back_button') bbtn = ba.internal.get_special_widget('back_button')
@ -606,7 +622,7 @@ class AccountSettingsWindow(ba.Window):
if show_device_sign_in_button: if show_device_sign_in_button:
button_width = 350 button_width = 350
v -= sign_in_button_space v -= sign_in_button_space + deprecated_space
self._sign_in_device_button = btn = ba.buttonwidget( self._sign_in_device_button = btn = ba.buttonwidget(
parent=self._subcontainer, parent=self._subcontainer,
position=((self._sub_width - button_width) * 0.5, v - 20), position=((self._sub_width - button_width) * 0.5, v - 20),
@ -615,6 +631,18 @@ class AccountSettingsWindow(ba.Window):
label='', label='',
on_activate_call=lambda: self._sign_in_press('Local'), on_activate_call=lambda: self._sign_in_press('Local'),
) )
ba.textwidget(
parent=self._subcontainer,
h_align='center',
v_align='center',
size=(0, 0),
position=(self._sub_width * 0.5, v + 60),
text=ba.Lstr(resource='deprecatedText'),
scale=0.8,
maxwidth=300,
color=(0.6, 0.55, 0.45),
)
ba.textwidget( ba.textwidget(
parent=self._subcontainer, parent=self._subcontainer,
draw_controller=btn, draw_controller=btn,
@ -709,12 +737,12 @@ class AccountSettingsWindow(ba.Window):
if show_game_service_button: if show_game_service_button:
button_width = 300 button_width = 300
v -= game_service_button_space * 0.85 v -= game_service_button_space * 0.85
account_type = ba.internal.get_v1_account_type() v1_account_type = ba.internal.get_v1_account_type()
if account_type == 'Game Center': if v1_account_type == 'Game Center':
account_type_name = ba.Lstr(resource='gameCenterText') v1_account_type_name = ba.Lstr(resource='gameCenterText')
else: else:
raise ValueError( raise ValueError(
"unknown account type: '" + str(account_type) + "'" "unknown account type: '" + str(v1_account_type) + "'"
) )
self._game_service_button = btn = ba.buttonwidget( self._game_service_button = btn = ba.buttonwidget(
parent=self._subcontainer, parent=self._subcontainer,
@ -724,7 +752,7 @@ class AccountSettingsWindow(ba.Window):
autoselect=True, autoselect=True,
on_activate_call=ba.internal.show_online_score_ui, on_activate_call=ba.internal.show_online_score_ui,
size=(button_width, 50), size=(button_width, 50),
label=account_type_name, label=v1_account_type_name,
) )
if first_selectable is None: if first_selectable is None:
first_selectable = btn first_selectable = btn
@ -767,11 +795,15 @@ class AccountSettingsWindow(ba.Window):
autoselect=True, autoselect=True,
icon=ba.gettexture( icon=ba.gettexture(
'googlePlayAchievementsIcon' 'googlePlayAchievementsIcon'
if is_google if is_gpgs
else 'achievementsIcon' else 'achievementsIcon'
), ),
icon_color=(0.8, 0.95, 0.7) if is_google else (0.85, 0.8, 0.9), icon_color=(0.8, 0.95, 0.7) if is_gpgs else (0.85, 0.8, 0.9),
on_activate_call=self._on_achievements_press, on_activate_call=(
self._on_custom_achievements_press
if is_gpgs
else self._on_achievements_press
),
size=(button_width, 50), size=(button_width, 50),
label='', label='',
) )
@ -903,6 +935,7 @@ class AccountSettingsWindow(ba.Window):
scale=0.9, scale=0.9,
color=(0.75, 0.7, 0.8), color=(0.75, 0.7, 0.8),
maxwidth=self._sub_width * 0.95, maxwidth=self._sub_width * 0.95,
text=ba.Lstr(resource=self._r + '.linkedAccountsText'),
h_align='center', h_align='center',
v_align='center', v_align='center',
) )
@ -911,6 +944,8 @@ class AccountSettingsWindow(ba.Window):
else: else:
self._linked_accounts_text = None self._linked_accounts_text = None
# Show link/unlink buttons only for V1 accounts.
if show_link_accounts_button: if show_link_accounts_button:
v -= link_accounts_button_space v -= link_accounts_button_space
self._link_accounts_button = btn = ba.buttonwidget( self._link_accounts_button = btn = ba.buttonwidget(
@ -990,6 +1025,50 @@ class AccountSettingsWindow(ba.Window):
else: else:
self._unlink_accounts_button = None self._unlink_accounts_button = None
if show_v2_link_info:
v -= v2_link_info_space
ba.textwidget(
parent=self._subcontainer,
h_align='center',
v_align='center',
size=(0, 0),
position=(self._sub_width * 0.5, v + v2_link_info_space - 20),
text=ba.Lstr(resource='v2AccountLinkingInfoText'),
flatness=1.0,
scale=0.8,
maxwidth=450,
color=(0.5, 0.45, 0.55),
)
if self._show_legacy_unlink_button:
v -= legacy_unlink_button_space
button_width_w = button_width * 1.5
ba.textwidget(
parent=self._subcontainer,
position=(self._sub_width * 0.5 - 150.0, v + 75),
size=(300.0, 60),
text=ba.Lstr(resource='whatIsThisText'),
scale=0.8,
color=(0.3, 0.7, 0.05),
maxwidth=200.0,
h_align='center',
v_align='center',
autoselect=True,
selectable=True,
on_activate_call=show_what_is_legacy_unlinking_page,
click_activate=True,
)
btn = ba.buttonwidget(
parent=self._subcontainer,
position=((self._sub_width - button_width_w) * 0.5, v + 25),
autoselect=True,
size=(button_width_w, 60),
label=ba.Lstr(resource=self._r + '.unlinkLegacyV1AccountsText'),
textcolor=(0.8, 0.4, 0),
color=(0.55, 0.5, 0.6),
on_activate_call=self._unlink_accounts_press,
)
if show_sign_out_button: if show_sign_out_button:
v -= sign_out_button_space v -= sign_out_button_space
self._sign_out_button = btn = ba.buttonwidget( self._sign_out_button = btn = ba.buttonwidget(
@ -1044,33 +1123,24 @@ class AccountSettingsWindow(ba.Window):
) )
self._needs_refresh = False self._needs_refresh = False
def _on_custom_achievements_press(self) -> None:
ba.timer(
0.15,
ba.Call(ba.internal.show_online_score_ui, 'achievements'),
timetype=ba.TimeType.REAL,
)
def _on_achievements_press(self) -> None: def _on_achievements_press(self) -> None:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bastd.ui import achievements from bastd.ui import achievements
account_state = ba.internal.get_v1_account_state() assert self._achievements_button is not None
account_type = ( achievements.AchievementsWindow(
ba.internal.get_v1_account_type() position=self._achievements_button.get_screen_space_center()
if account_state == 'signed_in'
else 'unknown'
) )
# for google play we use the built-in UI; otherwise pop up our own
if account_type == 'Google Play': def _on_what_is_v2_press(self) -> None:
ba.timer( show_what_is_v2_page()
0.15,
ba.Call(ba.internal.show_online_score_ui, 'achievements'),
timetype=ba.TimeType.REAL,
)
elif account_type != 'unknown':
assert self._achievements_button is not None
achievements.AchievementsWindow(
position=self._achievements_button.get_screen_space_center()
)
else:
print(
'ERROR: unknown account type in on_achievements_press:',
account_type,
)
def _on_manage_account_press(self) -> None: def _on_manage_account_press(self) -> None:
ba.screenmessage(ba.Lstr(resource='oneMomentText')) ba.screenmessage(ba.Lstr(resource='oneMomentText'))
@ -1106,7 +1176,7 @@ class AccountSettingsWindow(ba.Window):
timetype=ba.TimeType.REAL, timetype=ba.TimeType.REAL,
) )
def _have_unlinkable_accounts(self) -> bool: def _have_unlinkable_v1_accounts(self) -> bool:
# if this is not present, we haven't had contact from the server so # if this is not present, we haven't had contact from the server so
# let's not proceed.. # let's not proceed..
if ba.internal.get_public_login_id() is None: if ba.internal.get_public_login_id() is None:
@ -1119,16 +1189,33 @@ class AccountSettingsWindow(ba.Window):
def _update_unlink_accounts_button(self) -> None: def _update_unlink_accounts_button(self) -> None:
if self._unlink_accounts_button is None: if self._unlink_accounts_button is None:
return return
if self._have_unlinkable_accounts(): if self._have_unlinkable_v1_accounts():
clr = (0.75, 0.7, 0.8, 1.0) clr = (0.75, 0.7, 0.8, 1.0)
else: else:
clr = (1.0, 1.0, 1.0, 0.25) clr = (1.0, 1.0, 1.0, 0.25)
ba.textwidget(edit=self._unlink_accounts_button_label, color=clr) ba.textwidget(edit=self._unlink_accounts_button_label, color=clr)
def _should_show_legacy_unlink_button(self) -> bool:
# Only show this when fully signed in to a v2 account.
if not self._v1_signed_in or ba.app.accounts_v2.primary is None:
return False
out = self._have_unlinkable_v1_accounts()
return out
def _update_linked_accounts_text(self) -> None: def _update_linked_accounts_text(self) -> None:
if self._linked_accounts_text is None: if self._linked_accounts_text is None:
return return
# Disable this by default when signed in to a V2 account
# (since this shows V1 links which we should no longer care about).
if (
ba.app.accounts_v2.primary is not None
and not FORCE_ENABLE_V1_LINKING
):
return
# if this is not present, we haven't had contact from the server so # if this is not present, we haven't had contact from the server so
# let's not proceed.. # let's not proceed..
if ba.internal.get_public_login_id() is None: if ba.internal.get_public_login_id() is None:
@ -1138,13 +1225,9 @@ class AccountSettingsWindow(ba.Window):
accounts = ba.internal.get_v1_account_misc_read_val_2( accounts = ba.internal.get_v1_account_misc_read_val_2(
'linkedAccounts', [] 'linkedAccounts', []
) )
# our_account = _bs.get_v1_account_display_string()
# accounts = [a for a in accounts if a != our_account]
# accounts_str = u', '.join(accounts) if accounts else
# ba.Lstr(translate=('settingNames', 'None'))
# UPDATE - we now just print the number here; not the actual # UPDATE - we now just print the number here; not the actual
# accounts # accounts (they can see that in the unlink section if they're
# (they can see that in the unlink section if they're curious) # curious)
accounts_str = str(max(0, len(accounts) - 1)) accounts_str = str(max(0, len(accounts) - 1))
ba.textwidget( ba.textwidget(
edit=self._linked_accounts_text, edit=self._linked_accounts_text,
@ -1195,6 +1278,7 @@ class AccountSettingsWindow(ba.Window):
) )
def _refresh_account_name_text(self) -> None: def _refresh_account_name_text(self) -> None:
if self._account_name_text is None: if self._account_name_text is None:
return return
try: try:
@ -1202,7 +1286,20 @@ class AccountSettingsWindow(ba.Window):
except Exception: except Exception:
ba.print_exception() ba.print_exception()
name_str = '??' name_str = '??'
ba.textwidget(edit=self._account_name_text, text=name_str) ba.textwidget(edit=self._account_name_text, text=name_str)
if self._account_name_what_is_text is not None:
swidth = ba.internal.get_string_width(
name_str, suppress_warning=True
)
# Eww; number-fudging. Need to recalibrate this if
# account name scaling changes.
x = self._sub_width * 0.5 - swidth * 0.75 - 170
ba.textwidget(
edit=self._account_name_what_is_text,
position=(x, self._account_name_what_is_y),
)
def _refresh_achievements(self) -> None: def _refresh_achievements(self) -> None:
if ( if (
@ -1232,7 +1329,7 @@ class AccountSettingsWindow(ba.Window):
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bastd.ui.account import unlink from bastd.ui.account import unlink
if not self._have_unlinkable_accounts(): if not self._have_unlinkable_v1_accounts():
ba.playsound(ba.getsound('error')) ba.playsound(ba.getsound('error'))
return return
unlink.AccountUnlinkWindow(origin_widget=self._unlink_accounts_button) unlink.AccountUnlinkWindow(origin_widget=self._unlink_accounts_button)
@ -1263,6 +1360,11 @@ class AccountSettingsWindow(ba.Window):
def _sign_out_press(self) -> None: def _sign_out_press(self) -> None:
if ba.app.accounts_v2.have_primary_credentials(): if ba.app.accounts_v2.have_primary_credentials():
if (
ba.app.accounts_v2.primary is not None
and LoginType.GPGS in ba.app.accounts_v2.primary.logins
):
self._explicitly_signed_out_of_gpgs = True
ba.app.accounts_v2.set_primary_credentials(None) ba.app.accounts_v2.set_primary_credentials(None)
else: else:
ba.internal.sign_out_v1() ba.internal.sign_out_v1()
@ -1301,7 +1403,8 @@ class AccountSettingsWindow(ba.Window):
if adapter is not None: if adapter is not None:
self._signing_in_adapter = adapter self._signing_in_adapter = adapter
adapter.sign_in( adapter.sign_in(
result_cb=ba.WeakCall(self._on_adapter_sign_in_result) result_cb=ba.WeakCall(self._on_adapter_sign_in_result),
description='account settings button',
) )
# Will get 'Signing in...' to show. # Will get 'Signing in...' to show.
self._needs_refresh = True self._needs_refresh = True
@ -1334,6 +1437,27 @@ class AccountSettingsWindow(ba.Window):
# when finished. # when finished.
ba.app.accounts_v2.set_primary_credentials(result.credentials) ba.app.accounts_v2.set_primary_credentials(result.credentials)
# Special case - if the user has explicitly logged out and
# logged in again with GPGS via this button, warn them that
# they need to use the app if they want to switch to a
# different GPGS account.
if (
self._explicitly_signed_out_of_gpgs
and adapter.login_type is LoginType.GPGS
):
# Delay this slightly so it hopefully pops up after
# credentials go through and the account name shows up.
ba.timer(
1.5,
ba.Call(
ba.screenmessage,
ba.Lstr(
resource=self._r
+ '.googlePlayGamesAccountSwitchText'
),
),
)
# Speed any UI updates along. # Speed any UI updates along.
self._needs_refresh = True self._needs_refresh = True
ba.timer(0.1, ba.WeakCall(self._update), timetype=ba.TimeType.REAL) ba.timer(0.1, ba.WeakCall(self._update), timetype=ba.TimeType.REAL)
@ -1402,3 +1526,15 @@ class AccountSettingsWindow(ba.Window):
ba.containerwidget(edit=self._root_widget, selected_child=sel) ba.containerwidget(edit=self._root_widget, selected_child=sel)
except Exception: except Exception:
ba.print_exception(f'Error restoring state for {self}.') ba.print_exception(f'Error restoring state for {self}.')
def show_what_is_v2_page() -> None:
"""Show the webpage describing V2 accounts."""
bamasteraddr = ba.internal.get_master_server_address(version=2)
ba.open_url(f'{bamasteraddr}/whatisv2')
def show_what_is_legacy_unlinking_page() -> None:
"""Show the webpage describing legacy unlinking."""
bamasteraddr = ba.internal.get_master_server_address(version=2)
ba.open_url(f'{bamasteraddr}/whatarev1links')

View File

@ -67,11 +67,14 @@ class V2ProxySignInWindow(ba.Window):
label=ba.Lstr(resource='cancelText'), label=ba.Lstr(resource='cancelText'),
on_activate_call=self._done, on_activate_call=self._done,
autoselect=True, autoselect=True,
color=(0.55, 0.5, 0.6),
textcolor=(0.75, 0.7, 0.8), textcolor=(0.75, 0.7, 0.8),
) )
ba.containerwidget(
edit=self._root_widget, cancel_button=self._cancel_button if bool(False):
) ba.containerwidget(
edit=self._root_widget, cancel_button=self._cancel_button
)
self._update_timer: ba.Timer | None = None self._update_timer: ba.Timer | None = None

View File

@ -20,12 +20,12 @@ class ConfigErrorWindow(ba.Window):
self._config_file_path = ba.app.config_file_path self._config_file_path = ba.app.config_file_path
width = 800 width = 800
super().__init__( super().__init__(
ba.containerwidget(size=(width, 300), transition='in_right') ba.containerwidget(size=(width, 400), transition='in_right')
) )
padding = 20 padding = 20
ba.textwidget( ba.textwidget(
parent=self._root_widget, parent=self._root_widget,
position=(padding, 220), position=(padding, 220 + 60),
size=(width - 2 * padding, 100 - 2 * padding), size=(width - 2 * padding, 100 - 2 * padding),
h_align='center', h_align='center',
v_align='top', v_align='top',
@ -41,7 +41,7 @@ class ConfigErrorWindow(ba.Window):
) )
ba.textwidget( ba.textwidget(
parent=self._root_widget, parent=self._root_widget,
position=(padding, 198), position=(padding, 198 + 60),
size=(width - 2 * padding, 100 - 2 * padding), size=(width - 2 * padding, 100 - 2 * padding),
h_align='center', h_align='center',
v_align='top', v_align='top',

View File

@ -1094,7 +1094,6 @@ class PublicGatherTab(GatherTab):
self._parties_sorted.sort( self._parties_sorted.sort(
key=lambda p: ( key=lambda p: (
p[1].queue is None, # Show non-queued last.
p[1].ping if p[1].ping is not None else 999999.0, p[1].ping if p[1].ping is not None else 999999.0,
p[1].index, p[1].index,
) )

View File

@ -515,7 +515,20 @@ class MainMenuWindow(ba.Window):
self._tdelay = 2.0 self._tdelay = 2.0
self._t_delay_inc = 0.02 self._t_delay_inc = 0.02
self._t_delay_play = 1.7 self._t_delay_play = 1.7
self._next_refresh_allow_time = ba.time(ba.TimeType.REAL) + 2.01
def _set_allow_time() -> None:
self._next_refresh_allow_time = ba.time(ba.TimeType.REAL) + 2.5
# Slight hack: widget transitions currently only progress when
# frames are being drawn, but this tends to get called before
# frame drawing even starts, meaning we don't know exactly how
# long we should wait before refreshing to avoid interrupting
# the transition. To make things a bit better, let's do a
# redundant set of the time in a deferred call which hopefully
# happens closer to actual frame draw times.
_set_allow_time()
ba.pushcall(_set_allow_time)
ba.app.did_menu_intro = True ba.app.did_menu_intro = True
self._width = 400.0 self._width = 400.0
self._height = 200.0 self._height = 200.0

View File

@ -473,7 +473,6 @@ class PlaylistEditGameWindow(ba.Window):
# Ok now wire up the column. # Ok now wire up the column.
try: try:
# pylint: disable=unsubscriptable-object
prev_widgets: list[ba.Widget] | None = None prev_widgets: list[ba.Widget] | None = None
for cwdg in widget_column: for cwdg in widget_column:
if prev_widgets is not None: if prev_widgets is not None:

View File

@ -4,18 +4,25 @@
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
from __future__ import annotations from __future__ import annotations
import time
import copy import copy
import math import math
import logging
import weakref import weakref
from enum import Enum from enum import Enum
from threading import Thread
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from efro.error import CommunicationError
import bacommon.cloud
import ba import ba
import ba.internal import ba.internal
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any, Callable, Sequence from typing import Any, Callable, Sequence
MERCH_LINK_KEY = 'Merch Link'
class StoreBrowserWindow(ba.Window): class StoreBrowserWindow(ba.Window):
"""Window for browsing the store.""" """Window for browsing the store."""
@ -593,8 +600,14 @@ class StoreBrowserWindow(ba.Window):
else: else:
self._last_buy_time = curtime self._last_buy_time = curtime
# Pro is an actual IAP; the rest are ticket purchases. # Merch is a special case - just a link.
if item == 'pro': if item == 'merch':
url = ba.app.config.get('Merch Link')
if isinstance(url, str):
ba.open_url(url)
# Pro is an actual IAP, and the rest are ticket purchases.
elif item == 'pro':
ba.playsound(ba.getsound('click01')) ba.playsound(ba.getsound('click01'))
# Purchase either pro or pro_sale depending on whether # Purchase either pro or pro_sale depending on whether
@ -681,7 +694,9 @@ class StoreBrowserWindow(ba.Window):
assert self.button_infos is not None assert self.button_infos is not None
for b_type, b_info in self.button_infos.items(): for b_type, b_info in self.button_infos.items():
if b_type in ['upgrades.pro', 'pro']: if b_type == 'merch':
purchased = False
elif b_type in ['upgrades.pro', 'pro']:
purchased = ba.app.accounts_v1.have_pro() purchased = ba.app.accounts_v1.have_pro()
else: else:
purchased = ba.internal.get_purchased(b_type) purchased = ba.internal.get_purchased(b_type)
@ -707,7 +722,11 @@ class StoreBrowserWindow(ba.Window):
color = (0.4, 0.8, 0.1) color = (0.4, 0.8, 0.1)
extra_image_opacity = 1.0 extra_image_opacity = 1.0
call = b_info['call'] if 'call' in b_info else None call = b_info['call'] if 'call' in b_info else None
if b_type in ['upgrades.pro', 'pro']: if b_type == 'merch':
price_text = ''
price_text_left = ''
price_text_right = ''
elif b_type in ['upgrades.pro', 'pro']:
sale_time = get_available_sale_time('extras') sale_time = get_available_sale_time('extras')
if sale_time is not None: if sale_time is not None:
priceraw = ba.internal.get_price('pro') priceraw = ba.internal.get_price('pro')
@ -888,7 +907,14 @@ class StoreBrowserWindow(ba.Window):
dummy_name dummy_name
) )
section['v_spacing'] = ( section['v_spacing'] = (
-17 if self._tab == 'characters' else 0 -25
if (
self._tab == 'extras'
and uiscale is ba.UIScale.SMALL
)
else -17
if self._tab == 'characters'
else 0
) )
if 'title' not in section: if 'title' not in section:
section['title'] = '' section['title'] = ''
@ -900,7 +926,13 @@ class StoreBrowserWindow(ba.Window):
else 0 else 0
) )
section['y_offs'] = ( section['y_offs'] = (
55 20
if (
self._tab == 'extras'
and uiscale is ba.UIScale.SMALL
and ba.app.config.get('Merch Link')
)
else 55
if ( if (
self._tab == 'extras' self._tab == 'extras'
and uiscale is ba.UIScale.SMALL and uiscale is ba.UIScale.SMALL
@ -917,7 +949,9 @@ class StoreBrowserWindow(ba.Window):
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
# pylint: disable=too-many-nested-blocks # pylint: disable=too-many-nested-blocks
from bastd.ui.store import item as storeitemui from bastd.ui.store.item import (
instantiate_store_item_display,
)
title_spacing = 40 title_spacing = 40
button_border = 20 button_border = 20
@ -1102,7 +1136,7 @@ class StoreBrowserWindow(ba.Window):
+ (b_width + button_spacing) * col, + (b_width + button_spacing) * col,
v - b_height + boffs_v2, v - b_height + boffs_v2,
) )
storeitemui.instantiate_store_item_display( instantiate_store_item_display(
item_name, item_name,
item, item,
parent_widget=cnt2, parent_widget=cnt2,
@ -1121,7 +1155,6 @@ class StoreBrowserWindow(ba.Window):
# Wire this button to the equivalent in the # Wire this button to the equivalent in the
# previous row. # previous row.
if prev_row_buttons is not None: if prev_row_buttons is not None:
# pylint: disable=unsubscriptable-object
if len(prev_row_buttons) > col: if len(prev_row_buttons) > col:
ba.widget( ba.widget(
edit=btn, edit=btn,
@ -1325,3 +1358,41 @@ class StoreBrowserWindow(ba.Window):
) )
if self._on_close_call is not None: if self._on_close_call is not None:
self._on_close_call() self._on_close_call()
def _check_merch_availability_in_bg_thread() -> None:
# pylint: disable=cell-var-from-loop
# Merch is available from some countries only.
# Make a reasonable check to ask the master-server about this at
# launch and store the results.
for _i in range(15):
try:
if ba.app.cloud.is_connected():
response = ba.app.cloud.send_message(
bacommon.cloud.MerchAvailabilityMessage()
)
def _store_in_logic_thread() -> None:
cfg = ba.app.config
current: str | None = cfg.get(MERCH_LINK_KEY)
if not isinstance(current, str | None):
current = None
if current != response.url:
cfg[MERCH_LINK_KEY] = response.url
cfg.commit()
# If we successfully get a response, kick it over to the
# logic thread to store and we're done.
ba.pushcall(_store_in_logic_thread, from_other_thread=True)
return
except CommunicationError:
pass
except Exception:
logging.warning(
'Unexpected error in merch-availability-check.', exc_info=True
)
time.sleep(1.1934) # A bit randomized to avoid aliasing.
Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start()

View File

@ -45,19 +45,23 @@ def instantiate_store_item_display(
item['name'] = title = get_store_item_name_translated(item_name) item['name'] = title = get_store_item_name_translated(item_name)
btn: ba.Widget | None btn: ba.Widget | None
# Hack; showbuffer stuff isn't working well when we're showing merch.
showbuffer = 10 if item_name in {'merch', 'pro', 'pro_sale'} else 76.0
if button: if button:
item['button'] = btn = ba.buttonwidget( item['button'] = btn = ba.buttonwidget(
parent=parent_widget, parent=parent_widget,
position=b_pos, position=b_pos,
transition_delay=delay, transition_delay=delay,
show_buffer_top=76.0, show_buffer_top=showbuffer,
enable_sound=False, enable_sound=False,
button_type='square', button_type='square',
size=(b_width, b_height), size=(b_width, b_height),
autoselect=True, autoselect=True,
label='', label='',
) )
ba.widget(edit=btn, show_buffer_bottom=76.0) ba.widget(edit=btn, show_buffer_bottom=showbuffer)
else: else:
btn = None btn = None
@ -92,6 +96,10 @@ def instantiate_store_item_display(
tint_tex = character.icon_mask_texture tint_tex = character.icon_mask_texture
title_v = 0.255 title_v = 0.255
price_v = 0.145 price_v = 0.145
elif item_name == 'merch':
base_text_scale = 0.6
title_v = 0.85
price_v = 0.15
elif item_name in ['upgrades.pro', 'pro']: elif item_name in ['upgrades.pro', 'pro']:
base_text_scale = 0.6 base_text_scale = 0.6
title_v = 0.85 title_v = 0.85
@ -165,6 +173,23 @@ def instantiate_store_item_display(
tint2_color=tint2_color, tint2_color=tint2_color,
) )
if item_name == 'merch':
frame_size = b_width * 0.65
im_dim = frame_size * (100.0 / 113.0)
im_pos = (
b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x,
b_pos[1] + b_height * 0.47 - im_dim * 0.5,
)
ba.imagewidget(
parent=parent_widget,
position=im_pos,
size=(im_dim, im_dim),
transition_delay=delay,
draw_controller=btn,
opacity=1.0,
texture=ba.gettexture('merch'),
)
if item_name in ['pro', 'upgrades.pro']: if item_name in ['pro', 'upgrades.pro']:
frame_size = b_width * 0.5 frame_size = b_width * 0.5
im_dim = frame_size * (100.0 / 113.0) im_dim = frame_size * (100.0 / 113.0)
@ -184,7 +209,6 @@ def instantiate_store_item_display(
) )
txt = ba.Lstr(resource='store.bombSquadProNewDescriptionText') txt = ba.Lstr(resource='store.bombSquadProNewDescriptionText')
# t = 'foo\nfoo\nfoo\nfoo\nfoo\nfoo'
item['descriptionText'] = ba.textwidget( item['descriptionText'] = ba.textwidget(
parent=parent_widget, parent=parent_widget,
text=txt, text=txt,

View File

@ -0,0 +1,117 @@
# Released under the MIT License. See LICENSE for details.
#
"""UI for upgrading V1 accounts to V2."""
from __future__ import annotations
import ba
import ba.internal
class V2UpgradeWindow(ba.Window):
"""A window presenting a URL to the user visually."""
def __init__(self, login_name: str, code: str):
from bastd.ui.account.settings import show_what_is_v2_page
app = ba.app
uiscale = app.ui.uiscale
self._code = code
self._width = 700
self._height = 270
super().__init__(
root_widget=ba.containerwidget(
size=(self._width, self._height + 40),
transition='in_right',
scale=(
1.25
if uiscale is ba.UIScale.SMALL
else 1.25
if uiscale is ba.UIScale.MEDIUM
else 1.25
),
)
)
ba.playsound(ba.getsound('error'))
ba.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 46),
size=(0, 0),
color=ba.app.ui.title_color,
h_align='center',
v_align='center',
text=ba.Lstr(
resource='deviceAccountUpgradeText',
subs=[('${NAME}', login_name)],
),
maxwidth=self._width * 0.95,
)
ba.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, 125),
size=(0, 0),
scale=0.8,
color=(0.7, 0.8, 0.7),
h_align='center',
v_align='center',
text=(
ba.charstr(ba.SpecialChar.LOCAL_ACCOUNT)
+ login_name
+ ' ----> '
+ ba.charstr(ba.SpecialChar.V2_LOGO)
+ login_name
),
maxwidth=self._width * 0.95,
)
button_width = 200
cancel_button = ba.buttonwidget(
parent=self._root_widget,
position=(20, 25),
size=(button_width, 65),
autoselect=True,
label=ba.Lstr(resource='notNowText'),
on_activate_call=self._done,
)
_what_is_this_button = ba.buttonwidget(
parent=self._root_widget,
position=(self._width * 0.5 - button_width * 0.5, 25),
size=(button_width, 65),
autoselect=True,
label=ba.Lstr(resource='whatIsThisText'),
color=(0.55, 0.5, 0.6),
textcolor=(0.75, 0.7, 0.8),
on_activate_call=show_what_is_v2_page,
)
upgrade_button = ba.buttonwidget(
parent=self._root_widget,
position=(self._width - button_width - 20, 25),
size=(button_width, 65),
autoselect=True,
label=ba.Lstr(resource='upgradeText'),
on_activate_call=self._upgrade_press,
)
ba.containerwidget(
edit=self._root_widget,
selected_child=upgrade_button,
cancel_button=cancel_button,
)
def _upgrade_press(self) -> None:
# Get rid of the window and sign out before kicking the
# user over to a browser to do the upgrade. This hopefully
# makes it more clear when they come back that they need to
# sign in with the 'BombSquad account' option.
ba.containerwidget(edit=self._root_widget, transition='out_left')
ba.internal.sign_out_v1()
bamasteraddr = ba.internal.get_master_server_address(version=2)
ba.open_url(f'{bamasteraddr}/v2uda/{self._code}')
def _done(self) -> None:
ba.containerwidget(edit=self._root_widget, transition='out_left')

View File

@ -48,6 +48,7 @@
<w>allobjs</w> <w>allobjs</w>
<w>allocs</w> <w>allocs</w>
<w>allwarnings</w> <w>allwarnings</w>
<w>alogins</w>
<w>alot</w> <w>alot</w>
<w>alphaimg</w> <w>alphaimg</w>
<w>alphapixels</w> <w>alphapixels</w>
@ -71,6 +72,7 @@
<w>appconfig</w> <w>appconfig</w>
<w>appname</w> <w>appname</w>
<w>appnameupper</w> <w>appnameupper</w>
<w>appnow</w>
<w>appspot</w> <w>appspot</w>
<w>appstate</w> <w>appstate</w>
<w>apptime</w> <w>apptime</w>
@ -93,6 +95,7 @@
<w>avel</w> <w>avel</w>
<w>avels</w> <w>avels</w>
<w>awaitable</w> <w>awaitable</w>
<w>awaitables</w>
<w>axismotion</w> <w>axismotion</w>
<w>backgrounded</w> <w>backgrounded</w>
<w>backgrounding</w> <w>backgrounding</w>
@ -217,6 +220,7 @@
<w>cend</w> <w>cend</w>
<w>centiseconds</w> <w>centiseconds</w>
<w>certifi</w> <w>certifi</w>
<w>cfgdict</w>
<w>cfgdir</w> <w>cfgdir</w>
<w>cfgpath</w> <w>cfgpath</w>
<w>changeme</w> <w>changeme</w>
@ -367,6 +371,7 @@
<w>doraise</w> <w>doraise</w>
<w>dosomething</w> <w>dosomething</w>
<w>dout</w> <w>dout</w>
<w>downcasting</w>
<w>downsample</w> <w>downsample</w>
<w>dpad</w> <w>dpad</w>
<w>dpads</w> <w>dpads</w>
@ -385,11 +390,13 @@
<w>dummyret</w> <w>dummyret</w>
<w>dummyval</w> <w>dummyval</w>
<w>dummyvalid</w> <w>dummyvalid</w>
<w>dumpminlog</w>
<w>dval</w> <w>dval</w>
<w>dxgi</w> <w>dxgi</w>
<w>dynamicdata</w> <w>dynamicdata</w>
<w>echidna</w> <w>echidna</w>
<w>echofile</w> <w>echofile</w>
<w>echotime</w>
<w>edef</w> <w>edef</w>
<w>effmult</w> <w>effmult</w>
<w>efro</w> <w>efro</w>
@ -456,9 +463,11 @@
<w>fastldlt</w> <w>fastldlt</w>
<w>fastlsolve</w> <w>fastlsolve</w>
<w>fastltsolve</w> <w>fastltsolve</w>
<w>faulthandler</w>
<w>fbos</w> <w>fbos</w>
<w>fcntl</w> <w>fcntl</w>
<w>fdata</w> <w>fdata</w>
<w>fdcount</w>
<w>fdirx</w> <w>fdirx</w>
<w>fdiry</w> <w>fdiry</w>
<w>fdirz</w> <w>fdirz</w>
@ -667,6 +676,7 @@
<w>ioprepped</w> <w>ioprepped</w>
<w>ioprepping</w> <w>ioprepping</w>
<w>ioreg</w> <w>ioreg</w>
<w>iscoroutinefunction</w>
<w>iserverget</w> <w>iserverget</w>
<w>iserverput</w> <w>iserverput</w>
<w>isinst</w> <w>isinst</w>
@ -728,6 +738,7 @@
<w>leaderboards</w> <w>leaderboards</w>
<w>lenval</w> <w>lenval</w>
<w>levelno</w> <w>levelno</w>
<w>levelnos</w>
<w>levelstr</w> <w>levelstr</w>
<w>lgui</w> <w>lgui</w>
<w>lhalf</w> <w>lhalf</w>
@ -797,11 +808,13 @@
<w>maskuv</w> <w>maskuv</w>
<w>masterver</w> <w>masterver</w>
<w>mastervers</w> <w>mastervers</w>
<w>matchlines</w>
<w>maximus</w> <w>maximus</w>
<w>maxpathlen</w> <w>maxpathlen</w>
<w>maxtries</w> <w>maxtries</w>
<w>maxwait</w> <w>maxwait</w>
<w>maxwidth</w> <w>maxwidth</w>
<w>mdpath</w>
<w>mediump</w> <w>mediump</w>
<w>memalign</w> <w>memalign</w>
<w>memchr</w> <w>memchr</w>
@ -819,6 +832,7 @@
<w>millisecs</w> <w>millisecs</w>
<w>minelem</w> <w>minelem</w>
<w>miniplayer</w> <w>miniplayer</w>
<w>minlog</w>
<w>minping</w> <w>minping</w>
<w>minsdl</w> <w>minsdl</w>
<w>mipmapcount</w> <w>mipmapcount</w>
@ -1240,6 +1254,7 @@
<w>shhh</w> <w>shhh</w>
<w>shifthigh</w> <w>shifthigh</w>
<w>shouldnt</w> <w>shouldnt</w>
<w>showbuffer</w>
<w>shufflable</w> <w>shufflable</w>
<w>signsubscale</w> <w>signsubscale</w>
<w>sigsetjmp</w> <w>sigsetjmp</w>
@ -1334,6 +1349,7 @@
<w>subtypestr</w> <w>subtypestr</w>
<w>successmsg</w> <w>successmsg</w>
<w>sval</w> <w>sval</w>
<w>swidth</w>
<w>swiftc</w> <w>swiftc</w>
<w>symbolification</w> <w>symbolification</w>
<w>syscalls</w> <w>syscalls</w>
@ -1345,6 +1361,9 @@
<w>targs</w> <w>targs</w>
<w>tasklabel</w> <w>tasklabel</w>
<w>tbegin</w> <w>tbegin</w>
<w>tbfile</w>
<w>tbfiles</w>
<w>tbpath</w>
<w>tcls</w> <w>tcls</w>
<w>tdels</w> <w>tdels</w>
<w>tdiff</w> <w>tdiff</w>
@ -1500,6 +1519,7 @@
<w>wakeups</w> <w>wakeups</w>
<w>walisser</w> <w>walisser</w>
<w>wasdebug</w> <w>wasdebug</w>
<w>wasn</w>
<w>watte</w> <w>watte</w>
<w>wdeprecated</w> <w>wdeprecated</w>
<w>weakref</w> <w>weakref</w>
@ -1508,6 +1528,8 @@
<w>weeeird</w> <w>weeeird</w>
<w>welp</w> <w>welp</w>
<w>whaaaaaaa</w> <w>whaaaaaaa</w>
<w>whatarev</w>
<w>whatisv</w>
<w>wheee</w> <w>wheee</w>
<w>wheeee</w> <w>wheeee</w>
<w>wiimote</w> <w>wiimote</w>

View File

@ -464,7 +464,6 @@ add_executable(ballisticacore
${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host.h ${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host.h
${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host_udp.cc ${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host_udp.cc
${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host_udp.h ${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host_udp.h
${BA_SRC_ROOT}/ballistica/logic/friend_score_set.h
${BA_SRC_ROOT}/ballistica/logic/host_activity.cc ${BA_SRC_ROOT}/ballistica/logic/host_activity.cc
${BA_SRC_ROOT}/ballistica/logic/host_activity.h ${BA_SRC_ROOT}/ballistica/logic/host_activity.h
${BA_SRC_ROOT}/ballistica/logic/logic.cc ${BA_SRC_ROOT}/ballistica/logic/logic.cc

View File

@ -455,7 +455,6 @@
<ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host.h" /> <ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host.h" />
<ClCompile Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.cc" /> <ClCompile Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.cc" />
<ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h" /> <ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h" />
<ClInclude Include="..\..\src\ballistica\logic\friend_score_set.h" />
<ClCompile Include="..\..\src\ballistica\logic\host_activity.cc" /> <ClCompile Include="..\..\src\ballistica\logic\host_activity.cc" />
<ClInclude Include="..\..\src\ballistica\logic\host_activity.h" /> <ClInclude Include="..\..\src\ballistica\logic\host_activity.h" />
<ClCompile Include="..\..\src\ballistica\logic\logic.cc" /> <ClCompile Include="..\..\src\ballistica\logic\logic.cc" />

View File

@ -799,9 +799,6 @@
<ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h"> <ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h">
<Filter>ballistica\logic\connection</Filter> <Filter>ballistica\logic\connection</Filter>
</ClInclude> </ClInclude>
<ClInclude Include="..\..\src\ballistica\logic\friend_score_set.h">
<Filter>ballistica\logic</Filter>
</ClInclude>
<ClCompile Include="..\..\src\ballistica\logic\host_activity.cc"> <ClCompile Include="..\..\src\ballistica\logic\host_activity.cc">
<Filter>ballistica\logic</Filter> <Filter>ballistica\logic</Filter>
</ClCompile> </ClCompile>

View File

@ -450,7 +450,6 @@
<ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host.h" /> <ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host.h" />
<ClCompile Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.cc" /> <ClCompile Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.cc" />
<ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h" /> <ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h" />
<ClInclude Include="..\..\src\ballistica\logic\friend_score_set.h" />
<ClCompile Include="..\..\src\ballistica\logic\host_activity.cc" /> <ClCompile Include="..\..\src\ballistica\logic\host_activity.cc" />
<ClInclude Include="..\..\src\ballistica\logic\host_activity.h" /> <ClInclude Include="..\..\src\ballistica\logic\host_activity.h" />
<ClCompile Include="..\..\src\ballistica\logic\logic.cc" /> <ClCompile Include="..\..\src\ballistica\logic\logic.cc" />

View File

@ -799,9 +799,6 @@
<ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h"> <ClInclude Include="..\..\src\ballistica\logic\connection\connection_to_host_udp.h">
<Filter>ballistica\logic\connection</Filter> <Filter>ballistica\logic\connection</Filter>
</ClInclude> </ClInclude>
<ClInclude Include="..\..\src\ballistica\logic\friend_score_set.h">
<Filter>ballistica\logic</Filter>
</ClInclude>
<ClCompile Include="..\..\src\ballistica\logic\host_activity.cc"> <ClCompile Include="..\..\src\ballistica\logic\host_activity.cc">
<Filter>ballistica\logic</Filter> <Filter>ballistica\logic</Filter>
</ClCompile> </ClCompile>

View File

@ -156,7 +156,6 @@ void AppFlavor::UpdatePauseResume() {
void AppFlavor::OnPause() { void AppFlavor::OnPause() {
assert(InMainThread()); assert(InMainThread());
// Avoid reading gyro values for a short time to avoid hitches when restored.
g_graphics->SetGyroEnabled(false); g_graphics->SetGyroEnabled(false);
// IMPORTANT: Any on-pause related stuff that threads need to do must // IMPORTANT: Any on-pause related stuff that threads need to do must
@ -234,20 +233,64 @@ void AppFlavor::SetProductPrice(const std::string& product,
void AppFlavor::PauseApp() { void AppFlavor::PauseApp() {
assert(InMainThread()); assert(InMainThread());
millisecs_t start_time{Platform::GetCurrentMilliseconds()};
// Apple mentioned 5 seconds to run stuff once backgrounded or
// they bring down the hammer. Let's aim to stay under 2.
millisecs_t max_duration{2000};
Platform::DebugLog("PauseApp@" Platform::DebugLog("PauseApp@"
+ std::to_string(Platform::GetCurrentMilliseconds())); + std::to_string(Platform::GetCurrentMilliseconds()));
assert(!sys_paused_app_); assert(!sys_paused_app_);
sys_paused_app_ = true; sys_paused_app_ = true;
UpdatePauseResume(); UpdatePauseResume();
// We assume that the OS will completely suspend our process the moment
// we return from this call (though this is not technically true on all
// platforms). So we want to spin and wait for threads to actually
// process the pause message.
size_t running_thread_count{};
while (std::abs(Platform::GetCurrentMilliseconds() - start_time)
< max_duration) {
// If/when we get to a point with no threads waiting to be paused,
// we're good to go.
auto threads{Thread::GetStillPausingThreads()};
running_thread_count = threads.size();
if (running_thread_count == 0) {
if (g_buildconfig.debug_build()) {
Log(LogLevel::kDebug,
"PauseApp() completed in "
+ std::to_string(Platform::GetCurrentMilliseconds()
- start_time)
+ "ms.");
}
return;
}
}
// If we made it here, we timed out. Complain.
Log(LogLevel::kError,
std::string("PauseApp() took too long; ")
+ std::to_string(running_thread_count)
+ " threads not yet paused after "
+ std::to_string(Platform::GetCurrentMilliseconds() - start_time)
+ " ms.");
} }
void AppFlavor::ResumeApp() { void AppFlavor::ResumeApp() {
assert(InMainThread()); assert(InMainThread());
millisecs_t start_time{Platform::GetCurrentMilliseconds()};
Platform::DebugLog("ResumeApp@" Platform::DebugLog("ResumeApp@"
+ std::to_string(Platform::GetCurrentMilliseconds())); + std::to_string(Platform::GetCurrentMilliseconds()));
assert(sys_paused_app_); assert(sys_paused_app_);
sys_paused_app_ = false; sys_paused_app_ = false;
UpdatePauseResume(); UpdatePauseResume();
if (g_buildconfig.debug_build()) {
Log(LogLevel::kDebug,
"ResumeApp() completed in "
+ std::to_string(Platform::GetCurrentMilliseconds() - start_time)
+ "ms.");
}
} }
void AppFlavor::DidFinishRenderingFrame(FrameDef* frame) {} void AppFlavor::DidFinishRenderingFrame(FrameDef* frame) {}
@ -265,6 +308,7 @@ void AppFlavor::PrimeEventPump() {
#pragma mark Push-Calls #pragma mark Push-Calls
// FIXME - move this call to Platform.
void AppFlavor::PushShowOnlineScoreUICall(const std::string& show, void AppFlavor::PushShowOnlineScoreUICall(const std::string& show,
const std::string& game, const std::string& game,
const std::string& game_version) { const std::string& game_version) {
@ -316,14 +360,6 @@ void AppFlavor::PushOpenURLCall(const std::string& url) {
thread()->PushCall([url] { g_platform->OpenURL(url); }); thread()->PushCall([url] { g_platform->OpenURL(url); });
} }
void AppFlavor::PushGetFriendScoresCall(const std::string& game,
const std::string& game_version,
void* data) {
thread()->PushCall([game, game_version, data] {
g_platform->GetFriendScores(game, game_version, data);
});
}
void AppFlavor::PushSubmitScoreCall(const std::string& game, void AppFlavor::PushSubmitScoreCall(const std::string& game,
const std::string& game_version, const std::string& game_version,
int64_t score) { int64_t score) {

View File

@ -43,16 +43,17 @@ class AppFlavor {
/// Should process any pending OS events, etc. /// Should process any pending OS events, etc.
virtual auto RunEvents() -> void; virtual auto RunEvents() -> void;
// These should be called by the window, view-controller, sdl, /// Put the app into a paused state. Should be called from the main
// or whatever is driving the app. They must be called from the main thread. /// thread. Pauses work, closes network sockets, etc.
/// Corresponds to being backgrounded on mobile, etc.
/// Should be called on mobile when the app is backgrounded. /// It is assumed that, as soon as this call returns, all work is
/// Pauses threads, closes network sockets, etc. /// finished and all threads can be suspended by the OS without any
/// negative side effects.
auto PauseApp() -> void; auto PauseApp() -> void;
auto paused() const -> bool { return actually_paused_; } auto paused() const -> bool { return actually_paused_; }
/// Should be called on mobile when the app is foregrounded. /// Resume the app; corresponds to returning to foreground on mobile/etc.
/// Spins threads back up, re-opens network sockets, etc. /// Spins threads back up, re-opens network sockets, etc.
auto ResumeApp() -> void; auto ResumeApp() -> void;
@ -101,9 +102,6 @@ class AppFlavor {
auto PushShowOnlineScoreUICall(const std::string& show, auto PushShowOnlineScoreUICall(const std::string& show,
const std::string& game, const std::string& game,
const std::string& game_version) -> void; const std::string& game_version) -> void;
auto PushGetFriendScoresCall(const std::string& game,
const std::string& game_version, void* data)
-> void;
auto PushSubmitScoreCall(const std::string& game, auto PushSubmitScoreCall(const std::string& game,
const std::string& game_version, int64_t score) const std::string& game_version, int64_t score)
-> void; -> void;

View File

@ -32,8 +32,8 @@
namespace ballistica { namespace ballistica {
// These are set automatically via script; don't modify them here. // These are set automatically via script; don't modify them here.
const int kAppBuildNumber = 20934; const int kAppBuildNumber = 20993;
const char* kAppVersion = "1.7.14"; const char* kAppVersion = "1.7.19";
// Our standalone globals. // Our standalone globals.
// These are separated out for easy access. // These are separated out for easy access.

View File

@ -70,11 +70,11 @@ auto Logging::V1CloudLog(const std::string& msg) -> void {
std::scoped_lock lock(g_app->v1_cloud_log_mutex); std::scoped_lock lock(g_app->v1_cloud_log_mutex);
if (!g_app->v1_cloud_log_full) { if (!g_app->v1_cloud_log_full) {
(g_app->v1_cloud_log) += (msg + "\n"); (g_app->v1_cloud_log) += (msg + "\n");
if ((g_app->v1_cloud_log).size() > 10000) { if ((g_app->v1_cloud_log).size() > 25000) {
// Allow some reasonable overflow for last statement. // Allow some reasonable overflow for last statement.
if ((g_app->v1_cloud_log).size() > 100000) { if ((g_app->v1_cloud_log).size() > 250000) {
// FIXME: This could potentially chop up utf-8 chars. // FIXME: This could potentially chop up utf-8 chars.
(g_app->v1_cloud_log).resize(100000); (g_app->v1_cloud_log).resize(250000);
} }
g_app->v1_cloud_log += "\n<max log size reached>\n"; g_app->v1_cloud_log += "\n<max log size reached>\n";
g_app->v1_cloud_log_full = true; g_app->v1_cloud_log_full = true;

View File

@ -11,7 +11,7 @@
namespace ballistica { namespace ballistica {
void Object::PrintObjects() { void Object::LsObjects() {
#if BA_DEBUG_BUILD #if BA_DEBUG_BUILD
std::string s; std::string s;
{ {
@ -47,7 +47,7 @@ void Object::PrintObjects() {
for (auto&& i : obj_map) { for (auto&& i : obj_map) {
sorted.emplace_back(i.second, i.first); sorted.emplace_back(i.second, i.first);
} }
std::sort(sorted.begin(), sorted.end()); std::sort(sorted.rbegin(), sorted.rend());
for (auto&& i : sorted) { for (auto&& i : sorted) {
s += "\n " + std::to_string(i.first) + ": " + i.second; s += "\n " + std::to_string(i.first) + ": " + i.second;
} }
@ -56,7 +56,7 @@ void Object::PrintObjects() {
} }
Log(LogLevel::kInfo, s); Log(LogLevel::kInfo, s);
#else #else
Log(LogLevel::kInfo, "PrintObjects() only functions in debug builds."); Log(LogLevel::kInfo, "LsObjects() only functions in debug builds.");
#endif // BA_DEBUG_BUILD #endif // BA_DEBUG_BUILD
} }

View File

@ -21,8 +21,8 @@ class Object {
Object(); Object();
virtual ~Object(); virtual ~Object();
/// Prints a tally of object types and counts (debug build only). /// Logs a tally of ba::Object types and counts (debug build only).
static void PrintObjects(); static void LsObjects();
// Object classes can provide descriptive names for themselves; // Object classes can provide descriptive names for themselves;
// these are used for debugging and other purposes. // these are used for debugging and other purposes.

View File

@ -91,7 +91,7 @@ auto Thread::RunAssetsThreadP(void* data) -> void* {
return nullptr; return nullptr;
} }
void Thread::SetPaused(bool paused) { void Thread::PushSetPaused(bool paused) {
// Can be toggled from the main thread only. // Can be toggled from the main thread only.
assert(std::this_thread::get_id() == g_app->main_thread_id); assert(std::this_thread::get_id() == g_app->main_thread_id);
PushThreadMessage(ThreadMessage(paused ? ThreadMessage::Type::kPause PushThreadMessage(ThreadMessage(paused ? ThreadMessage::Type::kPause
@ -101,13 +101,24 @@ void Thread::SetPaused(bool paused) {
void Thread::WaitForNextEvent(bool single_cycle) { void Thread::WaitForNextEvent(bool single_cycle) {
// If we're running a single cycle we never stop to wait. // If we're running a single cycle we never stop to wait.
if (single_cycle) { if (single_cycle) {
// Need to revisit this if we ever do single-cycle for
// the gil-holding thread so we don't starve other Python threads.
assert(!acquires_python_gil_);
return; return;
} }
// We also never wait if we have pending runnables. // We also never wait if we have pending runnables; we wan't to run
// (we run all existing runnables in each loop cycle, but one of those // things as soon as we can. We chew through all runnables at the end
// may have enqueued more). // of the loop so it might seem like there should never be any here,
if (has_pending_runnables()) { // but runnables can add other runnables that won't get processed until
// the next time through.
// BUG FIX: We now skip this if we're paused since we don't run runnables
// in that case. This was preventing us from releasing the GIL while paused
// (and I assume causing us to spin full-speed through the loop; ugh).
// NOTE: It is theoretically possible for a runnable to add another runnable
// each time through the loop which would effectively starve the GIL as
// well; do we need to worry about that case?
if (has_pending_runnables() && !paused_) {
return; return;
} }
@ -422,15 +433,17 @@ Thread::~Thread() = default;
#pragma clang diagnostic push #pragma clang diagnostic push
#pragma ide diagnostic ignored "ConstantConditionsOC" #pragma ide diagnostic ignored "ConstantConditionsOC"
void Thread::LogThreadMessageTally() { void Thread::LogThreadMessageTally(
std::vector<std::pair<LogLevel, std::string>>* log_entries) {
// Prevent recursion. // Prevent recursion.
if (!writing_tally_) { if (!writing_tally_) {
writing_tally_ = true; writing_tally_ = true;
std::unordered_map<std::string, int> tally; std::unordered_map<std::string, int> tally;
Log(LogLevel::kError, "Thread message tally (" log_entries->emplace_back(std::make_pair(
LogLevel::kError, "Thread message tally ("
+ std::to_string(thread_messages_.size()) + std::to_string(thread_messages_.size())
+ " in list):"); + " in list):"));
for (auto&& m : thread_messages_) { for (auto&& m : thread_messages_) {
std::string s; std::string s;
switch (m.type) { switch (m.type) {
@ -464,8 +477,9 @@ void Thread::LogThreadMessageTally() {
} }
int entry = 1; int entry = 1;
for (auto&& i : tally) { for (auto&& i : tally) {
Log(LogLevel::kError, " #" + std::to_string(entry++) + " (" log_entries->emplace_back(std::make_pair(
+ std::to_string(i.second) + "x): " + i.first); LogLevel::kError, " #" + std::to_string(entry++) + " ("
+ std::to_string(i.second) + "x): " + i.first));
} }
writing_tally_ = false; writing_tally_ = false;
} }
@ -473,15 +487,18 @@ void Thread::LogThreadMessageTally() {
#pragma clang diagnostic pop #pragma clang diagnostic pop
void Thread::PushThreadMessage(const ThreadMessage& t) { void Thread::PushThreadMessage(const ThreadMessage& t) {
// We don't want to make log calls while holding this mutex;
// log calls acquire the GIL and if the GIL-holder (generally
// the logic thread) is trying to send a thread message to the
// thread doing the logging we would get deadlock.
// So tally up any logs and send them after.
std::vector<std::pair<LogLevel, std::string>> log_entries;
{ {
std::unique_lock<std::mutex> lock(thread_message_mutex_); std::unique_lock<std::mutex> lock(thread_message_mutex_);
// Plop the data on to the list; we're assuming the mutex is locked. // Plop the data on to the list; we're assuming the mutex is locked.
thread_messages_.push_back(t); thread_messages_.push_back(t);
// Keep our own count; apparently size() on an stl list involves iterating.
// FIXME: Actually I don't think this is the case anymore; should check.
// Debugging: show message count states. // Debugging: show message count states.
if (explicit_bool(false)) { if (explicit_bool(false)) {
static int one_off = 0; static int one_off = 0;
@ -498,8 +515,9 @@ void Thread::PushThreadMessage(const ThreadMessage& t) {
// Show count periodically. // Show count periodically.
if ((std::this_thread::get_id() == g_app->main_thread_id) && foo > 100) { if ((std::this_thread::get_id() == g_app->main_thread_id) && foo > 100) {
foo = 0; foo = 0;
Log(LogLevel::kInfo, log_entries.emplace_back(std::make_pair(
"MSG COUNT " + std::to_string(thread_messages_.size())); LogLevel::kInfo,
"MSG COUNT " + std::to_string(thread_messages_.size())));
} }
} }
@ -507,9 +525,11 @@ void Thread::PushThreadMessage(const ThreadMessage& t) {
static bool sent_error = false; static bool sent_error = false;
if (!sent_error) { if (!sent_error) {
sent_error = true; sent_error = true;
Log(LogLevel::kError, log_entries.emplace_back(std::make_pair(
"ThreadMessage list > 1000 in thread: " + GetCurrentThreadName()); LogLevel::kError,
LogThreadMessageTally(); "ThreadMessage list > 1000 in thread: " + GetCurrentThreadName()));
LogThreadMessageTally(&log_entries);
} }
} }
@ -523,15 +543,36 @@ void Thread::PushThreadMessage(const ThreadMessage& t) {
// available. // available.
} }
thread_message_cv_.notify_all(); thread_message_cv_.notify_all();
// Now log anything we accumulated safely outside of the locked section.
for (auto&& log_entry : log_entries) {
Log(log_entry.first, log_entry.second);
}
} }
void Thread::SetThreadsPaused(bool paused) { auto Thread::SetThreadsPaused(bool paused) -> void {
assert(std::this_thread::get_id() == g_app->main_thread_id);
g_app->threads_paused = paused; g_app->threads_paused = paused;
for (auto&& i : g_app->pausable_threads) { for (auto&& i : g_app->pausable_threads) {
i->SetPaused(paused); i->PushSetPaused(paused);
} }
} }
auto Thread::GetStillPausingThreads() -> std::vector<Thread*> {
std::vector<Thread*> threads;
assert(std::this_thread::get_id() == g_app->main_thread_id);
// Only return results if an actual pause is in effect.
if (g_app->threads_paused) {
for (auto&& i : g_app->pausable_threads) {
if (!i->paused()) {
threads.push_back(i);
}
}
}
return threads;
}
auto Thread::AreThreadsPaused() -> bool { return g_app->threads_paused; } auto Thread::AreThreadsPaused() -> bool { return g_app->threads_paused; }
auto Thread::NewTimer(millisecs_t length, bool repeat, auto Thread::NewTimer(millisecs_t length, bool repeat,
@ -553,6 +594,7 @@ auto Thread::GetCurrentThreadName() -> std::string {
} }
} }
// Ask pthread for the thread name if we don't have one.
// FIXME - move this to platform. // FIXME - move this to platform.
#if BA_OSTYPE_MACOS || BA_OSTYPE_IOS_TVOS || BA_OSTYPE_LINUX #if BA_OSTYPE_MACOS || BA_OSTYPE_IOS_TVOS || BA_OSTYPE_LINUX
std::string name = "unknown (sys-name="; std::string name = "unknown (sys-name=";

View File

@ -45,7 +45,8 @@ class Thread {
void SetAcquiresPythonGIL(); void SetAcquiresPythonGIL();
void SetPaused(bool paused); void PushSetPaused(bool paused);
auto thread_id() const -> std::thread::id { return thread_id_; } auto thread_id() const -> std::thread::id { return thread_id_; }
// Needed in rare cases where we jump physical threads. // Needed in rare cases where we jump physical threads.
@ -97,6 +98,10 @@ class Thread {
/// the app through a flood of packets. /// the app through a flood of packets.
auto CheckPushSafety() -> bool; auto CheckPushSafety() -> bool;
static auto GetStillPausingThreads() -> std::vector<Thread*>;
auto paused() { return paused_; }
private: private:
struct ThreadMessage { struct ThreadMessage {
enum class Type { kShutdown = 999, kRunnable, kPause, kResume }; enum class Type { kShutdown = 999, kRunnable, kPause, kResume };
@ -113,7 +118,8 @@ class Thread {
auto SetInternalThreadName(const std::string& name) -> void; auto SetInternalThreadName(const std::string& name) -> void;
auto WaitForNextEvent(bool single_cycle) -> void; auto WaitForNextEvent(bool single_cycle) -> void;
auto LoopUpkeep(bool once) -> void; auto LoopUpkeep(bool once) -> void;
auto LogThreadMessageTally() -> void; auto LogThreadMessageTally(
std::vector<std::pair<LogLevel, std::string>>* log_entries) -> void;
auto PushLocalRunnable(Runnable* runnable, bool* completion_flag) -> void; auto PushLocalRunnable(Runnable* runnable, bool* completion_flag) -> void;
auto PushCrossThreadRunnable(Runnable* runnable, bool* completion_flag) auto PushCrossThreadRunnable(Runnable* runnable, bool* completion_flag)
-> void; -> void;

View File

@ -80,7 +80,6 @@ class Data;
class DataData; class DataData;
class Dynamics; class Dynamics;
class FrameDef; class FrameDef;
struct FriendScoreSet;
class GLContext; class GLContext;
class GlobalsNode; class GlobalsNode;
class Graphics; class Graphics;

View File

@ -61,9 +61,9 @@
#if BA_OSTYPE_MACOS #if BA_OSTYPE_MACOS
#if BA_XCODE_BUILD #if BA_XCODE_BUILD
#include <OpenGL/gl.h> #include <OpenGL/gl.h>
#include <OpenGL/glext.h>
#include <OpenGL/glu.h> #include <OpenGL/glu.h>
#endif // BA_XCODE_BUILD #endif // BA_XCODE_BUILD
#include <OpenGL/glext.h>
#endif // BA_OSTYPE_MACOS #endif // BA_OSTYPE_MACOS
#endif // BA_OSTYPE_IOS_TVOS || BA_OSTYPE_ANDROID #endif // BA_OSTYPE_IOS_TVOS || BA_OSTYPE_ANDROID

View File

@ -1818,4 +1818,48 @@ auto Input::GetKeyName(int keycode) -> std::string {
#pragma clang diagnostic pop #pragma clang diagnostic pop
#endif // BA_SDL2_BUILD || BA_MINSDL_BUILD #endif // BA_SDL2_BUILD || BA_MINSDL_BUILD
auto Input::LsInputDevices() -> void {
BA_PRECONDITION(InLogicThread());
std::string out;
std::string ind{" "};
int index{0};
for (auto& device : input_devices_) {
if (index != 0) {
out += "\n";
}
out += std::to_string(index + 1) + ":\n";
out += ind + "name: " + device->GetDeviceName() + "\n";
out += ind + "index: " + std::to_string(device->index()) + "\n";
out += (ind + "is-controller: " + std::to_string(device->IsController())
+ "\n");
out += (ind + "is-sdl-controller: "
+ std::to_string(device->IsSDLController()) + "\n");
out += (ind + "is-touch-screen: " + std::to_string(device->IsTouchScreen())
+ "\n");
out += (ind + "is-remote-control: "
+ std::to_string(device->IsRemoteControl()) + "\n");
out += (ind + "is-test-input: " + std::to_string(device->IsTestInput())
+ "\n");
out +=
(ind + "is-keyboard: " + std::to_string(device->IsKeyboard()) + "\n");
out += (ind + "is-mfi-controller: "
+ std::to_string(device->IsMFiController()) + "\n");
out += (ind + "is-local: " + std::to_string(device->IsLocal()) + "\n");
out += (ind + "is-ui-only: " + std::to_string(device->IsUIOnly()) + "\n");
out += (ind + "is-remote-app: " + std::to_string(device->IsRemoteApp())
+ "\n");
out += ind + "attached-to: "
+ (device->GetRemotePlayer() != nullptr ? "remote-player"
: device->GetPlayer() != nullptr ? "local-player"
: "nothing");
++index;
}
Log(LogLevel::kInfo, out);
}
} // namespace ballistica } // namespace ballistica

View File

@ -126,6 +126,7 @@ class Input {
auto PushTouchEvent(const TouchEvent& touch_event) -> void; auto PushTouchEvent(const TouchEvent& touch_event) -> void;
auto PushDestroyKeyboardInputDevices() -> void; auto PushDestroyKeyboardInputDevices() -> void;
auto PushCreateKeyboardInputDevices() -> void; auto PushCreateKeyboardInputDevices() -> void;
auto LsInputDevices() -> void;
/// Roughly how long in milliseconds have all input devices been idle. /// Roughly how long in milliseconds have all input devices been idle.
auto input_idle_time() const { return input_idle_time_; } auto input_idle_time() const { return input_idle_time_; }
@ -170,7 +171,6 @@ class Input {
bool have_non_touch_inputs_{}; bool have_non_touch_inputs_{};
float cursor_pos_x_{}; float cursor_pos_x_{};
float cursor_pos_y_{}; float cursor_pos_y_{};
// millisecs_t last_input_time_{};
millisecs_t last_click_time_{}; millisecs_t last_click_time_{};
millisecs_t double_click_time_{200}; millisecs_t double_click_time_{200};
millisecs_t last_mouse_move_time_{}; millisecs_t last_mouse_move_time_{};

View File

@ -377,12 +377,14 @@ auto RemoteAppServer::GetClient(int request_id, struct sockaddr* addr,
} }
} }
// Don't reuse a slot for 5 seconds. // Don't reuse a slot for 5 seconds (if its been heard from since this time).
millisecs_t cooldown_time = GetRealTime() - 5000; millisecs_t cooldown_time = GetRealTime() - 5000;
// Ok, not there already.. now look for a non-taken one and return that. // Ok, not there already.. now look for a non-taken one and return that.
for (int i = 0; i < kMaxRemoteAppClients; i++) { for (int i = 0; i < kMaxRemoteAppClients; i++) {
if (!clients_[i].in_use && clients_[i].last_contact_time < cooldown_time) { if (!clients_[i].in_use
&& (clients_[i].last_contact_time == 0
|| clients_[i].last_contact_time < cooldown_time)) {
// Ok lets fill out the client. // Ok lets fill out the client.
clients_[i].in_use = true; clients_[i].in_use = true;
clients_[i].next_state_id = 0; clients_[i].next_state_id = 0;

View File

@ -1,30 +0,0 @@
// Released under the MIT License. See LICENSE for details.
#ifndef BALLISTICA_LOGIC_FRIEND_SCORE_SET_H_
#define BALLISTICA_LOGIC_FRIEND_SCORE_SET_H_
#include <list>
#include <string>
#include <utility>
namespace ballistica {
// Used by game-center/etc when reporting friend scores to the game.
struct FriendScoreSet {
FriendScoreSet(bool success, void* user_data)
: success(success), user_data(user_data) {}
struct Entry {
Entry(int score, std::string name, bool is_me)
: score(score), name(std::move(name)), is_me(is_me) {}
int score;
std::string name;
bool is_me;
};
std::list<Entry> entries;
bool success;
void* user_data;
};
} // namespace ballistica
#endif // BALLISTICA_LOGIC_FRIEND_SCORE_SET_H_

View File

@ -19,7 +19,6 @@
#include "ballistica/logic/connection/connection_set.h" #include "ballistica/logic/connection/connection_set.h"
#include "ballistica/logic/connection/connection_to_client_udp.h" #include "ballistica/logic/connection/connection_to_client_udp.h"
#include "ballistica/logic/connection/connection_to_host_udp.h" #include "ballistica/logic/connection/connection_to_host_udp.h"
#include "ballistica/logic/friend_score_set.h"
#include "ballistica/logic/host_activity.h" #include "ballistica/logic/host_activity.h"
#include "ballistica/logic/player.h" #include "ballistica/logic/player.h"
#include "ballistica/logic/session/client_session.h" #include "ballistica/logic/session/client_session.h"
@ -1138,11 +1137,6 @@ void Logic::PushPlaySoundCall(SystemSoundID sound) {
[sound] { g_audio->PlaySound(g_assets->GetSound(sound)); }); [sound] { g_audio->PlaySound(g_assets->GetSound(sound)); });
} }
void Logic::PushFriendScoreSetCall(const FriendScoreSet& score_set) {
thread()->PushCall(
[score_set] { g_python->HandleFriendScoresCB(score_set); });
}
void Logic::PushConfirmQuitCall() { void Logic::PushConfirmQuitCall() {
thread()->PushCall([this] { thread()->PushCall([this] {
assert(InLogicThread()); assert(InLogicThread());
@ -2114,6 +2108,20 @@ void Logic::SetPublicPartySize(int count) {
} }
} }
auto Logic::SetPublicPartyQueueEnabled(bool enabled) -> void {
assert(InLogicThread());
if (enabled == public_party_queue_enabled_) {
return;
}
public_party_queue_enabled_ = enabled;
// Push our new state to the server *ONLY* if public-party is turned on
// (wasteful otherwise).
if (public_party_enabled_) {
g_app_internal->PushPublicPartyState();
}
}
void Logic::SetPublicPartyMaxSize(int count) { void Logic::SetPublicPartyMaxSize(int count) {
assert(InLogicThread()); assert(InLogicThread());
if (count == public_party_max_size_) { if (count == public_party_max_size_) {

View File

@ -88,7 +88,6 @@ class Logic {
auto PushConfirmQuitCall() -> void; auto PushConfirmQuitCall() -> void;
auto PushStringEditSetCall(const std::string& value) -> void; auto PushStringEditSetCall(const std::string& value) -> void;
auto PushStringEditCancelCall() -> void; auto PushStringEditCancelCall() -> void;
auto PushFriendScoreSetCall(const FriendScoreSet& score_set) -> void;
auto PushShowURLCall(const std::string& url) -> void; auto PushShowURLCall(const std::string& url) -> void;
auto PushOnAppResumeCall() -> void; auto PushOnAppResumeCall() -> void;
auto PushFrameDefRequest() -> void; auto PushFrameDefRequest() -> void;
@ -215,6 +214,10 @@ class Logic {
auto public_party_size() const { return public_party_size_; } auto public_party_size() const { return public_party_size_; }
auto SetPublicPartySize(int count) -> void; auto SetPublicPartySize(int count) -> void;
auto public_party_max_size() const { return public_party_max_size_; } auto public_party_max_size() const { return public_party_max_size_; }
auto SetPublicPartyQueueEnabled(bool enabled) -> void;
auto public_party_queue_enabled() const {
return public_party_queue_enabled_;
}
auto public_party_max_player_count() const { auto public_party_max_player_count() const {
return public_party_max_player_count_; return public_party_max_player_count_;
} }
@ -323,6 +326,7 @@ class Logic {
bool public_party_enabled_{}; bool public_party_enabled_{};
int public_party_size_{1}; // Always count ourself (is that what we want?). int public_party_size_{1}; // Always count ourself (is that what we want?).
int public_party_max_size_{8}; int public_party_max_size_{8};
bool public_party_queue_enabled_{true};
int public_party_player_count_{0}; int public_party_player_count_{0};
int public_party_max_player_count_{8}; int public_party_max_player_count_{8};
std::string public_party_name_; std::string public_party_name_;

View File

@ -2,6 +2,7 @@
#include "ballistica/networking/network_reader.h" #include "ballistica/networking/network_reader.h"
#include "ballistica/core/thread.h"
#include "ballistica/generic/json.h" #include "ballistica/generic/json.h"
#include "ballistica/input/remote_app.h" #include "ballistica/input/remote_app.h"
#include "ballistica/logic/connection/connection_set.h" #include "ballistica/logic/connection/connection_set.h"
@ -175,6 +176,25 @@ static auto HandleGameQuery(const char* buffer, size_t size,
} }
} }
auto NetworkReader::CheckFDThreshold(int val) -> void {
if (passed_fd_threshold_) {
return;
}
// Let's trigger when we pass 2/3 of the FD limit.
if (val < FD_SETSIZE * 2 / 3) {
return;
}
// If we pass the threshold, do a one-time dump of info
// to try and debug it.
passed_fd_threshold_ = true;
g_logic->thread()->PushCall([val] {
assert(InLogicThread());
g_python->obj(Python::ObjID::kOnTooManyFileDescriptorsCall).Call();
});
}
auto NetworkReader::RunThread() -> int { auto NetworkReader::RunThread() -> int {
if (!HeadlessMode()) { if (!HeadlessMode()) {
remote_server_ = std::make_unique<RemoteAppServer>(); remote_server_ = std::make_unique<RemoteAppServer>();
@ -202,6 +222,7 @@ auto NetworkReader::RunThread() -> int {
// Try to get a clean error instead of a crash if we exceed our // Try to get a clean error instead of a crash if we exceed our
// open file descriptor limit (except on windows where FD_SETSIZE // open file descriptor limit (except on windows where FD_SETSIZE
// is apparently a dummy value). // is apparently a dummy value).
CheckFDThreshold(sd4_);
if (sd4_ < 0 || sd4_ >= FD_SETSIZE) { if (sd4_ < 0 || sd4_ >= FD_SETSIZE) {
FatalError("Socket/File Descriptor Overflow (sd4=" FatalError("Socket/File Descriptor Overflow (sd4="
+ std::to_string(sd4_) + ", FD_SETSIZE=" + std::to_string(sd4_) + ", FD_SETSIZE="
@ -216,6 +237,7 @@ auto NetworkReader::RunThread() -> int {
// Try to get a clean error instead of a crash if we exceed our // Try to get a clean error instead of a crash if we exceed our
// open file descriptor limit (except on windows where FD_SETSIZE // open file descriptor limit (except on windows where FD_SETSIZE
// is apparently a dummy value). // is apparently a dummy value).
CheckFDThreshold(sd6_);
if (sd6_ < 0 || sd6_ >= FD_SETSIZE) { if (sd6_ < 0 || sd6_ >= FD_SETSIZE) {
FatalError("Socket/File Descriptor Overflow (sd6=" FatalError("Socket/File Descriptor Overflow (sd6="
+ std::to_string(sd6_) + ", FD_SETSIZE=" + std::to_string(sd6_) + ", FD_SETSIZE="

View File

@ -32,6 +32,7 @@ class NetworkReader {
auto sd6() const { return sd6_; } auto sd6() const { return sd6_; }
private: private:
auto CheckFDThreshold(int val) -> void;
auto OpenSockets() -> void; auto OpenSockets() -> void;
auto PokeSelf() -> void; auto PokeSelf() -> void;
auto RunThread() -> int; auto RunThread() -> int;
@ -53,6 +54,7 @@ class NetworkReader {
bool paused_{}; bool paused_{};
std::mutex paused_mutex_; std::mutex paused_mutex_;
std::condition_variable paused_cv_; std::condition_variable paused_cv_;
bool passed_fd_threshold_{};
}; };
} // namespace ballistica } // namespace ballistica

View File

@ -37,8 +37,6 @@ class PlatformApple : public Platform {
const std::vector<float>& widths, float scale) const std::vector<float>& widths, float scale)
-> void* override; -> void* override;
auto GetTextTextureData(void* tex) -> uint8_t* override; auto GetTextTextureData(void* tex) -> uint8_t* override;
auto GetFriendScores(const std::string& game, const std::string& game_version,
void* py_callback) -> void override;
auto SubmitScore(const std::string& game, const std::string& version, auto SubmitScore(const std::string& game, const std::string& version,
int64_t score) -> void override; int64_t score) -> void override;
auto ReportAchievement(const std::string& achievement) -> void override; auto ReportAchievement(const std::string& achievement) -> void override;

View File

@ -28,7 +28,6 @@
#include "ballistica/graphics/mesh/sprite_mesh.h" #include "ballistica/graphics/mesh/sprite_mesh.h"
#include "ballistica/graphics/vr_graphics.h" #include "ballistica/graphics/vr_graphics.h"
#include "ballistica/input/input.h" #include "ballistica/input/input.h"
#include "ballistica/logic/friend_score_set.h"
#include "ballistica/logic/logic.h" #include "ballistica/logic/logic.h"
#include "ballistica/networking/networking_sys.h" #include "ballistica/networking/networking_sys.h"
#include "ballistica/platform/sdl/sdl_app.h" #include "ballistica/platform/sdl/sdl_app.h"
@ -196,6 +195,11 @@ auto Platform::LoginAdapterGetSignInToken(const std::string& login_type,
}); });
} }
auto Platform::LoginAdapterBackEndActiveChange(const std::string& login_type,
bool active) -> void {
// Default is no-op.
}
auto Platform::GetDeviceV1AccountUUIDPrefix() -> std::string { auto Platform::GetDeviceV1AccountUUIDPrefix() -> std::string {
Log(LogLevel::kError, "GetDeviceV1AccountUUIDPrefix() unimplemented"); Log(LogLevel::kError, "GetDeviceV1AccountUUIDPrefix() unimplemented");
return "u"; return "u";
@ -217,8 +221,15 @@ auto Platform::GetPublicDeviceUUID() -> std::string {
// This UUID is supposed to change periodically, so let's plug in // This UUID is supposed to change periodically, so let's plug in
// some stuff to enforce that. // some stuff to enforce that.
inputs.emplace_back(GetOSVersionString()); inputs.emplace_back(GetOSVersionString());
inputs.emplace_back(kAppVersion);
inputs.emplace_back("kerploople"); // This part gets shuffled periodically by my version-increment tools.
// We used to plug version in directly here, but that caused uuids to
// shuffle too rapidly during periods of rapid development. This
// keeps it more constant.
// __last_rand_uuid_component_shuffle_date__ 2022 12 17
auto rand_uuid_component{"BMCJPHH0SC22KB0WVJ1RAYD68TPEXL58"};
inputs.emplace_back(rand_uuid_component);
auto gil{Python::ScopedInterpreterLock()}; auto gil{Python::ScopedInterpreterLock()};
auto pylist{g_python->StringList(inputs)}; auto pylist{g_python->StringList(inputs)};
auto args{g_python->SingleMemberTuple(pylist)}; auto args{g_python->SingleMemberTuple(pylist)};
@ -862,13 +873,6 @@ auto Platform::ConvertIncomingLeaderboardScore(
return score; return score;
} }
void Platform::GetFriendScores(const std::string& game,
const std::string& game_version, void* data) {
// As a default, just fail gracefully.
Log(LogLevel::kError, "FIXME: GetFriendScores unimplemented");
g_logic->PushFriendScoreSetCall(FriendScoreSet(false, data));
}
void Platform::SubmitScore(const std::string& game, const std::string& version, void Platform::SubmitScore(const std::string& game, const std::string& version,
int64_t score) { int64_t score) {
Log(LogLevel::kError, "FIXME: SubmitScore() unimplemented"); Log(LogLevel::kError, "FIXME: SubmitScore() unimplemented");

View File

@ -339,6 +339,10 @@ class Platform {
/// Called when a Python LoginAdapter is requesting an explicit sign-in. /// Called when a Python LoginAdapter is requesting an explicit sign-in.
virtual auto LoginAdapterGetSignInToken(const std::string& login_type, virtual auto LoginAdapterGetSignInToken(const std::string& login_type,
int attempt_id) -> void; int attempt_id) -> void;
/// Called when a Python LoginAdapter is informing us that a back-end is
/// active/inactive.
virtual auto LoginAdapterBackEndActiveChange(const std::string& login_type,
bool active) -> void;
#pragma mark MUSIC PLAYBACK ---------------------------------------------------- #pragma mark MUSIC PLAYBACK ----------------------------------------------------
@ -368,9 +372,6 @@ class Platform {
virtual auto ConvertIncomingLeaderboardScore( virtual auto ConvertIncomingLeaderboardScore(
const std::string& leaderboard_id, int score) -> int; const std::string& leaderboard_id, int score) -> int;
virtual auto GetFriendScores(const std::string& game,
const std::string& game_version,
void* py_callback) -> void;
virtual auto SubmitScore(const std::string& game, const std::string& version, virtual auto SubmitScore(const std::string& game, const std::string& version,
int64_t score) -> void; int64_t score) -> void;
virtual auto ReportAchievement(const std::string& achievement) -> void; virtual auto ReportAchievement(const std::string& achievement) -> void;

View File

@ -6,6 +6,7 @@
#include "ballistica/app/app_flavor.h" #include "ballistica/app/app_flavor.h"
#include "ballistica/assets/component/texture.h" #include "ballistica/assets/component/texture.h"
#include "ballistica/core/logging.h" #include "ballistica/core/logging.h"
#include "ballistica/core/thread.h"
#include "ballistica/graphics/graphics.h" #include "ballistica/graphics/graphics.h"
#include "ballistica/logic/connection/connection_set.h" #include "ballistica/logic/connection/connection_set.h"
#include "ballistica/logic/host_activity.h" #include "ballistica/logic/host_activity.h"
@ -308,18 +309,29 @@ auto PyPushCall(PyObject* self, PyObject* args, PyObject* keywds) -> PyObject* {
int from_other_thread{}; int from_other_thread{};
int suppress_warning{}; int suppress_warning{};
int other_thread_use_fg_context{}; int other_thread_use_fg_context{};
static const char* kwlist[] = {"call", "from_other_thread", int raw{0};
static const char* kwlist[] = {"call",
"from_other_thread",
"suppress_other_thread_warning", "suppress_other_thread_warning",
"other_thread_use_fg_context", nullptr}; "other_thread_use_fg_context",
if (!PyArg_ParseTupleAndKeywords(args, keywds, "O|ppp", "raw",
nullptr};
if (!PyArg_ParseTupleAndKeywords(args, keywds, "O|pppp",
const_cast<char**>(kwlist), &call_obj, const_cast<char**>(kwlist), &call_obj,
&from_other_thread, &suppress_warning, &from_other_thread, &suppress_warning,
&other_thread_use_fg_context)) { &other_thread_use_fg_context, &raw)) {
return nullptr; return nullptr;
} }
// The from-other-thread case is basically a different call. // 'raw' mode does no thread checking and no context saves/restores.
if (from_other_thread) { if (raw) {
Py_INCREF(call_obj);
g_logic->thread()->PushCall([call_obj] {
assert(InLogicThread());
PythonRef(call_obj, PythonRef::kSteal).Call();
});
} else if (from_other_thread) {
// Warn the user not to use this from the logic thread since it doesnt // Warn the user not to use this from the logic thread since it doesnt
// save/restore context. // save/restore context.
if (!suppress_warning && InLogicThread()) { if (!suppress_warning && InLogicThread()) {
@ -1132,15 +1144,12 @@ auto PythonMethodsApp::GetMethods() -> std::vector<PyMethodDef> {
{"pushcall", (PyCFunction)PyPushCall, METH_VARARGS | METH_KEYWORDS, {"pushcall", (PyCFunction)PyPushCall, METH_VARARGS | METH_KEYWORDS,
"pushcall(call: Callable, from_other_thread: bool = False,\n" "pushcall(call: Callable, from_other_thread: bool = False,\n"
" suppress_other_thread_warning: bool = False,\n" " suppress_other_thread_warning: bool = False,\n"
" other_thread_use_fg_context: bool = False) -> None\n" " other_thread_use_fg_context: bool = False,\n"
"\n" " raw: bool = False) -> None\n"
"Pushes a call onto the event loop to be run during the next cycle.\n"
"\n" "\n"
"Push a call to the logic event-loop.\n"
"Category: **General Utility Functions**\n" "Category: **General Utility Functions**\n"
"\n" "\n"
"This can be handy for calls that are disallowed from within other\n"
"callbacks, etc.\n"
"\n"
"This call expects to be used in the logic thread, and will " "This call expects to be used in the logic thread, and will "
"automatically\n" "automatically\n"
"save and restore the ba.Context to behave seamlessly.\n" "save and restore the ba.Context to behave seamlessly.\n"
@ -1149,8 +1158,9 @@ auto PythonMethodsApp::GetMethods() -> std::vector<PyMethodDef> {
"however, you can pass 'from_other_thread' as True. In this case\n" "however, you can pass 'from_other_thread' as True. In this case\n"
"the call will always run in the UI context on the logic thread\n" "the call will always run in the UI context on the logic thread\n"
"or whichever context is in the foreground if\n" "or whichever context is in the foreground if\n"
"other_thread_use_fg_context is True."}, "other_thread_use_fg_context is True.\n"
"Passing raw=True will disable thread checks and context"
" sets/restores."},
{"getactivity", (PyCFunction)PyGetActivity, {"getactivity", (PyCFunction)PyGetActivity,
METH_VARARGS | METH_KEYWORDS, METH_VARARGS | METH_KEYWORDS,
"getactivity(doraise: bool = True) -> <varies>\n" "getactivity(doraise: bool = True) -> <varies>\n"

View File

@ -114,6 +114,21 @@ auto PySetPublicPartyMaxSize(PyObject* self, PyObject* args, PyObject* keywds)
BA_PYTHON_CATCH; BA_PYTHON_CATCH;
} }
auto PySetPublicPartyQueueEnabled(PyObject* self, PyObject* args,
PyObject* keywds) -> PyObject* {
BA_PYTHON_TRY;
int enabled;
static const char* kwlist[] = {"enabled", nullptr};
if (!PyArg_ParseTupleAndKeywords(args, keywds, "p",
const_cast<char**>(kwlist), &enabled)) {
return nullptr;
}
assert(g_python);
g_logic->SetPublicPartyQueueEnabled(enabled);
Py_RETURN_NONE;
BA_PYTHON_CATCH;
}
auto PySetAuthenticateClients(PyObject* self, PyObject* args, PyObject* keywds) auto PySetAuthenticateClients(PyObject* self, PyObject* args, PyObject* keywds)
-> PyObject* { -> PyObject* {
BA_PYTHON_TRY; BA_PYTHON_TRY;
@ -498,6 +513,13 @@ auto PythonMethodsNetworking::GetMethods() -> std::vector<PyMethodDef> {
METH_VARARGS | METH_KEYWORDS, METH_VARARGS | METH_KEYWORDS,
"set_public_party_max_size(max_size: int) -> None\n" "set_public_party_max_size(max_size: int) -> None\n"
"\n" "\n"
"(internal)"},
{"set_public_party_queue_enabled",
(PyCFunction)PySetPublicPartyQueueEnabled, METH_VARARGS | METH_KEYWORDS,
"set_public_party_queue_enabled(max_size: bool) -> None\n"
"\n"
"(internal)"}, "(internal)"},
{"get_public_party_max_size", (PyCFunction)PyGetPublicPartyMaxSize, {"get_public_party_max_size", (PyCFunction)PyGetPublicPartyMaxSize,

View File

@ -638,6 +638,22 @@ auto PyLoginAdapterGetSignInToken(PyObject* self, PyObject* args,
BA_PYTHON_CATCH; BA_PYTHON_CATCH;
} }
auto PyLoginAdapterBackEndActiveChange(PyObject* self, PyObject* args,
PyObject* keywds) -> PyObject* {
BA_PYTHON_TRY;
const char* login_type;
int active;
static const char* kwlist[] = {"login_type", "active", nullptr};
if (!PyArg_ParseTupleAndKeywords(args, keywds, "sp",
const_cast<char**>(kwlist), &login_type,
&active)) {
return nullptr;
}
g_platform->LoginAdapterBackEndActiveChange(login_type, active);
Py_RETURN_NONE;
BA_PYTHON_CATCH;
}
auto PySetInternalLanguageKeys(PyObject* self, PyObject* args) -> PyObject* { auto PySetInternalLanguageKeys(PyObject* self, PyObject* args) -> PyObject* {
BA_PYTHON_TRY; BA_PYTHON_TRY;
PyObject* list_obj; PyObject* list_obj;
@ -715,10 +731,18 @@ auto PyAndroidShowWifiSettings(PyObject* self, PyObject* args, PyObject* keywds)
BA_PYTHON_CATCH; BA_PYTHON_CATCH;
} }
auto PyPrintObjects(PyObject* self, PyObject* args, PyObject* keywds) auto PyLsObjects(PyObject* self, PyObject* args, PyObject* keywds)
-> PyObject* { -> PyObject* {
BA_PYTHON_TRY; BA_PYTHON_TRY;
Object::PrintObjects(); Object::LsObjects();
Py_RETURN_NONE;
BA_PYTHON_CATCH;
}
auto PyLsInputDevices(PyObject* self, PyObject* args, PyObject* keywds)
-> PyObject* {
BA_PYTHON_TRY;
g_input->LsInputDevices();
Py_RETURN_NONE; Py_RETURN_NONE;
BA_PYTHON_CATCH; BA_PYTHON_CATCH;
} }
@ -756,6 +780,7 @@ auto PythonMethodsSystem::GetMethods() -> std::vector<PyMethodDef> {
"\n" "\n"
"If this returns False, UIs should not show 'copy to clipboard'\n" "If this returns False, UIs should not show 'copy to clipboard'\n"
"buttons, etc."}, "buttons, etc."},
{"clipboard_has_text", (PyCFunction)PyClipboardHasText, METH_NOARGS, {"clipboard_has_text", (PyCFunction)PyClipboardHasText, METH_NOARGS,
"clipboard_has_text() -> bool\n" "clipboard_has_text() -> bool\n"
"\n" "\n"
@ -765,6 +790,7 @@ auto PythonMethodsSystem::GetMethods() -> std::vector<PyMethodDef> {
"\n" "\n"
"This will return False if no system clipboard is available; no need\n" "This will return False if no system clipboard is available; no need\n"
" to call ba.clipboard_is_supported() separately."}, " to call ba.clipboard_is_supported() separately."},
{"clipboard_set_text", (PyCFunction)PyClipboardSetText, {"clipboard_set_text", (PyCFunction)PyClipboardSetText,
METH_VARARGS | METH_KEYWORDS, METH_VARARGS | METH_KEYWORDS,
"clipboard_set_text(value: str) -> None\n" "clipboard_set_text(value: str) -> None\n"
@ -775,6 +801,7 @@ auto PythonMethodsSystem::GetMethods() -> std::vector<PyMethodDef> {
"\n" "\n"
"Ensure that ba.clipboard_is_supported() returns True before adding\n" "Ensure that ba.clipboard_is_supported() returns True before adding\n"
" buttons/etc. that make use of this functionality."}, " buttons/etc. that make use of this functionality."},
{"clipboard_get_text", (PyCFunction)PyClipboardGetText, METH_NOARGS, {"clipboard_get_text", (PyCFunction)PyClipboardGetText, METH_NOARGS,
"clipboard_get_text() -> str\n" "clipboard_get_text() -> str\n"
"\n" "\n"
@ -784,9 +811,20 @@ auto PythonMethodsSystem::GetMethods() -> std::vector<PyMethodDef> {
"\n" "\n"
"Ensure that ba.clipboard_has_text() returns True before calling\n" "Ensure that ba.clipboard_has_text() returns True before calling\n"
" this function."}, " this function."},
{"printobjects", (PyCFunction)PyPrintObjects,
{"ls_objects", (PyCFunction)PyLsObjects, METH_VARARGS | METH_KEYWORDS,
"ls_objects() -> None\n"
"\n"
"Log debugging info about C++ level objects.\n"
"\n"
"Category: **General Utility Functions**\n"
"\n"
"This call only functions in debug builds of the game.\n"
"It prints various info about the current object count, etc."},
{"ls_input_devices", (PyCFunction)PyLsInputDevices,
METH_VARARGS | METH_KEYWORDS, METH_VARARGS | METH_KEYWORDS,
"printobjects() -> None\n" "ls_input_devices() -> None\n"
"\n" "\n"
"Print debugging info about game objects.\n" "Print debugging info about game objects.\n"
"\n" "\n"
@ -859,6 +897,14 @@ auto PythonMethodsSystem::GetMethods() -> std::vector<PyMethodDef> {
"\n" "\n"
"(internal)"}, "(internal)"},
{"login_adapter_back_end_active_change",
(PyCFunction)PyLoginAdapterBackEndActiveChange,
METH_VARARGS | METH_KEYWORDS,
"login_adapter_back_end_active_change(login_type: str, active: bool)"
" -> None\n"
"\n"
"(internal)"},
{"submit_analytics_counts", (PyCFunction)PySubmitAnalyticsCounts, {"submit_analytics_counts", (PyCFunction)PySubmitAnalyticsCounts,
METH_VARARGS | METH_KEYWORDS, METH_VARARGS | METH_KEYWORDS,
"submit_analytics_counts() -> None\n" "submit_analytics_counts() -> None\n"

View File

@ -14,7 +14,6 @@
#include "ballistica/input/device/joystick.h" #include "ballistica/input/device/joystick.h"
#include "ballistica/input/device/keyboard_input.h" #include "ballistica/input/device/keyboard_input.h"
#include "ballistica/internal/app_internal.h" #include "ballistica/internal/app_internal.h"
#include "ballistica/logic/friend_score_set.h"
#include "ballistica/logic/host_activity.h" #include "ballistica/logic/host_activity.h"
#include "ballistica/logic/player.h" #include "ballistica/logic/player.h"
#include "ballistica/logic/v1_account.h" #include "ballistica/logic/v1_account.h"
@ -2144,40 +2143,6 @@ void Python::CaptureKeyboardInput(PyObject* obj) {
} }
void Python::ReleaseKeyboardInput() { keyboard_call_.Release(); } void Python::ReleaseKeyboardInput() { keyboard_call_.Release(); }
void Python::HandleFriendScoresCB(const FriendScoreSet& score_set) {
// This is the initial strong-ref to this pointer
// so it will be cleaned up properly.
Object::Ref<PythonContextCall> cb(
static_cast<PythonContextCall*>(score_set.user_data));
// We pass None on error.
if (!score_set.success) {
PythonRef args(Py_BuildValue("(O)", Py_None), PythonRef::kSteal);
cb->Run(args);
} else {
// Otherwise convert it to a python list and pass that.
PyObject* py_list = PyList_New(0);
std::string icon_str;
#if BA_USE_GOOGLE_PLAY_GAME_SERVICES
icon_str = g_logic->CharStr(SpecialChar::kGooglePlayGamesLogo);
#elif BA_USE_GAME_CIRCLE
icon_str = g_logic->CharStr(SpecialChar::kGameCircleLogo);
#elif BA_USE_GAME_CENTER
icon_str = g_logic->CharStr(SpecialChar::kGameCenterLogo);
#endif
for (auto&& i : score_set.entries) {
PyObject* obj =
Py_BuildValue("[isi]", i.score, (icon_str + i.name).c_str(),
static_cast<int>(i.is_me));
PyList_Append(py_list, obj);
Py_DECREF(obj);
}
PythonRef args(Py_BuildValue("(O)", py_list), PythonRef::kSteal);
Py_DECREF(py_list);
cb->Run(args);
}
}
auto Python::HandleKeyPressEvent(const SDL_Keysym& keysym) -> bool { auto Python::HandleKeyPressEvent(const SDL_Keysym& keysym) -> bool {
assert(InLogicThread()); assert(InLogicThread());
if (!keyboard_call_.exists()) { if (!keyboard_call_.exists()) {

View File

@ -114,7 +114,6 @@ class Python {
auto ReleaseGamePadInput() -> void; auto ReleaseGamePadInput() -> void;
auto CaptureKeyboardInput(PyObject* obj) -> void; auto CaptureKeyboardInput(PyObject* obj) -> void;
auto ReleaseKeyboardInput() -> void; auto ReleaseKeyboardInput() -> void;
auto HandleFriendScoresCB(const FriendScoreSet& ss) -> void;
auto IssueCallInLogicThreadWarning(PyObject* call) -> void; auto IssueCallInLogicThreadWarning(PyObject* call) -> void;
/// Borrowed from python's source code: used in overriding of objects' dir() /// Borrowed from python's source code: used in overriding of objects' dir()
@ -369,6 +368,7 @@ class Python {
kImplicitSignInCall, kImplicitSignInCall,
kImplicitSignOutCall, kImplicitSignOutCall,
kLoginAdapterGetSignInTokenResponseCall, kLoginAdapterGetSignInTokenResponseCall,
kOnTooManyFileDescriptorsCall,
kLast // Sentinel; must be at end. kLast // Sentinel; must be at end.
}; };

View File

@ -446,7 +446,9 @@ auto ContainerWidget::HandleMessage(const WidgetMessage& m) -> bool {
CheckLayout(); CheckLayout();
// Ignore mouse stuff while transitioning out. // Ignore mouse stuff while transitioning out.
if (transitioning_ && transitioning_out_) break; if (transitioning_ && transitioning_out_) {
break;
}
float x = m.fval1; float x = m.fval1;
float y = m.fval2; float y = m.fval2;
@ -505,7 +507,9 @@ auto ContainerWidget::HandleMessage(const WidgetMessage& m) -> bool {
CheckLayout(); CheckLayout();
// Ignore mouse stuff while transitioning. // Ignore mouse stuff while transitioning.
if (transitioning_ && transitioning_out_) break; if (transitioning_ && transitioning_out_) {
break;
}
float x = m.fval1; float x = m.fval1;
float y = m.fval2; float y = m.fval2;
@ -546,7 +550,9 @@ auto ContainerWidget::HandleMessage(const WidgetMessage& m) -> bool {
CheckLayout(); CheckLayout();
// Ignore mouse stuff while transitioning. // Ignore mouse stuff while transitioning.
if (transitioning_ && transitioning_out_) break; if (transitioning_ && transitioning_out_) {
break;
}
float x = m.fval1; float x = m.fval1;
float y = m.fval2; float y = m.fval2;
@ -909,7 +915,9 @@ void ContainerWidget::Draw(RenderPass* pass, bool draw_transparent) {
} }
// Don't draw if we've fully transitioned out. // Don't draw if we've fully transitioned out.
if (transitioning_out_ && !transitioning_) return; if (transitioning_out_ && !transitioning_) {
return;
}
float l = transition_offset_x_smoothed_ + transition_scale_offset_x_; float l = transition_offset_x_smoothed_ + transition_scale_offset_x_;
float r = l + width_; float r = l + width_;

View File

@ -1108,7 +1108,15 @@ void RootWidget::SetOverlayWidget(StackWidget* w) {
overlay_stack_widget_ = w; overlay_stack_widget_ = w;
} }
void RootWidget::OnCancelCustom() { g_ui->PushBackButtonCall(nullptr); } void RootWidget::OnCancelCustom() {
// Need to revisit this. If the cancel event it pushes is not handled, it will
// wind up back here where it pushes another back call. This cycle repeats
// forever until something comes along which does handle cancel events and
// then it gets them all. Current repro case is Sign-in-with-BombSquad-Account
// window - press escape a few times while that is up and then click cancel;
// This code is only used for toolbar mode so should be safe to leave it
// disabled for now. g_ui->PushBackButtonCall(nullptr);
}
auto RootWidget::GetSpecialWidget(const std::string& s) const -> Widget* { auto RootWidget::GetSpecialWidget(const std::string& s) const -> Widget* {
if (s == "party_button") { if (s == "party_button") {

View File

@ -441,20 +441,23 @@ void TextWidget::SetText(const std::string& text_in_raw) {
bool do_format_check{}; bool do_format_check{};
bool print_false_positives{}; bool print_false_positives{};
if (g_buildconfig.debug_build()) { // Only non-editable text support resource-strings.
do_format_check = explicit_bool(true); if (!editable_) {
} else { if (g_buildconfig.debug_build()) {
if (text_in_raw.size() > 1 && text_in_raw[0] == '{' do_format_check = explicit_bool(true);
&& text_in_raw[text_in_raw.size() - 1] == '}') { } else {
// Ok, its got bounds like json; now if its either missing quotes or a if (text_in_raw.size() > 1 && text_in_raw[0] == '{'
// colon then let's check it. && text_in_raw[text_in_raw.size() - 1] == '}') {
if (!strstr(text_in_raw.c_str(), "\"") // Ok, its got bounds like json; now if its either missing quotes or a
|| !strstr(text_in_raw.c_str(), ":")) { // colon then let's check it.
do_format_check = true; if (!strstr(text_in_raw.c_str(), "\"")
|| !strstr(text_in_raw.c_str(), ":")) {
do_format_check = true;
// We wanna avoid doing this check when we don't have to. // We wanna avoid doing this check when we don't have to.
// so lets print if we get a false positive // so lets print if we get a false positive
print_false_positives = true; print_false_positives = true;
}
} }
} }
} }

View File

@ -146,4 +146,5 @@ def get_binding_values() -> tuple[Any, ...]:
_hooks.implicit_sign_in, # kImplicitSignInCall _hooks.implicit_sign_in, # kImplicitSignInCall
_hooks.implicit_sign_out, # kImplicitSignOutCall _hooks.implicit_sign_out, # kImplicitSignOutCall
_hooks.login_adapter_get_sign_in_token_response, # kLoginAdapterGetSignInTokenResponseCall _hooks.login_adapter_get_sign_in_token_response, # kLoginAdapterGetSignInTokenResponseCall
_apputils.on_too_many_file_descriptors, # kOnTooManyFileDescriptorsCall
) # yapf: disable ) # yapf: disable

View File

@ -198,9 +198,9 @@ class _BoundTestMessageSenderAsync(BoundMessageSender):
async def send_async(self, message: _TMsg3) -> None: async def send_async(self, message: _TMsg3) -> None:
... ...
async def send_async(self, message: Message) -> Response | None: def send_async(self, message: Message) -> Awaitable[Response | None]:
"""Send a message asynchronously.""" """Send a message asynchronously."""
return await self._sender.send_async(self._obj, message) return self._sender.send_async(self._obj, message)
# SEND_ASYNC_CODE_TEST_END # SEND_ASYNC_CODE_TEST_END
@ -261,9 +261,9 @@ class _BoundTestMessageSenderBBoth(BoundMessageSender):
async def send_async(self, message: _TMsg4) -> None: async def send_async(self, message: _TMsg4) -> None:
... ...
async def send_async(self, message: Message) -> Response | None: def send_async(self, message: Message) -> Awaitable[Response | None]:
"""Send a message asynchronously.""" """Send a message asynchronously."""
return await self._sender.send_async(self._obj, message) return self._sender.send_async(self._obj, message)
# SEND_BOTH_CODE_TEST_END # SEND_BOTH_CODE_TEST_END
@ -424,11 +424,11 @@ class _TestAsyncMessageReceiver(MessageReceiver):
class _BoundTestAsyncMessageReceiver(BoundMessageReceiver): class _BoundTestAsyncMessageReceiver(BoundMessageReceiver):
"""Protocol-specific bound receiver.""" """Protocol-specific bound receiver."""
async def handle_raw_message( def handle_raw_message(
self, message: str, raise_unregistered: bool = False self, message: str, raise_unregistered: bool = False
) -> str: ) -> Awaitable[str]:
"""Asynchronously handle a raw incoming message.""" """Asynchronously handle a raw incoming message."""
return await self._receiver.handle_raw_message_async( return self._receiver.handle_raw_message_async(
self._obj, message, raise_unregistered self._obj, message, raise_unregistered
) )

View File

@ -157,6 +157,24 @@ class WorkspaceFetchResponse(Response):
done: Annotated[bool, IOAttrs('d')] = False done: Annotated[bool, IOAttrs('d')] = False
@ioprepped
@dataclass
class MerchAvailabilityMessage(Message):
"""Can we show merch link?"""
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [MerchAvailabilityResponse]
@ioprepped
@dataclass
class MerchAvailabilityResponse(Response):
"""About that merch..."""
url: Annotated[str | None, IOAttrs('u')]
@ioprepped @ioprepped
@dataclass @dataclass
class SignInMessage(Message): class SignInMessage(Message):
@ -165,6 +183,10 @@ class SignInMessage(Message):
login_type: Annotated[LoginType, IOAttrs('l')] login_type: Annotated[LoginType, IOAttrs('l')]
sign_in_token: Annotated[str, IOAttrs('t')] sign_in_token: Annotated[str, IOAttrs('t')]
# For debugging. Can remove soft_default once build 20988+ is ubiquitous.
description: Annotated[str, IOAttrs('d', soft_default='-')]
apptime: Annotated[float, IOAttrs('at', soft_default=-1.0)]
@classmethod @classmethod
def get_response_types(cls) -> list[type[Response] | None]: def get_response_types(cls) -> list[type[Response] | None]:
return [SignInResponse] return [SignInResponse]

View File

@ -19,3 +19,13 @@ class LoginType(Enum):
# Google Play Game Services # Google Play Game Services
GPGS = 'gpgs' GPGS = 'gpgs'
@property
def displayname(self) -> str:
"""Human readable name for this value."""
cls = type(self)
match self:
case cls.EMAIL:
return 'Email/Password'
case cls.GPGS:
return 'Google Play Games'

View File

@ -138,6 +138,11 @@ class ServerConfig:
tuple[float, float, float], tuple[float, float, float] tuple[float, float, float], tuple[float, float, float]
] | None = None ] | None = None
# Whether to enable the queue where players can line up before entering
# your server. Disabling this can be used as a workaround to deal with
# queue spamming attacks.
enable_queue: bool = True
# (internal) stress-testing mode. # (internal) stress-testing mode.
stress_test_players: int | None = None stress_test_players: int | None = None

View File

@ -40,22 +40,22 @@ class PyRequirement:
# as manually-installed bits, pip itself must have some way to allow for # as manually-installed bits, pip itself must have some way to allow for
# that, right?... # that, right?...
PY_REQUIREMENTS = [ PY_REQUIREMENTS = [
PyRequirement(modulename='pylint', minversion=[2, 14, 5]), PyRequirement(modulename='pylint', minversion=[2, 15, 9]),
PyRequirement(modulename='mypy', minversion=[0, 971]), PyRequirement(modulename='mypy', minversion=[0, 991]),
PyRequirement(modulename='cpplint', minversion=[1, 6, 1]), PyRequirement(modulename='cpplint', minversion=[1, 6, 1]),
PyRequirement(modulename='pytest', minversion=[7, 1, 2]), PyRequirement(modulename='pytest', minversion=[7, 2, 0]),
PyRequirement(modulename='pytz'), PyRequirement(modulename='pytz'),
PyRequirement(modulename='ansiwrap'), PyRequirement(modulename='ansiwrap'),
PyRequirement(modulename='yaml', pipname='PyYAML'), PyRequirement(modulename='yaml', pipname='PyYAML'),
PyRequirement(modulename='requests'), PyRequirement(modulename='requests'),
PyRequirement(modulename='pdoc'), PyRequirement(modulename='pdoc'),
PyRequirement(pipname='black', minversion=[22, 10, 0]), PyRequirement(pipname='black', minversion=[22, 12, 0]),
PyRequirement(pipname='typing_extensions', minversion=[4, 3, 0]), PyRequirement(pipname='typing_extensions', minversion=[4, 4, 0]),
PyRequirement(pipname='types-filelock', minversion=[3, 2, 7]), PyRequirement(pipname='types-filelock', minversion=[3, 2, 7]),
PyRequirement(pipname='types-requests', minversion=[2, 28, 9]), PyRequirement(pipname='types-requests', minversion=[2, 28, 11, 7]),
PyRequirement(pipname='types-pytz', minversion=[2022, 2, 1, 0]), PyRequirement(pipname='types-pytz', minversion=[2022, 2, 1, 0]),
PyRequirement(pipname='types-PyYAML', minversion=[6, 0, 11]), PyRequirement(pipname='types-PyYAML', minversion=[6, 0, 12, 2]),
PyRequirement(pipname='certifi', minversion=[2022, 6, 15]), PyRequirement(pipname='certifi', minversion=[2022, 12, 7]),
PyRequirement(pipname='types-certifi', minversion=[2021, 10, 8, 3]), PyRequirement(pipname='types-certifi', minversion=[2021, 10, 8, 3]),
] ]

View File

@ -729,8 +729,10 @@ class Updater:
def _check_misc(self) -> None: def _check_misc(self) -> None:
# Misc sanity checks. # Misc sanity checks.
if not self._public:
# Make sure we're set to prod master server. # Make sure we're set to prod master server.
# (but ONLY when checking; still want to be able to run updates).
if not self._public and self._check:
with open( with open(
'src/ballistica/internal/master_server_config.h', 'src/ballistica/internal/master_server_config.h',
encoding='utf-8', encoding='utf-8',

View File

@ -274,6 +274,44 @@ if TYPE_CHECKING:
) -> _CallNoArgs[OutT]: ) -> _CallNoArgs[OutT]:
... ...
# 4 arg call; 3 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
arg1: In1T,
arg2: In2T,
arg3: In3T,
) -> _Call1Arg[In4T, OutT]:
...
# 4 arg call; 2 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
arg1: In1T,
arg2: In2T,
) -> _Call2Args[In3T, In4T, OutT]:
...
# 4 arg call; 1 arg bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
arg1: In1T,
) -> _Call3Args[In2T, In3T, In4T, OutT]:
...
# 4 arg call; no args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
) -> _Call4Args[In1T, In2T, In3T, In4T, OutT]:
...
# 5 arg call; 5 args bundled. # 5 arg call; 5 args bundled.
# noinspection PyPep8Naming # noinspection PyPep8Naming
@overload @overload

View File

@ -111,6 +111,8 @@ class IOAttrs:
boundaries (see efro.util.utc_today()). boundaries (see efro.util.utc_today()).
'whole_hours', if True, requires datetime values to lie exactly on hour 'whole_hours', if True, requires datetime values to lie exactly on hour
boundaries (see efro.util.utc_this_hour()). boundaries (see efro.util.utc_this_hour()).
'whole_minutes', if True, requires datetime values to lie exactly on minute
boundaries (see efro.util.utc_this_minute()).
'soft_default', if passed, injects a default value into dataclass 'soft_default', if passed, injects a default value into dataclass
instantiation when the field is not present in the input data. instantiation when the field is not present in the input data.
This allows dataclasses to add new non-optional fields while This allows dataclasses to add new non-optional fields while
@ -136,6 +138,7 @@ class IOAttrs:
store_default: bool = True store_default: bool = True
whole_days: bool = False whole_days: bool = False
whole_hours: bool = False whole_hours: bool = False
whole_minutes: bool = False
soft_default: Any = MISSING soft_default: Any = MISSING
soft_default_factory: Callable[[], Any] | _MissingType = MISSING soft_default_factory: Callable[[], Any] | _MissingType = MISSING
@ -145,6 +148,7 @@ class IOAttrs:
store_default: bool = store_default, store_default: bool = store_default,
whole_days: bool = whole_days, whole_days: bool = whole_days,
whole_hours: bool = whole_hours, whole_hours: bool = whole_hours,
whole_minutes: bool = whole_minutes,
soft_default: Any = MISSING, soft_default: Any = MISSING,
soft_default_factory: Callable[[], Any] | _MissingType = MISSING, soft_default_factory: Callable[[], Any] | _MissingType = MISSING,
): ):
@ -160,6 +164,8 @@ class IOAttrs:
self.whole_days = whole_days self.whole_days = whole_days
if whole_hours != cls.whole_hours: if whole_hours != cls.whole_hours:
self.whole_hours = whole_hours self.whole_hours = whole_hours
if whole_minutes != cls.whole_minutes:
self.whole_minutes = whole_minutes
if soft_default is not cls.soft_default: if soft_default is not cls.soft_default:
# Do what dataclasses does with its default types and # Do what dataclasses does with its default types and
@ -216,13 +222,18 @@ class IOAttrs:
raise ValueError( raise ValueError(
f'Value {value} at {fieldpath} is not a whole day.' f'Value {value} at {fieldpath} is not a whole day.'
) )
if self.whole_hours: elif self.whole_hours:
if any( if any(
x != 0 for x in (value.minute, value.second, value.microsecond) x != 0 for x in (value.minute, value.second, value.microsecond)
): ):
raise ValueError( raise ValueError(
f'Value {value} at {fieldpath}' f' is not a whole hour.' f'Value {value} at {fieldpath}' f' is not a whole hour.'
) )
elif self.whole_minutes:
if any(x != 0 for x in (value.second, value.microsecond)):
raise ValueError(
f'Value {value} at {fieldpath}' f' is not a whole minute.'
)
def _get_origin(anntype: Any) -> Any: def _get_origin(anntype: Any) -> Any:

View File

@ -80,6 +80,15 @@ class IntegrityError(ValueError):
"""Data has been tampered with or corrupted in some form.""" """Data has been tampered with or corrupted in some form."""
class AuthenticationError(Exception):
"""Authentication has failed for some operation.
This can be raised if server-side-verification does not match
client-supplied credentials, if an invalid password is supplied
for a sign-in attempt, etc.
"""
def is_urllib_communication_error(exc: BaseException, url: str | None) -> bool: def is_urllib_communication_error(exc: BaseException, url: str | None) -> bool:
"""Is the provided exception from urllib a communication-related error? """Is the provided exception from urllib a communication-related error?

View File

@ -37,7 +37,27 @@ class LogLevel(Enum):
ERROR = 3 ERROR = 3
CRITICAL = 4 CRITICAL = 4
@property
def python_logging_level(self) -> int:
"""Give the corresponding logging level."""
return LOG_LEVEL_LEVELNOS[self]
@classmethod
def from_python_logging_level(cls, levelno: int) -> LogLevel:
"""Given a Python logging level, return a LogLevel."""
return LEVELNO_LOG_LEVELS[levelno]
# Python logging levels from LogLevels
LOG_LEVEL_LEVELNOS = {
LogLevel.DEBUG: logging.DEBUG,
LogLevel.INFO: logging.INFO,
LogLevel.WARNING: logging.WARNING,
LogLevel.ERROR: logging.ERROR,
LogLevel.CRITICAL: logging.CRITICAL,
}
# LogLevels from Python logging levels
LEVELNO_LOG_LEVELS = { LEVELNO_LOG_LEVELS = {
logging.DEBUG: LogLevel.DEBUG, logging.DEBUG: LogLevel.DEBUG,
logging.INFO: LogLevel.INFO, logging.INFO: LogLevel.INFO,
@ -128,7 +148,9 @@ class LogHandler(logging.Handler):
self._cache_lock = Lock() self._cache_lock = Lock()
self._printed_callback_error = False self._printed_callback_error = False
self._thread_bootstrapped = False self._thread_bootstrapped = False
self._thread = Thread(target=self._thread_main, daemon=True) self._thread = Thread(target=self._log_thread_main, daemon=True)
if __debug__:
self._last_slow_emit_warning_time: float | None = None
self._thread.start() self._thread.start()
# Spin until our thread is up and running; otherwise we could # Spin until our thread is up and running; otherwise we could
@ -145,7 +167,7 @@ class LogHandler(logging.Handler):
with self._callbacks_lock: with self._callbacks_lock:
self._callbacks.append(call) self._callbacks.append(call)
def _thread_main(self) -> None: def _log_thread_main(self) -> None:
self._event_loop = asyncio.new_event_loop() self._event_loop = asyncio.new_event_loop()
# NOTE: if we ever use default threadpool at all we should allow # NOTE: if we ever use default threadpool at all we should allow
# setting it for our loop. # setting it for our loop.
@ -172,20 +194,15 @@ class LogHandler(logging.Handler):
now = utc_now() now = utc_now()
with self._cache_lock: with self._cache_lock:
# Quick out: if oldest cache entry is still valid, # Prune the oldest entry as long as there is a first one that
# don't touch anything. # is too old.
if ( while (
self._cache self._cache
and (now - self._cache[0][1].time) < self._cache_time_limit and (now - self._cache[0][1].time) >= self._cache_time_limit
): ):
continue popped = self._cache.pop(0)
self._cache_size -= popped[0]
# Ok; full prune. self._cache_index_offset += 1
self._cache = [
e
for e in self._cache
if (now - e[1].time) < self._cache_time_limit
]
def get_cached( def get_cached(
self, start_index: int = 0, max_entries: int | None = None self, start_index: int = 0, max_entries: int | None = None
@ -223,6 +240,9 @@ class LogHandler(logging.Handler):
) )
def emit(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord) -> None:
if __debug__:
starttime = time.monotonic()
# Called by logging to send us records. # Called by logging to send us records.
# We simply package them up and ship them to our thread. # We simply package them up and ship them to our thread.
# UPDATE: turns out we CAN get log messages from this thread # UPDATE: turns out we CAN get log messages from this thread
@ -246,6 +266,9 @@ class LogHandler(logging.Handler):
# didn't expect to be stringified. # didn't expect to be stringified.
msg = self.format(record) msg = self.format(record)
if __debug__:
formattime = time.monotonic()
# Also immediately print pretty colored output to our echo file # Also immediately print pretty colored output to our echo file
# (generally stderr). We do this part here instead of in our bg # (generally stderr). We do this part here instead of in our bg
# thread because the delay can throw off command line prompts or # thread because the delay can throw off command line prompts or
@ -257,6 +280,9 @@ class LogHandler(logging.Handler):
else: else:
self._echofile.write(f'{msg}\n') self._echofile.write(f'{msg}\n')
if __debug__:
echotime = time.monotonic()
self._event_loop.call_soon_threadsafe( self._event_loop.call_soon_threadsafe(
tpartial( tpartial(
self._emit_in_thread, self._emit_in_thread,
@ -267,6 +293,37 @@ class LogHandler(logging.Handler):
) )
) )
if __debug__:
# Make noise if we're taking a significant amount of time here.
# Limit the noise to once every so often though; otherwise we
# could get a feedback loop where every log emit results in a
# warning log which results in another, etc.
now = time.monotonic()
# noinspection PyUnboundLocalVariable
duration = now - starttime
# noinspection PyUnboundLocalVariable
format_duration = formattime - starttime
# noinspection PyUnboundLocalVariable
echo_duration = echotime - formattime
if duration > 0.05 and (
self._last_slow_emit_warning_time is None
or now > self._last_slow_emit_warning_time + 10.0
):
# Logging calls from *within* a logging handler
# sounds sketchy, so let's just kick this over to
# the bg event loop thread we've already got.
self._last_slow_emit_warning_time = now
self._event_loop.call_soon_threadsafe(
tpartial(
logging.warning,
'efro.log.LogHandler emit took too long'
' (%.2fs total; %.2fs format, %.2fs echo).',
duration,
format_duration,
echo_duration,
)
)
def _emit_in_thread( def _emit_in_thread(
self, name: str, levelno: int, created: float, message: str self, name: str, levelno: int, created: float, message: str
) -> None: ) -> None:

View File

@ -43,6 +43,20 @@ class SysResponse:
users of the api never see them. users of the api never see them.
""" """
def set_local_exception(self, exc: Exception) -> None:
"""Attach a local exception to facilitate better logging/handling.
Be aware that this data does not get serialized and only
exists on the local object.
"""
setattr(self, '_sr_local_exception', exc)
def get_local_exception(self) -> Exception | None:
"""Fetch a local attached exception."""
value = getattr(self, '_sr_local_exception', None)
assert isinstance(value, Exception | None)
return value
# Some standard response types: # Some standard response types:

View File

@ -282,10 +282,13 @@ class MessageProtocol:
def _get_module_header( def _get_module_header(
self, self,
part: Literal['sender', 'receiver'], part: Literal['sender', 'receiver'],
extra_import_code: str | None = None, extra_import_code: str | None,
enable_async_sends: bool,
) -> str: ) -> str:
"""Return common parts of generated modules.""" """Return common parts of generated modules."""
# pylint: disable=too-many-locals, too-many-branches # pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
import textwrap import textwrap
tpimports: dict[str, list[str]] = {} tpimports: dict[str, list[str]] = {}
@ -342,7 +345,7 @@ class MessageProtocol:
if part == 'sender': if part == 'sender':
import_lines += ( import_lines += (
'from efro.message import MessageSender,' ' BoundMessageSender' 'from efro.message import MessageSender, BoundMessageSender'
) )
tpimport_typing_extras = '' tpimport_typing_extras = ''
else: else:
@ -362,11 +365,18 @@ class MessageProtocol:
import_lines += f'\n{extra_import_code}\n' import_lines += f'\n{extra_import_code}\n'
ovld = ', overload' if not single_message_type else '' ovld = ', overload' if not single_message_type else ''
ovld2 = (
', cast, Awaitable'
if (single_message_type and part == 'sender' and enable_async_sends)
else ''
)
tpimport_lines = textwrap.indent(tpimport_lines, ' ') tpimport_lines = textwrap.indent(tpimport_lines, ' ')
baseimps = ['Any'] baseimps = ['Any']
if part == 'receiver': if part == 'receiver':
baseimps.append('Callable') baseimps.append('Callable')
if part == 'sender' and enable_async_sends:
baseimps.append('Awaitable')
baseimps_s = ', '.join(baseimps) baseimps_s = ', '.join(baseimps)
out = ( out = (
'# Released under the MIT License. See LICENSE for details.\n' '# Released under the MIT License. See LICENSE for details.\n'
@ -375,7 +385,7 @@ class MessageProtocol:
f'\n' f'\n'
f'from __future__ import annotations\n' f'from __future__ import annotations\n'
f'\n' f'\n'
f'from typing import TYPE_CHECKING{ovld}\n' f'from typing import TYPE_CHECKING{ovld}{ovld2}\n'
f'\n' f'\n'
f'{import_lines}\n' f'{import_lines}\n'
f'\n' f'\n'
@ -399,13 +409,16 @@ class MessageProtocol:
) -> str: ) -> str:
"""Used by create_sender_module(); do not call directly.""" """Used by create_sender_module(); do not call directly."""
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
# pylint: disable=too-many-branches
import textwrap import textwrap
msgtypes = list(self.message_ids_by_type.keys()) msgtypes = list(self.message_ids_by_type.keys())
ppre = '_' if private else '' ppre = '_' if private else ''
out = self._get_module_header( out = self._get_module_header(
'sender', extra_import_code=protocol_module_level_import_code 'sender',
extra_import_code=protocol_module_level_import_code,
enable_async_sends=enable_async_sends,
) )
ccind = textwrap.indent(protocol_create_code, ' ') ccind = textwrap.indent(protocol_create_code, ' ')
out += ( out += (
@ -438,7 +451,8 @@ class MessageProtocol:
continue continue
pfx = 'async ' if async_pass else '' pfx = 'async ' if async_pass else ''
sfx = '_async' if async_pass else '' sfx = '_async' if async_pass else ''
awt = 'await ' if async_pass else '' # awt = 'await ' if async_pass else ''
awt = ''
how = 'asynchronously' if async_pass else 'synchronously' how = 'asynchronously' if async_pass else 'synchronously'
if len(msgtypes) == 1: if len(msgtypes) == 1:
@ -451,22 +465,29 @@ class MessageProtocol:
rtypevar = ' | '.join(_filt_tp_name(t) for t in rtypes) rtypevar = ' | '.join(_filt_tp_name(t) for t in rtypes)
else: else:
rtypevar = _filt_tp_name(rtypes[0]) rtypevar = _filt_tp_name(rtypes[0])
if async_pass:
rtypevar = f'Awaitable[{rtypevar}]'
out += ( out += (
f'\n' f'\n'
f' {pfx}def send{sfx}(self,' f' def send{sfx}(self,'
f' message: {msgtypevar})' f' message: {msgtypevar})'
f' -> {rtypevar}:\n' f' -> {rtypevar}:\n'
f' """Send a message {how}."""\n' f' """Send a message {how}."""\n'
f' out = {awt}self._sender.' f' out = {awt}self._sender.'
f'send{sfx}(self._obj, message)\n' f'send{sfx}(self._obj, message)\n'
f' assert isinstance(out, {rtypevar})\n'
f' return out\n'
) )
if not async_pass:
out += (
f' assert isinstance(out, {rtypevar})\n'
' return out\n'
)
else:
out += f' return cast({rtypevar}, out)\n'
else: else:
for msgtype in msgtypes: for msgtype in msgtypes:
msgtypevar = msgtype.__name__ msgtypevar = msgtype.__name__
# rtypes = msgtype.get_response_types()
rtypes = msgtype.get_response_types() rtypes = msgtype.get_response_types()
if len(rtypes) > 1: if len(rtypes) > 1:
rtypevar = ' | '.join( rtypevar = ' | '.join(
@ -482,10 +503,13 @@ class MessageProtocol:
f' -> {rtypevar}:\n' f' -> {rtypevar}:\n'
f' ...\n' f' ...\n'
) )
rtypevar = 'Response | None'
if async_pass:
rtypevar = f'Awaitable[{rtypevar}]'
out += ( out += (
f'\n' f'\n'
f' {pfx}def send{sfx}(self, message: Message)' f' def send{sfx}(self, message: Message)'
f' -> Response | None:\n' f' -> {rtypevar}:\n'
f' """Send a message {how}."""\n' f' """Send a message {how}."""\n'
f' return {awt}self._sender.' f' return {awt}self._sender.'
f'send{sfx}(self._obj, message)\n' f'send{sfx}(self._obj, message)\n'
@ -509,7 +533,9 @@ class MessageProtocol:
ppre = '_' if private else '' ppre = '_' if private else ''
msgtypes = list(self.message_ids_by_type.keys()) msgtypes = list(self.message_ids_by_type.keys())
out = self._get_module_header( out = self._get_module_header(
'receiver', extra_import_code=protocol_module_level_import_code 'receiver',
extra_import_code=protocol_module_level_import_code,
enable_async_sends=False,
) )
ccind = textwrap.indent(protocol_create_code, ' ') ccind = textwrap.indent(protocol_create_code, ' ')
out += ( out += (
@ -602,11 +628,11 @@ class MessageProtocol:
if is_async: if is_async:
out += ( out += (
'\n' '\n'
' async def handle_raw_message(\n' ' def handle_raw_message(\n'
' self, message: str, raise_unregistered: bool = False\n' ' self, message: str, raise_unregistered: bool = False\n'
' ) -> str:\n' ' ) -> Awaitable[str]:\n'
' """Asynchronously handle a raw incoming message."""\n' ' """Asynchronously handle a raw incoming message."""\n'
' return await self._receiver.' ' return self._receiver.'
'handle_raw_message_async(\n' 'handle_raw_message_async(\n'
' self._obj, message, raise_unregistered\n' ' self._obj, message, raise_unregistered\n'
' )\n' ' )\n'

View File

@ -62,12 +62,6 @@ class MessageReceiver:
[Any, Message | None, Response | SysResponse, dict], None [Any, Message | None, Response | SysResponse, dict], None
] | None = None ] | None = None
# TODO: don't currently have async encode equivalent
# or either for sender; can add as needed.
self._decode_filter_async_call: Callable[
[Any, dict, Message], Awaitable[None]
] | None = None
# noinspection PyProtectedMember # noinspection PyProtectedMember
def register_handler( def register_handler(
self, call: Callable[[Any, Message], Response | None] self, call: Callable[[Any, Message], Response | None]
@ -96,14 +90,17 @@ class MessageReceiver:
# Make sure we are only given async methods if we are an async handler # Make sure we are only given async methods if we are an async handler
# and sync ones otherwise. # and sync ones otherwise.
is_async = inspect.iscoroutinefunction(call) # UPDATE - can't do this anymore since we now sometimes use
if self.is_async != is_async: # regular functions which return awaitables instead of having
msg = ( # the entire function be async.
'Expected a sync method; found an async one.' # is_async = inspect.iscoroutinefunction(call)
if is_async # if self.is_async != is_async:
else 'Expected an async method; found a sync one.' # msg = (
) # 'Expected a sync method; found an async one.'
raise ValueError(msg) # if is_async
# else 'Expected an async method; found a sync one.'
# )
# raise ValueError(msg)
# Check annotation types to determine what message types we handle. # Check annotation types to determine what message types we handle.
# Return-type annotation can be a Union, but we probably don't # Return-type annotation can be a Union, but we probably don't
@ -189,19 +186,6 @@ class MessageReceiver:
self._decode_filter_call = call self._decode_filter_call = call
return call return call
def decode_filter_async_method(
self, call: Callable[[Any, dict, Message], Awaitable[None]]
) -> Callable[[Any, dict, Message], Awaitable[None]]:
"""Function decorator for defining a decode filter.
Decode filters can be used to extract extra data from incoming
message dicts. Note that this version will only work with
handle_raw_message_async().
"""
assert self._decode_filter_async_call is None
self._decode_filter_async_call = call
return call
def encode_filter_method( def encode_filter_method(
self, self,
call: Callable[ call: Callable[
@ -247,24 +231,6 @@ class MessageReceiver:
bound_obj, _msg_dict, msg_decoded = self._decode_incoming_message_base( bound_obj, _msg_dict, msg_decoded = self._decode_incoming_message_base(
bound_obj=bound_obj, msg=msg bound_obj=bound_obj, msg=msg
) )
# If they've set an async filter but are calling sync
# handle_raw_message() its likely a bug.
assert self._decode_filter_async_call is None
return msg_decoded
async def _decode_incoming_message_async(
self, bound_obj: Any, msg: str
) -> Message:
bound_obj, msg_dict, msg_decoded = self._decode_incoming_message_base(
bound_obj=bound_obj, msg=msg
)
if self._decode_filter_async_call is not None:
await self._decode_filter_async_call(
bound_obj, msg_dict, msg_decoded
)
return msg_decoded return msg_decoded
def encode_user_response( def encode_user_response(
@ -316,6 +282,7 @@ class MessageReceiver:
""" """
assert not self.is_async, "can't call sync handler on async receiver" assert not self.is_async, "can't call sync handler on async receiver"
msg_decoded: Message | None = None msg_decoded: Message | None = None
msgtype: type[Message] | None = None
try: try:
msg_decoded = self._decode_incoming_message(bound_obj, msg) msg_decoded = self._decode_incoming_message(bound_obj, msg)
msgtype = type(msg_decoded) msgtype = type(msg_decoded)
@ -335,41 +302,93 @@ class MessageReceiver:
bound_obj, msg_decoded, exc bound_obj, msg_decoded, exc
) )
if dolog: if dolog:
logging.exception('Error in efro.message handling.') if msgtype is not None:
logging.exception(
'Error handling %s.%s message.',
msgtype.__module__,
msgtype.__qualname__,
)
else:
logging.exception('Error in efro.message handling.')
return rstr return rstr
async def handle_raw_message_async( def handle_raw_message_async(
self, bound_obj: Any, msg: str, raise_unregistered: bool = False self, bound_obj: Any, msg: str, raise_unregistered: bool = False
) -> str: ) -> Awaitable[str]:
"""Should be called when the receiver gets a message. """Should be called when the receiver gets a message.
The return value is the raw response to the message. The return value is the raw response to the message.
""" """
# Note: This call is synchronous so that the first part of it can
# happen synchronously. If the whole call were async we wouldn't be
# able to guarantee that messages handlers would be called in the
# order the messages were received.
assert self.is_async, "can't call async handler on sync receiver" assert self.is_async, "can't call async handler on sync receiver"
msg_decoded: Message | None = None msg_decoded: Message | None = None
msgtype: type[Message] | None = None
try: try:
msg_decoded = await self._decode_incoming_message_async( msg_decoded = self._decode_incoming_message(bound_obj, msg)
bound_obj, msg
)
msgtype = type(msg_decoded) msgtype = type(msg_decoded)
handler = self._handlers.get(msgtype) handler = self._handlers.get(msgtype)
if handler is None: if handler is None:
raise RuntimeError(f'Got unhandled message type: {msgtype}.') raise RuntimeError(f'Got unhandled message type: {msgtype}.')
response = await handler(bound_obj, msg_decoded) handler_awaitable = handler(bound_obj, msg_decoded)
assert isinstance(response, Response | None)
return self.encode_user_response(bound_obj, msg_decoded, response)
except Exception as exc: except Exception as exc:
if raise_unregistered and isinstance( if raise_unregistered and isinstance(
exc, UnregisteredMessageIDError exc, UnregisteredMessageIDError
): ):
raise raise
rstr, dolog = self.encode_error_response( return self._handle_raw_message_async_error(
bound_obj, msg_decoded, exc bound_obj, msg_decoded, msgtype, exc
) )
if dolog:
# Return an awaitable to handle the rest asynchronously.
return self._handle_raw_message_async(
bound_obj, msg_decoded, msgtype, handler_awaitable
)
async def _handle_raw_message_async_error(
self,
bound_obj: Any,
msg_decoded: Message | None,
msgtype: type[Message] | None,
exc: Exception,
) -> str:
rstr, dolog = self.encode_error_response(bound_obj, msg_decoded, exc)
if dolog:
if msgtype is not None:
logging.exception(
'Error handling %s.%s message.',
msgtype.__module__,
msgtype.__qualname__,
)
else:
logging.exception('Error in efro.message handling.') logging.exception('Error in efro.message handling.')
return rstr return rstr
async def _handle_raw_message_async(
self,
bound_obj: Any,
msg_decoded: Message,
msgtype: type[Message] | None,
handler_awaitable: Awaitable[Response | None],
) -> str:
"""Should be called when the receiver gets a message.
The return value is the raw response to the message.
"""
try:
response = await handler_awaitable
assert isinstance(response, Response | None)
return self.encode_user_response(bound_obj, msg_decoded, response)
except Exception as exc:
return await self._handle_raw_message_async_error(
bound_obj, msg_decoded, msgtype, exc
)
class BoundMessageReceiver: class BoundMessageReceiver:

View File

@ -6,7 +6,6 @@ Supports static typing for message types and possible return types.
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from efro.error import CleanError, RemoteError, CommunicationError from efro.error import CleanError, RemoteError, CommunicationError
@ -45,6 +44,9 @@ class MessageSender:
self._send_async_raw_message_call: Callable[ self._send_async_raw_message_call: Callable[
[Any, str], Awaitable[str] [Any, str], Awaitable[str]
] | None = None ] | None = None
self._send_async_raw_message_ex_call: Callable[
[Any, str, Message], Awaitable[str]
] | None = None
self._encode_filter_call: Callable[ self._encode_filter_call: Callable[
[Any, Message, dict], None [Any, Message, dict], None
] | None = None ] | None = None
@ -76,11 +78,32 @@ class MessageSender:
CommunicationErrors raised here will be returned to the sender CommunicationErrors raised here will be returned to the sender
as such; all other exceptions will result in a RuntimeError for as such; all other exceptions will result in a RuntimeError for
the sender. the sender.
IMPORTANT: Generally async send methods should not be implemented
as 'async' methods, but instead should be regular methods that
return awaitable objects. This way it can be guaranteed that
outgoing messages are synchronously enqueued in the correct
order, and then async calls can be returned which finish each
send. If the entire call is async, they may be enqueued out of
order in rare cases.
""" """
assert self._send_async_raw_message_call is None assert self._send_async_raw_message_call is None
self._send_async_raw_message_call = call self._send_async_raw_message_call = call
return call return call
def send_async_ex_method(
self, call: Callable[[Any, str, Message], Awaitable[str]]
) -> Callable[[Any, str, Message], Awaitable[str]]:
"""Function decorator for extended send-async method.
Version of send_async_method which is also is passed the original
unencoded message; can be useful for cases where metadata is sent
along with messages referring to their payloads/etc.
"""
assert self._send_async_raw_message_ex_call is None
self._send_async_raw_message_ex_call = call
return call
def encode_filter_method( def encode_filter_method(
self, call: Callable[[Any, Message, dict], None] self, call: Callable[[Any, Message, dict], None]
) -> Callable[[Any, Message, dict], None]: ) -> Callable[[Any, Message, dict], None]:
@ -127,17 +150,34 @@ class MessageSender:
), ),
) )
async def send_async( def send_async(
self, bound_obj: Any, message: Message self, bound_obj: Any, message: Message
) -> Response | None: ) -> Awaitable[Response | None]:
"""Send a message asynchronously.""" """Send a message asynchronously."""
# Note: This call is synchronous so that the first part of it can
# happen synchronously. If the whole call were async we wouldn't be
# able to guarantee that messages sent in order would actually go
# out in order.
raw_response_awaitable = self.fetch_raw_response_async(
bound_obj=bound_obj,
message=message,
)
# Now return an awaitable that will finish the send.
return self._send_async_awaitable(
bound_obj, message, raw_response_awaitable
)
async def _send_async_awaitable(
self,
bound_obj: Any,
message: Message,
raw_response_awaitable: Awaitable[Response | SysResponse],
) -> Response | None:
return self.unpack_raw_response( return self.unpack_raw_response(
bound_obj=bound_obj, bound_obj=bound_obj,
message=message, message=message,
raw_response=await self.fetch_raw_response_async( raw_response=await raw_response_awaitable,
bound_obj=bound_obj,
message=message,
),
) )
def fetch_raw_response( def fetch_raw_response(
@ -158,52 +198,95 @@ class MessageSender:
bound_obj, msg_encoded bound_obj, msg_encoded
) )
except Exception as exc: except Exception as exc:
# Any error in the raw send call gets recorded as either response = ErrorSysResponse(
# a local or communication error. error_message='Error in MessageSender @send_method.',
return ErrorSysResponse(
error_message=f'Error in MessageSender @send_method'
f' ({type(exc)}): {exc}',
error_type=( error_type=(
ErrorSysResponse.ErrorType.COMMUNICATION ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError) if isinstance(exc, CommunicationError)
else ErrorSysResponse.ErrorType.LOCAL else ErrorSysResponse.ErrorType.LOCAL
), ),
) )
# Can include the actual exception since we'll be looking at
# this locally; might be helpful.
response.set_local_exception(exc)
return response
return self._decode_raw_response(bound_obj, message, response_encoded) return self._decode_raw_response(bound_obj, message, response_encoded)
async def fetch_raw_response_async( def fetch_raw_response_async(
self, bound_obj: Any, message: Message self, bound_obj: Any, message: Message
) -> Response | SysResponse: ) -> Awaitable[Response | SysResponse]:
"""Fetch a raw message response. """Fetch a raw message response awaitable.
The result of this should be passed to unpack_raw_response() to The result of this should be awaited and then passed to
produce the final message result. unpack_raw_response() to produce the final message result.
Generally you can just call send(); calling fetch and unpack Generally you can just call send(); calling fetch and unpack
manually is for when message sending and response handling need manually is for when message sending and response handling need
to happen in different contexts/threads. to happen in different contexts/threads.
""" """
if self._send_async_raw_message_call is None: # Note: This call is synchronous so that the first part of it can
# happen synchronously. If the whole call were async we wouldn't be
# able to guarantee that messages sent in order would actually go
# out in order.
if (
self._send_async_raw_message_call is None
and self._send_async_raw_message_ex_call is None
):
raise RuntimeError('send_async() is unimplemented for this type.') raise RuntimeError('send_async() is unimplemented for this type.')
msg_encoded = self._encode_message(bound_obj, message) msg_encoded = self._encode_message(bound_obj, message)
try: try:
response_encoded = await self._send_async_raw_message_call( if self._send_async_raw_message_ex_call is not None:
bound_obj, msg_encoded send_awaitable = self._send_async_raw_message_ex_call(
) bound_obj, msg_encoded, message
)
else:
assert self._send_async_raw_message_call is not None
send_awaitable = self._send_async_raw_message_call(
bound_obj, msg_encoded
)
except Exception as exc: except Exception as exc:
# Any error in the raw send call gets recorded as either return self._error_awaitable(exc)
# a local or communication error.
return ErrorSysResponse( # Now return an awaitable to finish the job.
error_message=f'Error in MessageSender @send_async_method' return self._fetch_raw_response_awaitable(
f' ({type(exc)}): {exc}', bound_obj, message, send_awaitable
)
async def _error_awaitable(self, exc: Exception) -> SysResponse:
response = ErrorSysResponse(
error_message='Error in MessageSender @send_async_method.',
error_type=(
ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError)
else ErrorSysResponse.ErrorType.LOCAL
),
)
# Can include the actual exception since we'll be looking at
# this locally; might be helpful.
response.set_local_exception(exc)
return response
async def _fetch_raw_response_awaitable(
self, bound_obj: Any, message: Message, send_awaitable: Awaitable[str]
) -> Response | SysResponse:
try:
response_encoded = await send_awaitable
except Exception as exc:
response = ErrorSysResponse(
error_message='Error in MessageSender @send_async_method.',
error_type=( error_type=(
ErrorSysResponse.ErrorType.COMMUNICATION ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError) if isinstance(exc, CommunicationError)
else ErrorSysResponse.ErrorType.LOCAL else ErrorSysResponse.ErrorType.LOCAL
), ),
) )
# Can include the actual exception since we'll be looking at
# this locally; might be helpful.
response.set_local_exception(exc)
return response
return self._decode_raw_response(bound_obj, message, response_encoded) return self._decode_raw_response(bound_obj, message, response_encoded)
def unpack_raw_response( def unpack_raw_response(
@ -250,18 +333,14 @@ class MessageSender:
self._decode_filter_call( self._decode_filter_call(
bound_obj, message, response_dict, response bound_obj, message, response_dict, response
) )
except Exception: except Exception as exc:
# If we got to this point, we successfully communicated
# with the other end so errors represent protocol mismatches
# or other invalid data. For now let's just log it but perhaps
# we'd want to somehow embed it in the ErrorSysResponse to be
# available directly to the user later.
logging.exception('Error decoding raw response')
response = ErrorSysResponse( response = ErrorSysResponse(
error_message='Error decoding raw response;' error_message='Error decoding raw response.',
' see log for details.',
error_type=ErrorSysResponse.ErrorType.LOCAL, error_type=ErrorSysResponse.ErrorType.LOCAL,
) )
# Since we'll be looking at this locally, we can include
# extra info for logging/etc.
response.set_local_exception(exc)
return response return response
def _unpack_raw_response( def _unpack_raw_response(
@ -282,16 +361,24 @@ class MessageSender:
# Some error occurred. Raise a local Exception for it. # Some error occurred. Raise a local Exception for it.
if isinstance(raw_response, ErrorSysResponse): if isinstance(raw_response, ErrorSysResponse):
# Errors that happened locally can attach their exceptions
# here for extra logging goodness.
local_exception = raw_response.get_local_exception()
if ( if (
raw_response.error_type raw_response.error_type
is ErrorSysResponse.ErrorType.COMMUNICATION is ErrorSysResponse.ErrorType.COMMUNICATION
): ):
raise CommunicationError(raw_response.error_message) raise CommunicationError(
raw_response.error_message
) from local_exception
# If something went wrong on *our* end of the connection, # If something went wrong on *our* end of the connection,
# don't say it was a remote error. # don't say it was a remote error.
if raw_response.error_type is ErrorSysResponse.ErrorType.LOCAL: if raw_response.error_type is ErrorSysResponse.ErrorType.LOCAL:
raise RuntimeError(raw_response.error_message) raise RuntimeError(
raw_response.error_message
) from local_exception
# If they want to support clean errors, do those. # If they want to support clean errors, do those.
if ( if (
@ -299,14 +386,18 @@ class MessageSender:
and raw_response.error_type and raw_response.error_type
is ErrorSysResponse.ErrorType.REMOTE_CLEAN is ErrorSysResponse.ErrorType.REMOTE_CLEAN
): ):
raise CleanError(raw_response.error_message) raise CleanError(
raw_response.error_message
) from local_exception
if ( if (
self.protocol.forward_communication_errors self.protocol.forward_communication_errors
and raw_response.error_type and raw_response.error_type
is ErrorSysResponse.ErrorType.REMOTE_COMMUNICATION is ErrorSysResponse.ErrorType.REMOTE_COMMUNICATION
): ):
raise CommunicationError(raw_response.error_message) raise CommunicationError(
raw_response.error_message
) from local_exception
# Everything else gets lumped in as a remote error. # Everything else gets lumped in as a remote error.
raise RemoteError( raise RemoteError(
@ -316,7 +407,7 @@ class MessageSender:
if self._peer_desc_call is None if self._peer_desc_call is None
else self._peer_desc_call(bound_obj) else self._peer_desc_call(bound_obj)
), ),
) ) from local_exception
assert isinstance(raw_response, Response) assert isinstance(raw_response, Response)
return raw_response return raw_response
@ -345,23 +436,23 @@ class BoundMessageSender:
assert self._obj is not None assert self._obj is not None
return self._sender.send(bound_obj=self._obj, message=message) return self._sender.send(bound_obj=self._obj, message=message)
async def send_async_untyped(self, message: Message) -> Response | None: def send_async_untyped(
self, message: Message
) -> Awaitable[Response | None]:
"""Send a message asynchronously. """Send a message asynchronously.
Whenever possible, use the send_async() call provided by generated Whenever possible, use the send_async() call provided by generated
subclasses instead of this; it will provide better type safety. subclasses instead of this; it will provide better type safety.
""" """
assert self._obj is not None assert self._obj is not None
return await self._sender.send_async( return self._sender.send_async(bound_obj=self._obj, message=message)
bound_obj=self._obj, message=message
)
async def fetch_raw_response_async_untyped( def fetch_raw_response_async_untyped(
self, message: Message self, message: Message
) -> Response | SysResponse: ) -> Awaitable[Response | SysResponse]:
"""Split send (part 1 of 2).""" """Split send (part 1 of 2)."""
assert self._obj is not None assert self._obj is not None
return await self._sender.fetch_raw_response_async( return self._sender.fetch_raw_response_async(
bound_obj=self._obj, message=message bound_obj=self._obj, message=message
) )

View File

@ -323,12 +323,12 @@ class RPCEndpoint:
if self.debug_print: if self.debug_print:
self.debug_print_call(f'{self._label}: finished.') self.debug_print_call(f'{self._label}: finished.')
async def send_message( def send_message(
self, self,
message: bytes, message: bytes,
timeout: float | None = None, timeout: float | None = None,
close_on_error: bool = True, close_on_error: bool = True,
) -> bytes: ) -> Awaitable[bytes]:
"""Send a message to the peer and return a response. """Send a message to the peer and return a response.
If timeout is not provided, the default will be used. If timeout is not provided, the default will be used.
@ -340,7 +340,10 @@ class RPCEndpoint:
respect to a given endpoint. Pass close_on_error=False to respect to a given endpoint. Pass close_on_error=False to
override this for a particular message. override this for a particular message.
""" """
# pylint: disable=too-many-branches # Note: This call is synchronous so that the first part of it
# (enqueueing outgoing messages) happens synchronously. If it were
# a pure async call it could be possible for send order to vary
# based on how the async tasks get processed.
if self.debug_print_io: if self.debug_print_io:
self.debug_print_call( self.debug_print_call(
@ -358,16 +361,6 @@ class RPCEndpoint:
f'{self._label}: have peerinfo? {self._peer_info is not None}.' f'{self._label}: have peerinfo? {self._peer_info is not None}.'
) )
# We need to know their protocol, so if we haven't gotten a handshake
# from them yet, just wait.
while self._peer_info is None:
await asyncio.sleep(0.01)
assert self._peer_info is not None
if self._peer_info.protocol == 1:
if len(message) > 65535:
raise RuntimeError('Message cannot be larger than 65535 bytes')
# message_id is a 16 bit looping value. # message_id is a 16 bit looping value.
message_id = self._next_message_id message_id = self._next_message_id
self._next_message_id = (self._next_message_id + 1) % 65536 self._next_message_id = (self._next_message_id + 1) % 65536
@ -420,8 +413,35 @@ class RPCEndpoint:
if timeout is None: if timeout is None:
timeout = self.DEFAULT_MESSAGE_TIMEOUT timeout = self.DEFAULT_MESSAGE_TIMEOUT
assert timeout is not None assert timeout is not None
bytes_awaitable = msgobj.wait_task
# Now complete the send asynchronously.
return self._send_message(
message, timeout, close_on_error, bytes_awaitable, message_id
)
async def _send_message(
self,
message: bytes,
timeout: float | None,
close_on_error: bool,
bytes_awaitable: asyncio.Task[bytes],
message_id: int,
) -> bytes:
# We need to know their protocol, so if we haven't gotten a handshake
# from them yet, just wait.
while self._peer_info is None:
await asyncio.sleep(0.01)
assert self._peer_info is not None
if self._peer_info.protocol == 1:
if len(message) > 65535:
raise RuntimeError('Message cannot be larger than 65535 bytes')
try: try:
return await asyncio.wait_for(msgobj.wait_task, timeout=timeout) return await asyncio.wait_for(bytes_awaitable, timeout=timeout)
except asyncio.CancelledError as exc: except asyncio.CancelledError as exc:
# Question: we assume this means the above wait_for() was # Question: we assume this means the above wait_for() was
# cancelled; how do we distinguish between this and *us* being # cancelled; how do we distinguish between this and *us* being
@ -449,7 +469,7 @@ class RPCEndpoint:
) )
# Stop waiting on the response. # Stop waiting on the response.
msgobj.wait_task.cancel() bytes_awaitable.cancel()
# Remove the record of this message. # Remove the record of this message.
del self._in_flight_messages[message_id] del self._in_flight_messages[message_id]
@ -628,7 +648,8 @@ class RPCEndpoint:
# Now just sit and handle stuff as it comes in. # Now just sit and handle stuff as it comes in.
while True: while True:
assert not self._closing if self._closing:
return
# Read message type. # Read message type.
mtype = _PacketType(await self._read_int_8()) mtype = _PacketType(await self._read_int_8())

View File

@ -39,6 +39,7 @@ class _EmptyObj:
pass pass
# TODO: kill this and just use efro.call.tpartial
if TYPE_CHECKING: if TYPE_CHECKING:
Call = Call Call = Call
else: else:

View File

@ -134,6 +134,7 @@ def _add_build_to_xcarchive(
MODES['debug']['configuration'], MODES['debug']['configuration'],
'-archivePath', '-archivePath',
str(archivepathbase), str(archivepathbase),
'-allowProvisioningUpdates',
] ]
subprocess.run(args, check=True, capture_output=False) subprocess.run(args, check=True, capture_output=False)

View File

@ -162,6 +162,13 @@ def _spelling(words: list[str]) -> None:
print(f'Modified {num_modded_dictionaries} dictionaries.') print(f'Modified {num_modded_dictionaries} dictionaries.')
def pur() -> None:
"""Run pur using project's Python version."""
import subprocess
subprocess.run([sys.executable, '-m', 'pur'] + sys.argv[2:], check=True)
def spelling_all() -> None: def spelling_all() -> None:
"""Add all misspellings from a pycharm run.""" """Add all misspellings from a pycharm run."""
import subprocess import subprocess

View File

@ -45,6 +45,19 @@ def ignore_type_check_filter(if_node: nc.NodeNG) -> nc.NodeNG:
and isinstance(if_node.parent, astroid.Module) and isinstance(if_node.parent, astroid.Module)
): ):
# Special case: some third party modules are starting to contain
# code that we don't handle cleanly which results in pylint runs
# breaking. For now just ignoring them as they pop up.
# We should try to figure out how to disable this filtering
# for third party modules altogether or make our filtering more
# robust.
if if_node.parent.name in {
'filelock',
'aiohttp.web_app',
'aiohttp.web_response',
}:
return if_node
module_node = if_node.parent module_node = if_node.parent
# Remove any locals getting defined under this if statement. # Remove any locals getting defined under this if statement.
@ -177,7 +190,7 @@ def var_annotations_filter(node: nc.NodeNG) -> nc.NodeNG:
# Future behavior: # Future behavior:
# Annotated assigns under functions are not evaluated. # Annotated assigns under functions are not evaluated.
# Class and module vars are normally not either. However we # Class and module vars are normally not either. However we
# do evaluate if we come across an 'ioprepped' dataclass # *do* evaluate if we come across an 'ioprepped' dataclass
# decorator. (the 'ioprepped' decorator explicitly evaluates # decorator. (the 'ioprepped' decorator explicitly evaluates
# dataclass annotations). # dataclass annotations).
@ -230,7 +243,8 @@ def var_annotations_filter(node: nc.NodeNG) -> nc.NodeNG:
break break
fnode = fnode.parent fnode = fnode.parent
# If this annotation won't be eval'ed, replace it with a dummy string. # If this annotation won't be eval'ed, replace its annotation with
# a dummy value.
if not willeval: if not willeval:
dummyval = astroid.Const(parent=node, value='dummyval') dummyval = astroid.Const(parent=node, value='dummyval')
node.annotation = dummyval node.annotation = dummyval