cuju-render 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. cuju/render/__init__.py +759 -0
  2. cuju/render/__main__.py +1065 -0
  3. cuju/render/_export/__init__.py +3 -0
  4. cuju/render/_export/feed.py +542 -0
  5. cuju/render/_export/sizes.py +157 -0
  6. cuju/render/_export/video.py +1618 -0
  7. cuju/render/_export/video_jobs.py +408 -0
  8. cuju/render/_format/__init__.py +3 -0
  9. cuju/render/_format/annotations.py +235 -0
  10. cuju/render/_format/codec.py +307 -0
  11. cuju/render/_format/keep.py +121 -0
  12. cuju/render/_format/recording.py +860 -0
  13. cuju/render/_format/writer.py +54 -0
  14. cuju/render/_guide/__init__.py +3 -0
  15. cuju/render/_guide/doctor.py +523 -0
  16. cuju/render/_guide/help.py +62 -0
  17. cuju/render/_guide/tutorial.py +254 -0
  18. cuju/render/_http/__init__.py +3 -0
  19. cuju/render/_http/commands.py +57 -0
  20. cuju/render/_http/headers.py +103 -0
  21. cuju/render/_http/launch.py +500 -0
  22. cuju/render/_http/launchers.py +408 -0
  23. cuju/render/_http/pwa.py +128 -0
  24. cuju/render/_http/server.py +2105 -0
  25. cuju/render/_http/window.py +360 -0
  26. cuju/render/_moments/__init__.py +3 -0
  27. cuju/render/_moments/captions.py +284 -0
  28. cuju/render/_moments/events.py +1123 -0
  29. cuju/render/_moments/search.py +694 -0
  30. cuju/render/_packages/__init__.py +3 -0
  31. cuju/render/_packages/glsl.py +746 -0
  32. cuju/render/_packages/loudness.py +334 -0
  33. cuju/render/_packages/pack.py +1959 -0
  34. cuju/render/_packages/presentation.py +1376 -0
  35. cuju/render/_packages/vocabulary.py +609 -0
  36. cuju/render/_playing/__init__.py +3 -0
  37. cuju/render/_playing/achievements.py +463 -0
  38. cuju/render/_playing/bench.py +609 -0
  39. cuju/render/_playing/challenges.py +1813 -0
  40. cuju/render/_playing/demo.py +323 -0
  41. cuju/render/_playing/draw.py +211 -0
  42. cuju/render/_playing/errors.py +20 -0
  43. cuju/render/_playing/game.py +645 -0
  44. cuju/render/_playing/limits.py +339 -0
  45. cuju/render/_playing/play.py +926 -0
  46. cuju/render/_playing/policy_worker.py +250 -0
  47. cuju/render/_playing/strict.py +810 -0
  48. cuju/render/_registry/__init__.py +3 -0
  49. cuju/render/_registry/clips.py +394 -0
  50. cuju/render/_registry/joints.py +108 -0
  51. cuju/render/_registry/teams.py +2058 -0
  52. cuju/render/_store/__init__.py +3 -0
  53. cuju/render/_store/home.py +70 -0
  54. cuju/render/_store/library.py +604 -0
  55. cuju/render/_store/samples.py +163 -0
  56. cuju/render/_text/__init__.py +3 -0
  57. cuju/render/_text/languages.py +637 -0
  58. cuju/render/_text/messages.py +1396 -0
  59. cuju/render/_tournaments/__init__.py +3 -0
  60. cuju/render/_tournaments/results.py +547 -0
  61. cuju/render/_tournaments/tournament.py +2742 -0
  62. cuju/render/_tournaments/tournament_cli.py +587 -0
  63. cuju/render/_tournaments/tournament_runner.py +1975 -0
  64. cuju/render/_version.py +6 -0
  65. cuju/render/_watch/__init__.py +3 -0
  66. cuju/render/_watch/grid.py +265 -0
  67. cuju/render/_watch/live.py +664 -0
  68. cuju/render/_watch/notebook.py +79 -0
  69. cuju/render/_watch/show.py +171 -0
  70. cuju/render/samples/sample-11v11.cjr +0 -0
  71. cuju/render/samples/sample-5v5.cjr +0 -0
  72. cuju/render/samples/teams/starter/main.py +33 -0
  73. cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
  74. cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
  75. cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
  76. cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
  77. cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
  78. cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
  79. cuju/render/web/assets/adboard_polygon.webp +0 -0
  80. cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
  81. cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
  82. cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
  83. cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
  84. cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
  85. cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
  86. cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
  87. cuju/render/web/assets/ball.glb +0 -0
  88. cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
  89. cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
  90. cuju/render/web/assets/challenges/corner.mp4 +0 -0
  91. cuju/render/web/assets/challenges/corner.webp +0 -0
  92. cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
  93. cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
  94. cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
  95. cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
  96. cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
  97. cuju/render/web/assets/challenges/empty_goal.webp +0 -0
  98. cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
  99. cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
  100. cuju/render/web/assets/challenges/manifest.json +375 -0
  101. cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
  102. cuju/render/web/assets/challenges/one_on_one.webp +0 -0
  103. cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
  104. cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
  105. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
  106. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
  107. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
  108. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
  109. cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
  110. cuju/render/web/assets/challenges/run_to_score.webp +0 -0
  111. cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
  112. cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
  113. cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
  114. cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
  115. cuju/render/web/assets/crowd.webp +0 -0
  116. cuju/render/web/assets/crowd_normal.webp +0 -0
  117. cuju/render/web/assets/cuju-logo-384.png +0 -0
  118. cuju/render/web/assets/cuju-logo.png +0 -0
  119. cuju/render/web/assets/cuju-logo.svg +46 -0
  120. cuju/render/web/assets/cuju-mark-light.svg +33 -0
  121. cuju/render/web/assets/cuju-mark.svg +33 -0
  122. cuju/render/web/assets/goal.glb +0 -0
  123. cuju/render/web/assets/grass.webp +0 -0
  124. cuju/render/web/assets/hair_black.png +0 -0
  125. cuju/render/web/assets/hair_blonde.png +0 -0
  126. cuju/render/web/assets/hair_brown.png +0 -0
  127. cuju/render/web/assets/hair_darkblonde.png +0 -0
  128. cuju/render/web/assets/hair_long01.glb +0 -0
  129. cuju/render/web/assets/hair_long02.glb +0 -0
  130. cuju/render/web/assets/hair_medium01.glb +0 -0
  131. cuju/render/web/assets/hair_red.png +0 -0
  132. cuju/render/web/assets/hair_short01.glb +0 -0
  133. cuju/render/web/assets/hair_short02.glb +0 -0
  134. cuju/render/web/assets/icons/cuju-192.png +0 -0
  135. cuju/render/web/assets/icons/cuju-512.png +0 -0
  136. cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
  137. cuju/render/web/assets/kit_goalkeeper.webp +0 -0
  138. cuju/render/web/assets/kit_mask.png +0 -0
  139. cuju/render/web/assets/kit_normal.webp +0 -0
  140. cuju/render/web/assets/manifest.json +601 -0
  141. cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
  142. cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
  143. cuju/render/web/assets/models/kaykit-knight.glb +0 -0
  144. cuju/render/web/assets/models/kaykit-knight.json +23 -0
  145. cuju/render/web/assets/models/kaykit-mage.glb +0 -0
  146. cuju/render/web/assets/models/kaykit-mage.json +23 -0
  147. cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
  148. cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
  149. cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
  150. cuju/render/web/assets/models/kaykit-ranger.json +23 -0
  151. cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
  152. cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
  153. cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
  154. cuju/render/web/assets/models/kaykit-rogue.json +23 -0
  155. cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
  156. cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
  157. cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
  158. cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
  159. cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
  160. cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
  161. cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
  162. cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
  163. cuju/render/web/assets/pitch.webp +0 -0
  164. cuju/render/web/assets/pitch_normal.webp +0 -0
  165. cuju/render/web/assets/player.glb +0 -0
  166. cuju/render/web/assets/shoe.webp +0 -0
  167. cuju/render/web/assets/shoe_sole.png +0 -0
  168. cuju/render/web/assets/skin_01.png +0 -0
  169. cuju/render/web/assets/skin_02.png +0 -0
  170. cuju/render/web/assets/skin_03.png +0 -0
  171. cuju/render/web/assets/skin_04.png +0 -0
  172. cuju/render/web/assets/sounds/applause.ogg +0 -0
  173. cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
  174. cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
  175. cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
  176. cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
  177. cuju/render/web/assets/sounds/city-horn.ogg +0 -0
  178. cuju/render/web/assets/sounds/city-loop.ogg +0 -0
  179. cuju/render/web/assets/sounds/city-siren.ogg +0 -0
  180. cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
  181. cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
  182. cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
  183. cuju/render/web/assets/sounds/gulls.ogg +0 -0
  184. cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
  185. cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
  186. cuju/render/web/assets/sounds/sounds.json +46 -0
  187. cuju/render/web/assets/sounds/space-beep.ogg +0 -0
  188. cuju/render/web/assets/sounds/space-hum.ogg +0 -0
  189. cuju/render/web/assets/sounds/space-radio.ogg +0 -0
  190. cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
  191. cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
  192. cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
  193. cuju/render/web/easy.css +357 -0
  194. cuju/render/web/export.html +23 -0
  195. cuju/render/web/feed.css +262 -0
  196. cuju/render/web/feed.html +28 -0
  197. cuju/render/web/flat.css +120 -0
  198. cuju/render/web/home.css +837 -0
  199. cuju/render/web/index.html +373 -0
  200. cuju/render/web/js/boot.js +50 -0
  201. cuju/render/web/js/broadcast/audio.js +2141 -0
  202. cuju/render/web/js/broadcast/celebrate.js +1238 -0
  203. cuju/render/web/js/broadcast/decisions.js +609 -0
  204. cuju/render/web/js/broadcast/hud.js +352 -0
  205. cuju/render/web/js/broadcast/show.js +3614 -0
  206. cuju/render/web/js/broadcast/transition.js +2231 -0
  207. cuju/render/web/js/broadcast/voices.js +153 -0
  208. cuju/render/web/js/capture.js +101 -0
  209. cuju/render/web/js/core/accessibility.js +90 -0
  210. cuju/render/web/js/core/api.js +429 -0
  211. cuju/render/web/js/core/comfort.js +135 -0
  212. cuju/render/web/js/core/i18n.js +705 -0
  213. cuju/render/web/js/core/icons.js +167 -0
  214. cuju/render/web/js/core/input-settings.js +156 -0
  215. cuju/render/web/js/core/keys.js +277 -0
  216. cuju/render/web/js/core/language-ui.js +125 -0
  217. cuju/render/web/js/core/menu.js +853 -0
  218. cuju/render/web/js/core/names.js +113 -0
  219. cuju/render/web/js/core/page-structure.js +110 -0
  220. cuju/render/web/js/core/scenarios.js +109 -0
  221. cuju/render/web/js/core/spoilers.js +236 -0
  222. cuju/render/web/js/core/typeahead.js +38 -0
  223. cuju/render/web/js/export/dom-picture.js +330 -0
  224. cuju/render/web/js/export/export-host.js +97 -0
  225. cuju/render/web/js/export/feed-dialog.js +191 -0
  226. cuju/render/web/js/export/feed-params.js +266 -0
  227. cuju/render/web/js/export/feed.js +363 -0
  228. cuju/render/web/js/export/mp4-mux.js +295 -0
  229. cuju/render/web/js/export/photo-core.js +313 -0
  230. cuju/render/web/js/export/photo.js +1314 -0
  231. cuju/render/web/js/export/safe-frame.js +59 -0
  232. cuju/render/web/js/export/size-fields.js +103 -0
  233. cuju/render/web/js/export/sizes.js +178 -0
  234. cuju/render/web/js/export/video-boot.js +55 -0
  235. cuju/render/web/js/export/video-browser.js +325 -0
  236. cuju/render/web/js/export/video-export.js +417 -0
  237. cuju/render/web/js/export/video-plan.js +207 -0
  238. cuju/render/web/js/export/video-range.js +185 -0
  239. cuju/render/web/js/export/webm-mux.js +198 -0
  240. cuju/render/web/js/feed-boot.js +151 -0
  241. cuju/render/web/js/feed-frame.js +101 -0
  242. cuju/render/web/js/help/doctor.js +488 -0
  243. cuju/render/web/js/help/firstrun.js +379 -0
  244. cuju/render/web/js/help/help-center.js +438 -0
  245. cuju/render/web/js/help/install-offer.js +164 -0
  246. cuju/render/web/js/help/install-state.js +104 -0
  247. cuju/render/web/js/help/lessons-core.js +207 -0
  248. cuju/render/web/js/help/lessons.js +463 -0
  249. cuju/render/web/js/help/palette.js +584 -0
  250. cuju/render/web/js/help/pwa.js +257 -0
  251. cuju/render/web/js/help/tour-core.js +241 -0
  252. cuju/render/web/js/help/tour.js +461 -0
  253. cuju/render/web/js/main.js +2513 -0
  254. cuju/render/web/js/pages/draw.js +188 -0
  255. cuju/render/web/js/pages/fieldpage.js +530 -0
  256. cuju/render/web/js/pages/home.js +521 -0
  257. cuju/render/web/js/pages/matches.js +779 -0
  258. cuju/render/web/js/pages/moments-ui.js +359 -0
  259. cuju/render/web/js/pages/queue-filters.js +63 -0
  260. cuju/render/web/js/pages/recording-groups.js +80 -0
  261. cuju/render/web/js/pages/recordings.js +1069 -0
  262. cuju/render/web/js/play/bench.js +347 -0
  263. cuju/render/web/js/play/challenges.js +1302 -0
  264. cuju/render/web/js/play/medals.js +127 -0
  265. cuju/render/web/js/play/play.js +1953 -0
  266. cuju/render/web/js/play/teasers.js +196 -0
  267. cuju/render/web/js/play/touch.js +350 -0
  268. cuju/render/web/js/play/tournament-layout.js +487 -0
  269. cuju/render/web/js/play/tournament-results.js +441 -0
  270. cuju/render/web/js/play/tournament-strict.js +560 -0
  271. cuju/render/web/js/play/tournament.js +2585 -0
  272. cuju/render/web/js/players/animate.js +972 -0
  273. cuju/render/web/js/players/clips.js +224 -0
  274. cuju/render/web/js/players/kaykit.js +162 -0
  275. cuju/render/web/js/players/kits.js +676 -0
  276. cuju/render/web/js/players/number-patch.js +82 -0
  277. cuju/render/web/js/players/players.js +2694 -0
  278. cuju/render/web/js/players/recolor.js +893 -0
  279. cuju/render/web/js/players/retarget.js +325 -0
  280. cuju/render/web/js/players/rig-numbers.js +174 -0
  281. cuju/render/web/js/presentation/animator.js +231 -0
  282. cuju/render/web/js/presentation/assign.js +85 -0
  283. cuju/render/web/js/presentation/behaviors.js +440 -0
  284. cuju/render/web/js/presentation/budget.js +209 -0
  285. cuju/render/web/js/presentation/catalog.js +1674 -0
  286. cuju/render/web/js/presentation/clocks.js +72 -0
  287. cuju/render/web/js/presentation/commands.js +301 -0
  288. cuju/render/web/js/presentation/contract.js +240 -0
  289. cuju/render/web/js/presentation/effects.js +904 -0
  290. cuju/render/web/js/presentation/flash.js +605 -0
  291. cuju/render/web/js/presentation/freeze-worklet.js +22 -0
  292. cuju/render/web/js/presentation/glsl.js +464 -0
  293. cuju/render/web/js/presentation/host.js +323 -0
  294. cuju/render/web/js/presentation/index.js +2066 -0
  295. cuju/render/web/js/presentation/loudness-worklet.js +235 -0
  296. cuju/render/web/js/presentation/loudness.js +740 -0
  297. cuju/render/web/js/presentation/moments.js +536 -0
  298. cuju/render/web/js/presentation/post.js +327 -0
  299. cuju/render/web/js/presentation/preview.js +180 -0
  300. cuju/render/web/js/presentation/protocol.js +174 -0
  301. cuju/render/web/js/presentation/reach.js +158 -0
  302. cuju/render/web/js/presentation/rigging.js +130 -0
  303. cuju/render/web/js/presentation/sandbox.js +157 -0
  304. cuju/render/web/js/presentation/shaders.js +235 -0
  305. cuju/render/web/js/presentation/stage.js +402 -0
  306. cuju/render/web/js/presentation/teamaudio.js +722 -0
  307. cuju/render/web/js/presentation/tracks.js +197 -0
  308. cuju/render/web/js/presentation/values.js +81 -0
  309. cuju/render/web/js/recording/annotation-data.js +203 -0
  310. cuju/render/web/js/recording/captions.js +491 -0
  311. cuju/render/web/js/recording/codec.js +185 -0
  312. cuju/render/web/js/recording/decode-worker.js +70 -0
  313. cuju/render/web/js/recording/decode.js +94 -0
  314. cuju/render/web/js/recording/episodes.js +46 -0
  315. cuju/render/web/js/recording/format.js +461 -0
  316. cuju/render/web/js/recording/live.js +348 -0
  317. cuju/render/web/js/recording/moments-query.js +59 -0
  318. cuju/render/web/js/recording/moments.js +149 -0
  319. cuju/render/web/js/recording/pose.js +86 -0
  320. cuju/render/web/js/recording/timeline.js +275 -0
  321. cuju/render/web/js/teams/art.js +50 -0
  322. cuju/render/web/js/teams/clip-editor.js +293 -0
  323. cuju/render/web/js/teams/editor.js +1979 -0
  324. cuju/render/web/js/teams/library.js +344 -0
  325. cuju/render/web/js/teams/painter.js +633 -0
  326. cuju/render/web/js/teams/surprise-ui.js +195 -0
  327. cuju/render/web/js/teams/surprise.js +497 -0
  328. cuju/render/web/js/teams/team-check.js +127 -0
  329. cuju/render/web/js/watch/annotations.js +270 -0
  330. cuju/render/web/js/watch/flat.js +366 -0
  331. cuju/render/web/js/watch/grid.js +436 -0
  332. cuju/render/web/js/watch/pitch2d.js +289 -0
  333. cuju/render/web/js/watch/player-tools.js +192 -0
  334. cuju/render/web/js/watch/presets.js +201 -0
  335. cuju/render/web/js/world/arenas.js +997 -0
  336. cuju/render/web/js/world/ball.js +204 -0
  337. cuju/render/web/js/world/cameras.js +982 -0
  338. cuju/render/web/js/world/crowd.js +376 -0
  339. cuju/render/web/js/world/field.js +227 -0
  340. cuju/render/web/js/world/framing.js +145 -0
  341. cuju/render/web/js/world/occlusion.js +48 -0
  342. cuju/render/web/js/world/pitch.js +703 -0
  343. cuju/render/web/js/world/scene.js +395 -0
  344. cuju/render/web/js/world/sky.js +325 -0
  345. cuju/render/web/js/world/stadium.js +1262 -0
  346. cuju/render/web/js/world/vendor-sky.js +367 -0
  347. cuju/render/web/js/world/weather.js +264 -0
  348. cuju/render/web/languages/en.json +4750 -0
  349. cuju/render/web/lessons.json +137 -0
  350. cuju/render/web/manifest.webmanifest +23 -0
  351. cuju/render/web/style.css +8399 -0
  352. cuju/render/web/sw.js +235 -0
  353. cuju/render/web/vendor/fabric/LICENSE +16 -0
  354. cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
  355. cuju/render/web/vendor/lucide/LICENSE +43 -0
  356. cuju/render/web/vendor/lucide/icons.js +141 -0
  357. cuju/render/web/vendor/three/LICENSE +21 -0
  358. cuju/render/web/vendor/three/build/three.core.js +60586 -0
  359. cuju/render/web/vendor/three/build/three.module.js +19719 -0
  360. cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
  361. cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
  362. cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
  363. cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
  364. cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
  365. cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
  366. cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
  367. cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
  368. cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
  369. cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
  370. cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
  371. cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
  372. cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
  373. cuju_render-0.1.0.dist-info/METADATA +116 -0
  374. cuju_render-0.1.0.dist-info/RECORD +382 -0
  375. cuju_render-0.1.0.dist-info/WHEEL +4 -0
  376. cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
  377. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
  378. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
  379. cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
  380. cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
  381. cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
  382. cuju_render-0.1.0.dist-info/licenses/NOTICE +35 -0
@@ -0,0 +1,2742 @@
1
+ # Copyright 2026 The cuju Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The rules of the renderer's tournaments, their formats, draws, schedules and standings.
5
+
6
+ A tournament is a configuration, its entrants, its seed and a chain of stages, together with the
7
+ results of the matches played so far. `evaluate` replays the whole tournament from those two
8
+ things alone. It draws the groups and seeds, schedules every match whose sides are known, settles
9
+ every decided tie and computes the tables, so the same configuration and seed always give the same
10
+ draw, the same matches and the same match seeds, and a tournament resumes from its results after
11
+ any stop. Each match has a stable key, such as `0/sf1/2` for the second leg of the first
12
+ semi-final of the first stage, and its seed comes from the tournament seed and that key.
13
+
14
+ The module uses Python's standard library alone, so that the page's mock server can load it from
15
+ its file. Whatever needs the team registry takes it as an argument, and the server passes it in.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import hashlib
22
+ import math
23
+ import random
24
+ from collections.abc import Callable
25
+ from typing import Any
26
+
27
+ from .._text import messages as _messages
28
+
29
+ # The formats of a stage.
30
+ FORMATS = (
31
+ 'knockout',
32
+ 'double_elimination',
33
+ 'league',
34
+ 'groups',
35
+ 'swiss',
36
+ 'ladder',
37
+ 'king_of_the_hill',
38
+ 'gauntlet',
39
+ )
40
+ # The criteria that order a table after points, in the order a stage names them.
41
+ TIEBREAKERS = (
42
+ 'goal_difference',
43
+ 'goals_for',
44
+ 'goals_against',
45
+ 'head_to_head',
46
+ 'wins',
47
+ 'away_goals',
48
+ 'fair_play',
49
+ 'buchholz',
50
+ 'lots',
51
+ )
52
+ # The ways of settling a knockout tie whose matches end level, in the order a stage names them.
53
+ SETTLEMENTS = ('away_goals', 'replay', 'decider', 'lots')
54
+ # How the first stage orders the entrants, and how a later stage orders those who advance.
55
+ SEEDINGS = ('random', 'manual', 'rating')
56
+ STAGE_SEEDINGS = ('standing', 'random', 'rating')
57
+ KINDS = ('team', 'builtin', 'person', 'random')
58
+ MAX_ENTRANTS = 256
59
+ MAX_STAGES = 8
60
+ MAX_LEGS = 10
61
+ # The most legs that a league's pairs play once more legs are added to a stage that has begun.
62
+ MAX_ADDED_LEGS = 90
63
+ # The entrants that a tournament or a preset adds to those a person enters, at most.
64
+ MAX_INCLUDED = 8
65
+ MAX_CONCURRENCY = 8
66
+ # The rating of an entrant that has played no tournament match, and how far one match moves it.
67
+ BASE_RATING = 1500.0
68
+ RATING_STEP = 20.0
69
+ # A match lasts at least and at most this many steps when a stage sets its length. A batch keeps
70
+ # 512 seconds of history, so 5000 steps of 10 ticks fit within it.
71
+ MIN_LENGTH, MAX_LENGTH = 50, 5000
72
+
73
+ # Every setting of a stage with its default. A tournament's `defaults` and each stage override
74
+ # them in turn.
75
+ STAGE_DEFAULTS: dict[str, Any] = {
76
+ 'format': 'knockout',
77
+ 'name': '',
78
+ 'scenario': None,
79
+ 'length': None,
80
+ 'ability': None,
81
+ 'legs': 1,
82
+ 'final_legs': None,
83
+ 'settle': ['replay', 'lots'],
84
+ 'replays': 1,
85
+ 'decider_scenario': 'academy_run_to_score_with_keeper',
86
+ 'decider_length': None,
87
+ 'points': {'win': 3, 'draw': 1, 'loss': 0},
88
+ 'tiebreakers': ['goal_difference', 'goals_for', 'head_to_head', 'wins', 'fair_play', 'lots'],
89
+ 'seeding': 'standing',
90
+ 'bracket': 'byes',
91
+ 'bracket_size': None,
92
+ 'until': 1,
93
+ 'third_place': False,
94
+ 'grand_final_reset': True,
95
+ 'cycles': 1,
96
+ 'groups': None,
97
+ 'group_size': 4,
98
+ 'group_sizes': None,
99
+ 'pots': None,
100
+ 'qualifiers': 2,
101
+ 'best_position': None,
102
+ 'best_count': 0,
103
+ 'rounds': None,
104
+ 'laps': 1,
105
+ 'bye_points': 'win',
106
+ 'draws': None,
107
+ 'advance': None,
108
+ }
109
+ # The settings of a tournament besides its stages.
110
+ TOURNAMENT_KEYS = (
111
+ 'name',
112
+ 'seed',
113
+ 'entrants',
114
+ 'seeding',
115
+ 'concurrency',
116
+ 'forfeit_score',
117
+ 'ability',
118
+ 'defaults',
119
+ 'stages',
120
+ 'preset',
121
+ 'strict',
122
+ 'preflight',
123
+ 'include',
124
+ )
125
+ # The limits of strict rules with their defaults, in seconds but for the mean of a call, in
126
+ # milliseconds, the size of a team's folder, in megabytes, which the check of a team holds it to,
127
+ # and the threads of each team's process. Each limit takes a value from the first to the second of
128
+ # its range.
129
+ STRICT_DEFAULTS: dict[str, Any] = {
130
+ 'threads': 1,
131
+ 'load_seconds': 30.0,
132
+ 'first_call_seconds': 10.0,
133
+ 'call_seconds': 1.0,
134
+ 'reset_seconds': 5.0,
135
+ 'mean_call_ms': 20.0,
136
+ 'folder_mb': 100.0,
137
+ 'memory_mb': 2048.0,
138
+ 'match_seconds': 120.0,
139
+ }
140
+ STRICT_RANGES: dict[str, tuple[float, float]] = {
141
+ 'threads': (1, 64),
142
+ 'load_seconds': (1, 600),
143
+ 'first_call_seconds': (0.1, 600),
144
+ 'call_seconds': (0.01, 60),
145
+ 'reset_seconds': (0.1, 600),
146
+ 'mean_call_ms': (0.1, 10000),
147
+ 'folder_mb': (1, 10000),
148
+ 'memory_mb': (64, 65536),
149
+ 'match_seconds': (1, 36000),
150
+ }
151
+ # The switches of strict rules with their defaults. `frozen` plays each team from a read only copy
152
+ # of its folder and refuses a team whose files changed since the pre-flight, `gpu` lets a team's
153
+ # process see the GPUs, and `deterministic` refuses at the pre-flight a team whose answers differ
154
+ # when the same steps play again from the same seed, which is otherwise a warning.
155
+ STRICT_FLAGS: dict[str, bool] = {'frozen': True, 'gpu': False, 'deterministic': False}
156
+ _ROUND_NAMES = {2: 'final', 4: 'semi_finals', 8: 'quarter_finals'}
157
+ _TIE_NAMES = {4: 'semi_final', 8: 'quarter_final'}
158
+ # The stage names that the rules and the shipped presets give, by their English text, each with
159
+ # the last part of the id of its label, so a stage of a preset shows its name in any language.
160
+ STAGE_NAMES = {
161
+ 'Knockout': 'knockout',
162
+ 'Double elimination': 'double_elimination',
163
+ 'League': 'league',
164
+ 'Groups': 'groups',
165
+ 'Swiss': 'swiss',
166
+ 'Ladder': 'ladder',
167
+ 'King of the hill': 'king_of_the_hill',
168
+ 'Gauntlet': 'gauntlet',
169
+ 'Group stage': 'group_stage',
170
+ 'Knockout stage': 'knockout_stage',
171
+ 'League phase': 'league_phase',
172
+ 'Playoffs': 'playoffs',
173
+ 'Round robin': 'round_robin',
174
+ 'Cup': 'cup',
175
+ 'Regular season': 'regular_season',
176
+ }
177
+
178
+
179
+ class TournamentError(ValueError):
180
+ """Raised for a configuration that cannot make a tournament, with the reason."""
181
+
182
+
183
+ class TournamentMessageError(_messages.Message, TournamentError):
184
+ """A `TournamentError` whose text is a message of the server, which the page translates."""
185
+
186
+ _wrapper = True
187
+
188
+
189
+ # The names and labels that the rules make.
190
+
191
+
192
+ def _label(name: str, /, **values: Any) -> dict[str, Any]:
193
+ """Returns a label of the tournaments, such as a round or a generated entrant name.
194
+
195
+ Args:
196
+ name: The id of its message after `server.tournament.label.`, such as `semi_final`.
197
+ values: The values of its placeholders, each text, a number or a label of its own.
198
+ """
199
+ return _messages.label(f'server.tournament.label.{name}', **values)
200
+
201
+
202
+ def text_of(value: Any) -> str:
203
+ """Returns the English text of a name, a label or a plain text."""
204
+ if isinstance(value, dict):
205
+ return str(value.get('text', ''))
206
+ return '' if value is None else str(value)
207
+
208
+
209
+ def label_of(holder: dict[str, Any], field: str = 'name') -> Any:
210
+ """Returns the name that an object holds in `field`, its label when it has a valid one.
211
+
212
+ An object keeps a label as `field`, `field_id` and `field_values`, as `_messages.named` gives
213
+ them, and a name that a person gave, or one of a file of before, as the text alone.
214
+ """
215
+ text = holder.get(field)
216
+ if isinstance(text, str) and holder.get(f'{field}_id') is not None:
217
+ found = {'id': holder[f'{field}_id'], 'text': text, 'values': holder.get(f'{field}_values')}
218
+ if _messages.valid_label(found):
219
+ return found
220
+ return text
221
+
222
+
223
+ def set_named(holder: dict[str, Any], field: str, value: Any) -> None:
224
+ """Keeps a name in `field` of an object, a label with its id and values beside the text."""
225
+ holder.pop(f'{field}_id', None)
226
+ holder.pop(f'{field}_values', None)
227
+ holder.update(_messages.named(field, value))
228
+
229
+
230
+ def named_from(field: str, holder: dict[str, Any], source: str = 'name') -> dict[str, Any]:
231
+ """Returns the fields of the name that `holder` keeps in `source`, under the name `field`."""
232
+ return _messages.named(field, label_of(holder, source))
233
+
234
+
235
+ # Presets of formats that ship with the renderer. A preset holds everything but the entrants,
236
+ # the seed and the name, and each one adapts to the number of entrants where its format allows.
237
+ # Each draws its first stage at random from the tournament seed, so a new tournament's groups and
238
+ # bracket come out of a fair draw that the same seed repeats. Seeding by hand or by rating stays a
239
+ # choice of the person, and a file of before keeps the seeding it was drawn with.
240
+ PRESETS: dict[str, dict[str, Any]] = {
241
+ 'world_cup': {
242
+ 'title': 'World Cup',
243
+ 'description': 'Groups of four play once each, the best two of each group go on to a '
244
+ 'knockout of single matches with a third place match.',
245
+ 'seeding': 'random',
246
+ 'defaults': {'settle': ['decider', 'lots']},
247
+ 'stages': [
248
+ {'format': 'groups', 'name': 'Group stage', 'group_size': 4, 'qualifiers': 2},
249
+ {'format': 'knockout', 'name': 'Knockout stage', 'third_place': True},
250
+ ],
251
+ },
252
+ 'euro': {
253
+ 'title': 'European Championship',
254
+ 'description': 'Groups of four, the best two of each group and enough of the best '
255
+ 'third places to fill a knockout bracket.',
256
+ 'seeding': 'random',
257
+ 'defaults': {'settle': ['decider', 'lots']},
258
+ 'stages': [
259
+ {
260
+ 'format': 'groups',
261
+ 'name': 'Group stage',
262
+ 'group_size': 4,
263
+ 'qualifiers': 2,
264
+ 'best_position': 3,
265
+ 'best_count': 'fill',
266
+ },
267
+ {'format': 'knockout', 'name': 'Knockout stage'},
268
+ ],
269
+ },
270
+ 'champions_league': {
271
+ 'title': 'Champions League',
272
+ 'description': 'Groups of four play home and away, the best two go on to two legged '
273
+ 'ties settled by away goals, with a single match final.',
274
+ 'seeding': 'random',
275
+ 'defaults': {'settle': ['away_goals', 'decider', 'lots']},
276
+ 'stages': [
277
+ {
278
+ 'format': 'groups',
279
+ 'name': 'Group stage',
280
+ 'group_size': 4,
281
+ 'qualifiers': 2,
282
+ 'cycles': 2,
283
+ },
284
+ {'format': 'knockout', 'name': 'Knockout stage', 'legs': 2, 'final_legs': 1},
285
+ ],
286
+ },
287
+ 'league_phase': {
288
+ 'title': 'League phase and playoffs',
289
+ 'description': 'A Swiss league phase ranks everyone, and the best eight play a '
290
+ 'knockout of two legged ties.',
291
+ 'seeding': 'random',
292
+ 'defaults': {'settle': ['away_goals', 'decider', 'lots']},
293
+ 'stages': [
294
+ {'format': 'swiss', 'name': 'League phase', 'advance': 8},
295
+ {'format': 'knockout', 'name': 'Playoffs', 'legs': 2, 'final_legs': 1},
296
+ ],
297
+ },
298
+ 'classic_league': {
299
+ 'title': 'Classic league',
300
+ 'description': 'Everyone plays everyone twice, home and away, for three points a win.',
301
+ 'seeding': 'random',
302
+ 'stages': [{'format': 'league', 'name': 'League', 'cycles': 2}],
303
+ },
304
+ 'round_robin': {
305
+ 'title': 'Round robin',
306
+ 'description': 'Everyone plays everyone once.',
307
+ 'seeding': 'random',
308
+ 'stages': [{'format': 'league', 'name': 'Round robin'}],
309
+ },
310
+ 'quick_cup': {
311
+ 'title': 'Quick 8 team cup',
312
+ 'description': 'A knockout of short single matches, made for eight entrants, with '
313
+ 'byes for any other number and drawn ties settled by lots.',
314
+ 'seeding': 'random',
315
+ 'defaults': {'length': 600, 'settle': ['lots']},
316
+ 'stages': [{'format': 'knockout', 'name': 'Cup'}],
317
+ },
318
+ 'swiss_16': {
319
+ 'title': 'Swiss of 16',
320
+ 'description': 'Five rounds of the Swiss system, each pairing entrants with the same '
321
+ 'score who have not met, ranked by points and Buchholz.',
322
+ 'seeding': 'random',
323
+ 'defaults': {
324
+ 'tiebreakers': ['buchholz', 'goal_difference', 'goals_for', 'head_to_head', 'lots']
325
+ },
326
+ 'stages': [{'format': 'swiss', 'name': 'Swiss', 'rounds': 5}],
327
+ },
328
+ 'double_elimination': {
329
+ 'title': 'Double elimination',
330
+ 'description': 'A winners and a losers bracket, so an entrant leaves after a second '
331
+ 'defeat, with a deciding match when the losers bracket wins the grand final.',
332
+ 'seeding': 'random',
333
+ 'stages': [{'format': 'double_elimination', 'name': 'Double elimination'}],
334
+ },
335
+ 'league_and_playoffs': {
336
+ 'title': 'League and playoffs',
337
+ 'description': 'A round robin league, then the best four play semi-finals and a final.',
338
+ 'seeding': 'random',
339
+ 'stages': [
340
+ {'format': 'league', 'name': 'Regular season', 'advance': 4},
341
+ {'format': 'knockout', 'name': 'Playoffs'},
342
+ ],
343
+ },
344
+ 'play_in_cup': {
345
+ 'title': 'Cup with a play-in',
346
+ 'description': 'A knockout whose lowest seeds first play a play-in round down to a full '
347
+ 'bracket, with a third place match.',
348
+ 'seeding': 'random',
349
+ 'stages': [
350
+ {'format': 'knockout', 'name': 'Cup', 'bracket': 'play_in', 'third_place': True}
351
+ ],
352
+ },
353
+ 'ladder': {
354
+ 'title': 'Ladder',
355
+ 'description': 'Each round, neighbours on the ladder meet, and a challenger who wins '
356
+ 'takes the place above.',
357
+ 'seeding': 'random',
358
+ 'stages': [{'format': 'ladder', 'name': 'Ladder'}],
359
+ },
360
+ 'king_of_the_hill': {
361
+ 'title': 'King of the hill',
362
+ 'description': 'The top seed starts as king, and every challenger in turn tries to '
363
+ 'take the crown, which stays with the king on a draw.',
364
+ 'seeding': 'random',
365
+ 'stages': [{'format': 'king_of_the_hill', 'name': 'King of the hill'}],
366
+ },
367
+ 'gauntlet': {
368
+ 'title': 'Gauntlet',
369
+ 'description': 'The lowest seeds meet first, and the winner climbs to meet each higher '
370
+ 'seed in turn, up to the top seed.',
371
+ 'seeding': 'random',
372
+ 'defaults': {'draws': 'settle'},
373
+ 'stages': [{'format': 'gauntlet', 'name': 'Gauntlet'}],
374
+ },
375
+ }
376
+
377
+
378
+ # Small helpers.
379
+
380
+
381
+ def _integer(value: Any) -> bool:
382
+ return isinstance(value, int) and not isinstance(value, bool)
383
+
384
+
385
+ def _number(value: Any) -> bool:
386
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
387
+
388
+
389
+ def _power(value: int) -> bool:
390
+ return value >= 1 and value & (value - 1) == 0
391
+
392
+
393
+ def _next_power(value: int) -> int:
394
+ return 1 << max(0, (value - 1).bit_length())
395
+
396
+
397
+ def _previous_power(value: int) -> int:
398
+ return 1 << (value.bit_length() - 1)
399
+
400
+
401
+ def _rng(seed: int, *parts: Any) -> random.Random:
402
+ """Returns a generator of its own for one purpose, such as the draw of a stage's groups."""
403
+ text = '/'.join([str(seed), *map(str, parts)])
404
+ return random.Random(hashlib.sha256(text.encode()).digest())
405
+
406
+
407
+ def match_seed(seed: int, key: str) -> int:
408
+ """Returns the seed of the match `key` of a tournament, from 0 to 2**31 - 1."""
409
+ digest = hashlib.sha256(f'{seed}/match/{key}'.encode()).digest()
410
+ return int.from_bytes(digest[:4], 'little') % 2**31
411
+
412
+
413
+ def group_name(index: int) -> str:
414
+ """Returns the letter of a group, A to Z, then AA, AB and so on."""
415
+ letters = ''
416
+ index += 1
417
+ while index:
418
+ index, rest = divmod(index - 1, 26)
419
+ letters = chr(65 + rest) + letters
420
+ return letters
421
+
422
+
423
+ def bracket_order(size: int) -> list[int]:
424
+ """Returns the seed of each position of a bracket of `size`, so seed 1 meets seed `size`."""
425
+ order = [0]
426
+ while len(order) < size:
427
+ width = len(order) * 2
428
+ order = [seed for first in order for seed in (first, width - 1 - first)]
429
+ return order
430
+
431
+
432
+ def round_robin(members: list[Any]) -> list[list[tuple[Any, Any]]]:
433
+ """Returns the rounds of a round robin by the circle method, with `None` as the rest.
434
+
435
+ Each member meets every other once, plays once a round, and the home side alternates.
436
+ """
437
+ players = list(members) + ([None] if len(members) % 2 else [])
438
+ count = len(players)
439
+ rounds = []
440
+ for index in range(count - 1):
441
+ pairs = []
442
+ for slot in range(count // 2):
443
+ first, second = players[slot], players[count - 1 - slot]
444
+ if (index + slot) % 2:
445
+ first, second = second, first
446
+ pairs.append((first, second))
447
+ rounds.append(pairs)
448
+ players = [players[0], players[-1], *players[1:-1]]
449
+ return rounds
450
+
451
+
452
+ class Unknown:
453
+ """A side of a tie that an earlier tie decides, such as the winner of the first semi-final."""
454
+
455
+ def __init__(self, label: dict[str, Any]) -> None:
456
+ self.label = label
457
+
458
+ def __repr__(self) -> str:
459
+ return f'Unknown({text_of(self.label)!r})'
460
+
461
+
462
+ def _side(value: Any) -> Any:
463
+ """Returns a side as the view holds it, an entrant, `None` for a bye, or where it comes from.
464
+
465
+ A side still to come holds the English text in `from`, with the id and values of its label
466
+ beside it, so the page names it in its own language.
467
+ """
468
+ return _messages.named('from', value.label) if isinstance(value, Unknown) else value
469
+
470
+
471
+ # The configuration.
472
+
473
+
474
+ def _field(name: str, /, **values: Any) -> dict[str, Any]:
475
+ """Returns the name of a setting in a message, the label `server.tournament.field.<name>`."""
476
+ return _messages.label(f'server.tournament.field.{name}', **values)
477
+
478
+
479
+ def _where(index: int) -> dict[str, Any]:
480
+ """Returns the stage of index `index` as it reads in a message, `stage 1` for the first."""
481
+ return _messages.label('server.tournament.where', number=index + 1)
482
+
483
+
484
+ def _plain_text(value: Any, name: Any, longest: int = 80) -> str:
485
+ if not isinstance(value, str):
486
+ raise TournamentMessageError('server.tournament.text', name=name, value=repr(value))
487
+ text = ' '.join(''.join(ch for ch in value if ch.isprintable()).split())
488
+ if not text or len(text) > longest:
489
+ raise TournamentMessageError(
490
+ 'server.tournament.text_length', name=name, most=longest, value=repr(value)
491
+ )
492
+ return text
493
+
494
+
495
+ def _choice(value: Any, choices: tuple[str, ...], name: Any) -> str:
496
+ if value not in choices:
497
+ raise TournamentMessageError(
498
+ 'server.tournament.choice', name=name, choices=', '.join(choices), value=repr(value)
499
+ )
500
+ return value
501
+
502
+
503
+ def _count(value: Any, name: Any, lowest: int, highest: int) -> int:
504
+ if not _integer(value) or not lowest <= value <= highest:
505
+ # A range of one value reads as that value.
506
+ if lowest == highest:
507
+ raise TournamentMessageError(
508
+ 'server.tournament.count_exact', name=name, lowest=lowest, value=repr(value)
509
+ )
510
+ raise TournamentMessageError(
511
+ 'server.tournament.count', name=name, lowest=lowest, highest=highest, value=repr(value)
512
+ )
513
+ return value
514
+
515
+
516
+ def name_key(name: Any) -> str:
517
+ """Returns what two entrant names share when they read as one, trimmed and without case.
518
+
519
+ Each character is lowered, raised and lowered again, which folds such letters as the German
520
+ sharp s, and which the page's `nameKey` in `web/js/play/tournament-layout.js` does alike,
521
+ character by character, so both give the same key.
522
+ """
523
+ text = ' '.join(text_of(name).split())
524
+ return ''.join(character.lower().upper().lower() for character in text)
525
+
526
+
527
+ def free_name(base: str, taken: set[str] | list[str], longest: int | None = 40) -> str:
528
+ """Returns `base`, or `base` with the first number from 2 whose name no entrant holds.
529
+
530
+ `taken` holds the keys of the names in use, as `name_key` gives them. A name keeps to
531
+ `longest` characters, the most a given name holds, by shortening `base` before its number.
532
+ The page's `freeName` proposes the same name when a person adds a team that is already an
533
+ entrant.
534
+ """
535
+ base = ' '.join(base.split())
536
+ if longest is not None:
537
+ base = base[:longest].rstrip()
538
+ number = 2
539
+ name = base
540
+ while name_key(name) in taken:
541
+ suffix = f' {number}'
542
+ cut = base if longest is None else base[: longest - len(suffix)].rstrip()
543
+ name = f'{cut}{suffix}'
544
+ number += 1
545
+ return name
546
+
547
+
548
+ def check_ability(value: Any, message: str, /, **values: Any) -> float | None:
549
+ """Returns an ability from 0 to 1 to four decimals, or `None` when it is unset.
550
+
551
+ Args:
552
+ value: The ability, a number or `None`.
553
+ message: The id of the message that refuses any other value, which names it `ability`.
554
+ values: The other values of that message, such as the number of the entrant.
555
+ """
556
+ if value is None:
557
+ return None
558
+ if not _number(value) or not 0 <= value <= 1:
559
+ raise TournamentMessageError(message, ability=repr(value), **values)
560
+ return round(float(value), 4)
561
+
562
+
563
+ def match_ability(
564
+ entrant: dict[str, Any], stage: dict[str, Any], standard: float | None
565
+ ) -> float | None:
566
+ """Returns the ability that the built-in AI plays with on an entrant's side in a stage.
567
+
568
+ The entrant's own ability comes first, then the stage's, then the tournament's standard, and
569
+ `None` leaves the scenario's own. It steers every player of the side that the built-in AI
570
+ controls, the whole side of a built-in AI entrant and the teammates of a team or a person.
571
+ The page's `matchAbility` in `web/js/play/tournament-layout.js` gives the same.
572
+ """
573
+ for value in (entrant.get('ability'), stage.get('ability'), standard):
574
+ if value is not None:
575
+ return value
576
+ return None
577
+
578
+
579
+ def shown_ability(entrant: dict[str, Any], standard: float | None) -> float | None:
580
+ """Returns the ability to show beside an entrant's name, or `None` when there is none.
581
+
582
+ An entrant shows its own ability where it differs from the tournament's standard, except a
583
+ built-in AI whose name the rules made, which already holds it, as in Built-in AI 0.80. The
584
+ page's `shownAbility` gives the same.
585
+ """
586
+ own = entrant.get('ability')
587
+ if own is None or own == standard:
588
+ return None
589
+ if entrant.get('kind') == 'builtin' and entrant.get('name_id') is not None:
590
+ return None
591
+ return own
592
+
593
+
594
+ def _given_name(entrant: dict[str, Any]) -> str | None:
595
+ """Returns the name that a person gave an entrant, trimmed, or `None` when it has none."""
596
+ if entrant.get('name') in (None, ''):
597
+ return None
598
+ return _plain_text(entrant['name'], _field('entrant_name'), 40)
599
+
600
+
601
+ def _entrant_name(entrant: dict[str, Any], team: dict[str, Any] | None) -> Any:
602
+ """Returns the name of an entrant, the one a person gave as text, else a label or the team's."""
603
+ given = _given_name(entrant)
604
+ if given is not None:
605
+ return given
606
+ if entrant['kind'] == 'team':
607
+ return label_of(team)
608
+ if entrant['kind'] == 'builtin':
609
+ ability = entrant.get('ability')
610
+ return (
611
+ _label('builtin')
612
+ if ability is None
613
+ else _label('builtin_ability', ability=f'{ability:.2f}')
614
+ )
615
+ if entrant['kind'] == 'random':
616
+ return _label('random')
617
+ raise TournamentMessageError('server.tournament.person_names')
618
+
619
+
620
+ def _name_entrants(entrants: list[dict[str, Any]], bases: list[tuple[Any, bool]]) -> None:
621
+ """Gives each entrant its name, the one a person gave or one that the rules make.
622
+
623
+ Two given names that read as one refuse the tournament. A made name, such as that of a team
624
+ or of the built-in AI at an ability, takes the first free number from 2 when a given name or
625
+ an earlier made one holds it, as part of its label when the rules made it.
626
+ """
627
+ given: dict[str, int] = {}
628
+ for index, (base, own) in enumerate(bases):
629
+ if not own:
630
+ continue
631
+ key = name_key(base)
632
+ if key in given:
633
+ raise TournamentMessageError(
634
+ 'server.tournament.name_taken', entrant=index + 1, name=base, other=given[key] + 1
635
+ )
636
+ given[key] = index
637
+ names = set(given)
638
+ for entrant, (base, own) in zip(entrants, bases, strict=True):
639
+ name = base
640
+ if not own:
641
+ if isinstance(base, dict):
642
+ suffix = 2
643
+ while name_key(name) in names:
644
+ name = _label('numbered', name=base, number=suffix)
645
+ suffix += 1
646
+ elif name_key(name) in names:
647
+ name = free_name(base, names, None)
648
+ names.add(name_key(name))
649
+ set_named(entrant, 'name', name)
650
+
651
+
652
+ # The key of the random entrant, whose colon no identifier of a team holds, so it never names a
653
+ # team, as the keys of the built-in AI, such as builtin:0.50, never do either.
654
+ RANDOM_KEY = 'random:uniform'
655
+ # The colors of the random entrant, its shirt and its trim.
656
+ _RANDOM_KIT = ([120, 120, 128], [255, 214, 0])
657
+
658
+
659
+ def entrant_key(raw: dict[str, Any]) -> str | None:
660
+ """Returns the key of an entrant as given, before its check, or `None` when it has none yet.
661
+
662
+ The key is the one that `check_entrants` gives, the team for a team, the built-in AI at its
663
+ ability, `random` for the random entrant and the name of a person.
664
+ """
665
+ kind = raw.get('kind', 'team')
666
+ if kind == 'team':
667
+ return str(raw.get('team'))
668
+ if kind == 'builtin':
669
+ ability = raw.get('ability')
670
+ return f'builtin:{"scenario" if not _number(ability) else f"{float(ability):.2f}"}'
671
+ if kind == 'random':
672
+ return RANDOM_KEY
673
+ name = raw.get('name')
674
+ return f'person:{" ".join(name.split())}' if isinstance(name, str) else None
675
+
676
+
677
+ def check_entrants(
678
+ entrants: Any, teams: list[dict[str, Any]], ratings: dict[str, float] | None = None
679
+ ) -> list[dict[str, Any]]:
680
+ """Returns the entrants of a tournament in full, each with its kind, team, name and rating.
681
+
682
+ A team of the registry may enter more than once, each entrant under a name of its own. The
683
+ names that people give are unique within the tournament, compared trimmed and without case,
684
+ and each keeps its name as given. An entrant without a given name takes the name of its team
685
+ or kind, with the first free number from 2 when a given name or an earlier entrant already
686
+ holds it, so a team entered twice plays as Rex and Rex 2.
687
+
688
+ Args:
689
+ entrants: A list of entrants, each `{'kind': 'team', 'team': identifier}`, `{'kind':
690
+ 'builtin', 'ability': 0.5}`, `{'kind': 'random'}` for the random entrant, which plays
691
+ one player with actions drawn at random, or `{'kind': 'person', 'name': 'Ana'}`, with an
692
+ optional
693
+ `name`, `rating` and, for a person, `teammates`, a registry team of dynamic assignment
694
+ or `builtin`.
695
+ teams: The registry, as `_teams.teams` returns it.
696
+ ratings: The ratings that earlier tournaments gave, by entrant key. The entrants of one
697
+ team share its key, and so its rating.
698
+
699
+ Raises:
700
+ TournamentError: If an entrant is unknown or invalid, two given names are the same, or
701
+ there are fewer than 2 or more than 256.
702
+ """
703
+ if not isinstance(entrants, list) or not 2 <= len(entrants) <= MAX_ENTRANTS:
704
+ count = len(entrants) if isinstance(entrants, list) else entrants
705
+ raise TournamentMessageError(
706
+ 'server.tournament.entrants', most=MAX_ENTRANTS, count=repr(count)
707
+ )
708
+ registry = {team['id']: team for team in teams}
709
+ builtin = next((team for team in teams if team.get('kind') == 'builtin'), None)
710
+ ratings = ratings or {}
711
+ result: list[dict[str, Any]] = []
712
+ bases: list[tuple[Any, bool]] = []
713
+ for index, raw in enumerate(entrants):
714
+ if not isinstance(raw, dict):
715
+ raise TournamentMessageError(
716
+ 'server.tournament.entrant_object', entrant=index + 1, value=repr(raw)
717
+ )
718
+ unknown = set(raw) - {'kind', 'team', 'name', 'ability', 'rating', 'teammates'}
719
+ if unknown:
720
+ raise TournamentMessageError(
721
+ 'server.tournament.entrant_fields', entrant=index + 1, fields=sorted(unknown)
722
+ )
723
+ kind = _choice(raw.get('kind', 'team'), KINDS, _field('entrant_kind', number=index + 1))
724
+ entrant: dict[str, Any] = {'kind': kind}
725
+ # Every kind may carry an ability, which the built-in AI players of its side play with.
726
+ ability = check_ability(raw.get('ability'), 'server.tournament.ability', entrant=index + 1)
727
+ if kind == 'team':
728
+ team = registry.get(raw.get('team'))
729
+ if team is None or team.get('kind') == 'builtin':
730
+ raise TournamentMessageError(
731
+ 'server.tournament.entrant_team', entrant=index + 1, team=repr(raw.get('team'))
732
+ )
733
+ entrant.update(team=team['id'], format=team.get('format'), key=team['id'])
734
+ elif kind == 'builtin':
735
+ team = builtin
736
+ entrant.update(team='builtin', format=None)
737
+ entrant['key'] = f'builtin:{"scenario" if ability is None else f"{ability:.2f}"}'
738
+ elif kind == 'random':
739
+ # The random entrant plays one player of any scenario, and its teammates are the
740
+ # built-in AI's, so it keeps the colors of no team of the registry.
741
+ team = None
742
+ entrant.update(team='random-actions', format=None, key=RANDOM_KEY)
743
+ else:
744
+ mates = raw.get('teammates', 'builtin')
745
+ team = registry.get(mates) if mates != 'builtin' else builtin
746
+ if mates != 'builtin' and (team is None or team.get('kind') == 'builtin'):
747
+ raise TournamentMessageError('server.tournament.teammates', mates=repr(mates))
748
+ entrant.update(team=mates, format=None if mates == 'builtin' else team.get('format'))
749
+ base = _entrant_name({**raw, 'kind': kind, 'ability': ability}, team)
750
+ if kind == 'person':
751
+ entrant['key'] = f'person:{base}'
752
+ # The name comes once every entrant is known, since a made name avoids every given one.
753
+ entrant['name'] = None
754
+ bases.append((base, _given_name(raw) is not None))
755
+ rating = raw.get('rating')
756
+ if rating is not None and (not _number(rating) or not 0 <= rating <= 10000):
757
+ raise TournamentMessageError('server.tournament.rating', rating=repr(rating))
758
+ entrant['rating'] = float(
759
+ rating if rating is not None else ratings.get(entrant['key'], BASE_RATING)
760
+ )
761
+ entrant['rated'] = rating is not None
762
+ kit = (team or {}).get('style', {}).get('kit', {})
763
+ primary, secondary = _RANDOM_KIT if kind == 'random' else ([255, 111, 0], [255, 255, 255])
764
+ entrant['color'] = list(kit.get('primary') or (team or {}).get('color') or primary)
765
+ entrant['secondary'] = list(
766
+ kit.get('secondary') or (team or {}).get('secondary') or secondary
767
+ )
768
+ entrant['ability'] = ability
769
+ result.append(entrant)
770
+ _name_entrants(result, bases)
771
+ return result
772
+
773
+
774
+ def _merge(stage: dict[str, Any], defaults: dict[str, Any], index: int) -> dict[str, Any]:
775
+ if not isinstance(stage, dict):
776
+ raise TournamentMessageError(
777
+ 'server.tournament.stage_object', stage=index + 1, value=repr(stage)
778
+ )
779
+ unknown = (set(stage) | set(defaults)) - set(STAGE_DEFAULTS)
780
+ if unknown:
781
+ raise TournamentMessageError(
782
+ 'server.tournament.stage_settings', stage=index + 1, settings=sorted(unknown)
783
+ )
784
+ return {**copy.deepcopy(STAGE_DEFAULTS), **copy.deepcopy(defaults), **copy.deepcopy(stage)}
785
+
786
+
787
+ def _check_points(points: Any, where: dict[str, Any]) -> dict[str, int]:
788
+ if not isinstance(points, dict) or set(points) != {'win', 'draw', 'loss'}:
789
+ raise TournamentMessageError('server.tournament.points', where=where, points=repr(points))
790
+ for name, value in points.items():
791
+ _count(value, _field(f'points_{name}', where=where), -100, 100)
792
+ return dict(points)
793
+
794
+
795
+ def _ordered_names(value: Any, allowed: tuple[str, ...], name: Any) -> list[str]:
796
+ if not isinstance(value, list) or len(set(value)) != len(value):
797
+ raise TournamentMessageError('server.tournament.list', name=name, value=repr(value))
798
+ for item in value:
799
+ _choice(item, allowed, _field('each', name=name))
800
+ # Lots always come last, so every order ends in a decision.
801
+ return [item for item in value if item != 'lots'] + ['lots']
802
+
803
+
804
+ def _group_sizes(stage: dict[str, Any], count: int, index: int, fit: bool) -> list[int]:
805
+ where = _where(index)
806
+ if fit and _integer(stage['group_size']):
807
+ stage['group_size'] = min(stage['group_size'], count)
808
+ if stage['group_sizes'] is not None:
809
+ sizes = stage['group_sizes']
810
+ if not isinstance(sizes, list) or not sizes or not all(_integer(s) for s in sizes):
811
+ raise TournamentMessageError('server.tournament.group_sizes', where=where)
812
+ if sum(sizes) != count:
813
+ raise TournamentMessageError(
814
+ 'server.tournament.group_total', where=where, total=sum(sizes), count=count
815
+ )
816
+ else:
817
+ if stage['groups'] is not None:
818
+ groups = _count(stage['groups'], _field('groups', where=where), 1, count)
819
+ else:
820
+ size = _count(stage['group_size'], _field('group_size', where=where), 2, count)
821
+ groups = max(1, round(count / size))
822
+ base, extra = divmod(count, groups)
823
+ sizes = [base + (1 if group < extra else 0) for group in range(groups)]
824
+ if min(sizes) < 2:
825
+ raise TournamentMessageError(
826
+ 'server.tournament.group_minimum', where=where, count=count, sizes=sorted(set(sizes))
827
+ )
828
+ return sizes
829
+
830
+
831
+ def _qualifiers(stage: dict[str, Any], sizes: list[int], index: int) -> list[int]:
832
+ value = stage['qualifiers']
833
+ if _integer(value):
834
+ value = [value] * len(sizes)
835
+ if not isinstance(value, list) or len(value) != len(sizes):
836
+ raise TournamentMessageError('server.tournament.qualifiers', stage=index + 1)
837
+ for group, (size, qualified) in enumerate(zip(sizes, value, strict=True)):
838
+ name = _field('qualifiers', group=group_name(group), where=_where(index))
839
+ _count(qualified, name, 0, size)
840
+ return list(value)
841
+
842
+
843
+ def _best(stage: dict[str, Any], sizes: list[int], qualified: list[int], index: int) -> int:
844
+ position, count = stage['best_position'], stage['best_count']
845
+ if position is None:
846
+ if count not in (0, None):
847
+ raise TournamentMessageError('server.tournament.best_places', stage=index + 1)
848
+ return 0
849
+ _count(position, _field('best_position', where=_where(index)), 1, max(sizes))
850
+ eligible = sum(
851
+ 1
852
+ for size, group in zip(sizes, qualified, strict=True)
853
+ if size >= position and group < position
854
+ )
855
+ if count == 'fill':
856
+ total = sum(qualified)
857
+ count = min(eligible, max(0, _next_power(max(total, 2)) - total))
858
+ return _count(count, _field('best_count', position=position, where=_where(index)), 0, eligible)
859
+
860
+
861
+ def _resolve_stage(
862
+ stage: dict[str, Any], count: int, index: int, last: bool, fit: bool = False
863
+ ) -> dict[str, Any]:
864
+ """Checks one stage for `count` entrants and fills in its sizes, rounds and advance.
865
+
866
+ With `fit`, for the stages of a preset that ships, a group size, a best place, a number of
867
+ Swiss rounds or of entrants that advance beyond what `count` entrants allow comes down to the
868
+ most they allow, so the preset makes a tournament for any number of entrants.
869
+ """
870
+ where = _where(index)
871
+ fmt = _choice(stage['format'], FORMATS, _field('format', where=where))
872
+ if not isinstance(stage['name'], str):
873
+ raise TournamentMessageError('server.tournament.stage_name', where=where)
874
+ # A name that the rules or a shipped preset give keeps its label, any other stays as given.
875
+ name = stage['name'].strip()[:60] or _FORMAT_TITLES[fmt]
876
+ set_named(stage, 'name', _label(f'stage.{STAGE_NAMES[name]}') if name in STAGE_NAMES else name)
877
+ if count < 2:
878
+ raise TournamentMessageError('server.tournament.stage_entrants', where=where, count=count)
879
+ stage['entrants'] = count
880
+ stage['legs'] = _count(stage['legs'], _field('legs', where=where), 1, MAX_LEGS)
881
+ if stage['final_legs'] is not None:
882
+ _count(stage['final_legs'], _field('final_legs', where=where), 1, MAX_LEGS)
883
+ stage['settle'] = _ordered_names(stage['settle'], SETTLEMENTS, _field('settle', where=where))
884
+ _count(stage['replays'], _field('replays', where=where), 1, 5)
885
+ stage['points'] = _check_points(stage['points'], where)
886
+ stage['tiebreakers'] = _ordered_names(
887
+ stage['tiebreakers'], TIEBREAKERS, _field('tiebreakers', where=where)
888
+ )
889
+ _choice(stage['seeding'], STAGE_SEEDINGS, _field('seeding', where=where))
890
+ for key in ('length', 'decider_length'):
891
+ if stage[key] is not None:
892
+ _count(stage[key], _field(key, where=where), MIN_LENGTH, MAX_LENGTH)
893
+ if stage['ability'] is not None and (
894
+ not _number(stage['ability']) or not 0 <= stage['ability'] <= 1
895
+ ):
896
+ raise TournamentMessageError('server.tournament.stage_ability', where=where)
897
+ if stage['draws'] is None:
898
+ stage['draws'] = 'settle' if fmt == 'gauntlet' else 'holder'
899
+ _choice(stage['draws'], ('holder', 'settle'), _field('draws', where=where))
900
+ _choice(stage['bye_points'], ('win', 'draw', 'none'), _field('bye_points', where=where))
901
+ for key in ('third_place', 'grand_final_reset'):
902
+ if not isinstance(stage[key], bool):
903
+ raise TournamentMessageError('server.tournament.flag', setting=_field(key), where=where)
904
+ advance = None
905
+ if fmt in ('knockout', 'double_elimination'):
906
+ mode = _choice(stage['bracket'], ('byes', 'play_in'), _field('bracket', where=where))
907
+ if fmt == 'double_elimination' and mode != 'byes':
908
+ raise TournamentMessageError('server.tournament.double_byes', where=where)
909
+ size = stage['bracket_size']
910
+ if size is None:
911
+ size = _previous_power(count) if mode == 'play_in' else _next_power(count)
912
+ if not _integer(size) or not _power(size) or size < 2:
913
+ raise TournamentMessageError('server.tournament.bracket_power', where=where)
914
+ if mode == 'byes' and not count <= size < 2 * count:
915
+ # A bracket of 2 takes exactly 2, which reads better than a range of one value.
916
+ exact = size == 2
917
+ raise TournamentMessageError(
918
+ 'server.tournament.bracket_exact' if exact else 'server.tournament.bracket_byes',
919
+ size=size,
920
+ lowest=size // 2 + 1,
921
+ count=count,
922
+ where=where,
923
+ )
924
+ if mode == 'play_in' and not size <= count < 2 * size:
925
+ raise TournamentMessageError(
926
+ 'server.tournament.bracket_play_in',
927
+ size=size,
928
+ highest=2 * size - 1,
929
+ count=count,
930
+ where=where,
931
+ )
932
+ stage['bracket_size'] = size
933
+ if fmt == 'knockout':
934
+ until = stage['until']
935
+ if not _integer(until) or not _power(until) or until >= size:
936
+ raise TournamentMessageError(
937
+ 'server.tournament.knockout_stop', where=where, size=size
938
+ )
939
+ advance = until
940
+ if not last and stage['advance'] not in (None, until):
941
+ raise TournamentMessageError(
942
+ 'server.tournament.knockout_advance', where=where, count=until
943
+ )
944
+ elif fmt in ('league', 'groups'):
945
+ _count(stage['cycles'], _field('cycles', where=where), 1, 10)
946
+ if fmt == 'groups':
947
+ sizes = _group_sizes(stage, count, index, fit)
948
+ qualified = _qualifiers(stage, sizes, index)
949
+ position = stage['best_position']
950
+ if fit and _integer(position) and position > max(sizes):
951
+ stage.update(best_position=None, best_count=0)
952
+ best = _best(stage, sizes, qualified, index)
953
+ stage.update(group_sizes=sizes, qualifiers=qualified, best_count=best)
954
+ stage['groups'] = len(sizes)
955
+ advance = sum(qualified) + best
956
+ if not last and stage['advance'] not in (None, advance):
957
+ raise TournamentMessageError(
958
+ 'server.tournament.groups_advance', where=where, advance=advance
959
+ )
960
+ if not last and advance < 2:
961
+ raise TournamentMessageError('server.tournament.groups_few', where=where)
962
+ _pots(stage, count, index)
963
+ elif fmt == 'swiss':
964
+ most = max(1, math.ceil(count / 2))
965
+ if stage['rounds'] is None:
966
+ stage['rounds'] = min(most, max(1, math.ceil(math.log2(count))))
967
+ elif fit and _integer(stage['rounds']):
968
+ stage['rounds'] = min(stage['rounds'], most)
969
+ _count(
970
+ stage['rounds'],
971
+ _field('swiss_rounds', where=where, entrants=count),
972
+ 1,
973
+ most,
974
+ )
975
+ elif fmt == 'ladder':
976
+ if stage['rounds'] is None:
977
+ stage['rounds'] = count
978
+ _count(stage['rounds'], _field('ladder_rounds', where=where), 1, 1000)
979
+ elif fmt == 'king_of_the_hill':
980
+ _count(stage['laps'], _field('laps', where=where), 1, 10)
981
+ if advance is None:
982
+ advance = stage['advance']
983
+ if advance is None:
984
+ advance = count if last else max(2, _previous_power(max(2, count // 2)))
985
+ elif fit and _integer(advance) and advance > count:
986
+ advance = count if last else max(2, _previous_power(count))
987
+ _count(advance, _field('advance', where=where), 1 if last else 2, count)
988
+ stage['advance'] = advance
989
+ return stage
990
+
991
+
992
+ def _pots(stage: dict[str, Any], count: int, index: int) -> None:
993
+ pots = stage['pots']
994
+ if pots is None:
995
+ pots = max(stage['group_sizes'])
996
+ stage['pots'] = _count(pots, _field('pots', where=_where(index)), 1, count)
997
+
998
+
999
+ _FORMAT_TITLES = {
1000
+ 'knockout': 'Knockout',
1001
+ 'double_elimination': 'Double elimination',
1002
+ 'league': 'League',
1003
+ 'groups': 'Groups',
1004
+ 'swiss': 'Swiss',
1005
+ 'ladder': 'Ladder',
1006
+ 'king_of_the_hill': 'King of the hill',
1007
+ 'gauntlet': 'Gauntlet',
1008
+ }
1009
+
1010
+ # Checks whether an entrant can play a scenario, and returns the reason when it cannot, as text
1011
+ # or as a label of a server message, which the page shows in its own language.
1012
+ Fits = Callable[[dict[str, Any], str], 'str | dict[str, Any] | None']
1013
+
1014
+
1015
+ def _refuse_sizes(
1016
+ entrants: list[dict[str, Any]],
1017
+ scenario: str,
1018
+ index: int,
1019
+ reason: Any,
1020
+ fits: Fits,
1021
+ scenarios: list[str] | None,
1022
+ ) -> None:
1023
+ """Refuses entrants of both sizes for a scenario that fits one size, before any one team.
1024
+
1025
+ The refusal names a scenario that every entrant can play, one of the academy first, since a
1026
+ scenario that fits both sizes may still give a side too few players for a team's control, or
1027
+ says that none fits when no scenario of `scenarios` does.
1028
+ """
1029
+ if not isinstance(reason, dict) or reason.get('id') != 'server.tournament.team_format':
1030
+ return
1031
+ sizes = {entrant['format']: entrant for entrant in reversed(entrants) if entrant['format']}
1032
+ if '5v5' in sizes and '11v11' in sizes:
1033
+ names = sorted(scenarios or [], key=lambda name: (not name.startswith('academy_'), name))
1034
+ fitting = next(
1035
+ (name for name in names if not any(fits(entrant, name) for entrant in entrants)),
1036
+ None,
1037
+ )
1038
+ values = {
1039
+ 'five': label_of(sizes['5v5']),
1040
+ 'eleven': label_of(sizes['11v11']),
1041
+ 'scenario': _messages.scenario(scenario),
1042
+ 'stage': index + 1,
1043
+ }
1044
+ if fitting is None:
1045
+ raise TournamentMessageError('server.tournament.mixed_sizes_none', **values)
1046
+ raise TournamentMessageError(
1047
+ 'server.tournament.mixed_sizes', fitting=_messages.scenario(fitting), **values
1048
+ )
1049
+
1050
+
1051
+ def default_scenario(entrants: list[dict[str, Any]]) -> str:
1052
+ """Returns the scenario of a stage that names none, the Kaggle match or 5 against 5."""
1053
+ formats = {entrant['format'] for entrant in entrants if entrant['format']}
1054
+ return '5_vs_5' if formats == {'5v5'} else '11_vs_11_kaggle'
1055
+
1056
+
1057
+ def check(
1058
+ config: Any,
1059
+ teams: list[dict[str, Any]],
1060
+ fits: Fits | None = None,
1061
+ ratings: dict[str, float] | None = None,
1062
+ scenarios: list[str] | None = None,
1063
+ ) -> dict[str, Any]:
1064
+ """Returns a tournament's configuration in full, every setting of every stage resolved.
1065
+
1066
+ Args:
1067
+ config: The configuration, with its name, seed, entrants and stages, and optional
1068
+ `defaults`, `seeding`, `concurrency` and `forfeit_score`.
1069
+ teams: The team registry.
1070
+ fits: A check of an entrant against a scenario, which returns why it cannot play it, or
1071
+ `None` to skip that check.
1072
+ ratings: The ratings of earlier tournaments, by entrant key.
1073
+ scenarios: The scenarios a stage may name, or `None` for any.
1074
+
1075
+ Raises:
1076
+ TournamentError: With the reason, for any setting that cannot make a tournament.
1077
+ """
1078
+ if not isinstance(config, dict):
1079
+ raise TournamentMessageError('server.tournament.object')
1080
+ unknown = set(config) - set(TOURNAMENT_KEYS)
1081
+ if unknown:
1082
+ raise TournamentMessageError('server.tournament.settings', settings=sorted(unknown))
1083
+ # A tournament without a name takes the label Tournament, which reads in any language.
1084
+ result: dict[str, Any] = _messages.named(
1085
+ 'name',
1086
+ _plain_text(config['name'], _field('name')) if 'name' in config else _label('tournament'),
1087
+ )
1088
+ seed = config.get('seed')
1089
+ if seed is None:
1090
+ seed = random.SystemRandom().randrange(2**31)
1091
+ if isinstance(seed, str) and seed.strip().isdigit():
1092
+ seed = int(seed)
1093
+ result['seed'] = _count(seed, _field('seed'), 0, 2**63 - 1)
1094
+ include = check_include(config.get('include'))
1095
+ given = config.get('entrants')
1096
+ entrants = check_entrants(with_included(given, include), teams, ratings)
1097
+ result['entrants'] = entrants
1098
+ result['include'] = include
1099
+ result['strict'] = check_strict(config.get('strict'))
1100
+ if not isinstance(config.get('preflight', False), bool):
1101
+ raise TournamentMessageError('server.tournament.preflight', value=repr(config['preflight']))
1102
+ result['preflight'] = config.get('preflight', False)
1103
+ seeding = _field('tournament_seeding')
1104
+ result['seeding'] = _choice(config.get('seeding', 'random'), SEEDINGS, seeding)
1105
+ result['concurrency'] = _count(
1106
+ config.get('concurrency', 1), _field('concurrency'), 1, MAX_CONCURRENCY
1107
+ )
1108
+ forfeit = _field('forfeit_score')
1109
+ result['forfeit_score'] = _count(config.get('forfeit_score', 3), forfeit, 1, 10)
1110
+ # The standard ability of every entrant and stage that sets none, `None` for the scenario's.
1111
+ result['ability'] = check_ability(config.get('ability'), 'server.tournament.standard_ability')
1112
+ result['preset'] = config.get('preset') if isinstance(config.get('preset'), str) else None
1113
+ defaults = config.get('defaults', {})
1114
+ if not isinstance(defaults, dict):
1115
+ raise TournamentMessageError('server.tournament.defaults')
1116
+ stages = config.get('stages')
1117
+ if not isinstance(stages, list) or not 1 <= len(stages) <= MAX_STAGES:
1118
+ raise TournamentMessageError('server.tournament.stages', most=MAX_STAGES)
1119
+ count = len(entrants)
1120
+ # The stages of a preset that ships, as it ships them, fit the number of entrants, also when
1121
+ # they come from a preset of the user that saved them unchanged.
1122
+ fit = any(stages == shipped['stages'] for shipped in PRESETS.values())
1123
+ resolved = []
1124
+ for index, raw in enumerate(stages):
1125
+ stage = _merge(raw, defaults, index)
1126
+ last = index == len(stages) - 1
1127
+ stage = _resolve_stage(stage, count, index, last, fit)
1128
+ for key in ('scenario', 'decider_scenario'):
1129
+ if key == 'scenario' and stage[key] is None:
1130
+ stage[key] = default_scenario(entrants)
1131
+ if not isinstance(stage[key], str) or (
1132
+ scenarios is not None and stage[key] not in scenarios
1133
+ ):
1134
+ raise TournamentMessageError(
1135
+ 'server.tournament.stage_scenario', stage=index + 1, scenario=repr(stage[key])
1136
+ )
1137
+ if fits is not None:
1138
+ wanted = [(stage['scenario'], 'server.tournament.cannot_play')]
1139
+ if 'decider' in stage['settle'] and stage['format'] not in (
1140
+ 'league',
1141
+ 'groups',
1142
+ 'swiss',
1143
+ ):
1144
+ # A deciding match plays a scenario of its own, which the refusal names apart.
1145
+ wanted.append((stage['decider_scenario'], 'server.tournament.cannot_play_decider'))
1146
+ for scenario, message in wanted:
1147
+ for entrant in entrants:
1148
+ reason = fits(entrant, scenario)
1149
+ if reason:
1150
+ _refuse_sizes(entrants, scenario, index, reason, fits, scenarios)
1151
+ raise TournamentMessageError(
1152
+ message,
1153
+ entrant=label_of(entrant),
1154
+ scenario=_messages.scenario(scenario),
1155
+ stage=index + 1,
1156
+ reason=reason,
1157
+ )
1158
+ resolved.append(stage)
1159
+ count = stage['advance']
1160
+ result['stages'] = resolved
1161
+ # The stages and defaults as given, before the entrants resolved them, so a tournament run
1162
+ # again with other entrants resolves them anew.
1163
+ result['given'] = {'stages': copy.deepcopy(stages), 'defaults': copy.deepcopy(defaults)}
1164
+ # The entrants as given too, with those the tournament adds, so a pre-flight that leaves some
1165
+ # out makes the tournament again from the rest. They stay apart from `given`, which a run
1166
+ # again spreads into a new configuration with entrants of its own.
1167
+ result['given_entrants'] = copy.deepcopy(with_included(given, include))
1168
+ return result
1169
+
1170
+
1171
+ def check_strict(value: Any) -> dict[str, Any] | None:
1172
+ """Returns the limits of strict rules in full, or `None` when a tournament plays without them.
1173
+
1174
+ Args:
1175
+ value: `None` or false for no strict rules, true for the default limits, or an object that
1176
+ sets some of `STRICT_DEFAULTS`, the rest keeping their defaults.
1177
+
1178
+ Raises:
1179
+ TournamentError: If the value is none of those, or a limit lies outside its range.
1180
+ """
1181
+ if value is None or value is False:
1182
+ return None
1183
+ if value is True:
1184
+ value = {}
1185
+ if not isinstance(value, dict):
1186
+ raise TournamentMessageError('server.tournament.strict', value=repr(value))
1187
+ unknown = set(value) - set(STRICT_DEFAULTS) - set(STRICT_FLAGS)
1188
+ if unknown:
1189
+ raise TournamentMessageError('server.tournament.strict_settings', settings=sorted(unknown))
1190
+ limits = {**STRICT_DEFAULTS, **STRICT_FLAGS, **value}
1191
+ for name in STRICT_FLAGS:
1192
+ if not isinstance(limits[name], bool):
1193
+ raise TournamentMessageError(
1194
+ 'server.tournament.flag', setting=_field(f'strict_{name}'), where=_field('strict')
1195
+ )
1196
+ for name, (lowest, highest) in STRICT_RANGES.items():
1197
+ found = limits[name]
1198
+ name_label = _field(f'strict_{name}')
1199
+ if name == 'threads':
1200
+ limits[name] = _count(found, name_label, int(lowest), int(highest))
1201
+ elif not _number(found) or not lowest <= found <= highest:
1202
+ raise TournamentMessageError(
1203
+ 'server.tournament.limit',
1204
+ name=name_label,
1205
+ lowest=f'{lowest:g}',
1206
+ highest=f'{highest:g}',
1207
+ value=repr(found),
1208
+ )
1209
+ else:
1210
+ limits[name] = float(found)
1211
+ return limits
1212
+
1213
+
1214
+ def check_include(value: Any) -> list[dict[str, Any]]:
1215
+ """Returns the entrants that a tournament adds to those a person enters, as a copy.
1216
+
1217
+ A tournament may add the random entrant and the built-in AI so, as a floor
1218
+ that every team should beat. Each is an entrant as `check_entrants` takes it, and one whose
1219
+ key an entrant already holds is left out, so the same entrant never enters twice.
1220
+ """
1221
+ if value is None:
1222
+ return []
1223
+ if not isinstance(value, list) or len(value) > MAX_INCLUDED:
1224
+ raise TournamentMessageError('server.tournament.include', most=MAX_INCLUDED)
1225
+ for raw in value:
1226
+ if not isinstance(raw, dict) or raw.get('kind', 'team') == 'person':
1227
+ raise TournamentMessageError('server.tournament.include', most=MAX_INCLUDED)
1228
+ return copy.deepcopy(value)
1229
+
1230
+
1231
+ def with_included(entrants: Any, include: list[dict[str, Any]]) -> Any:
1232
+ """Returns the entrants as given with each included one whose key none of them holds."""
1233
+ if not isinstance(entrants, list):
1234
+ return entrants
1235
+ keys = {entrant_key(raw) for raw in entrants if isinstance(raw, dict)}
1236
+ added = []
1237
+ for raw in include:
1238
+ key = entrant_key(raw)
1239
+ if key not in keys:
1240
+ keys.add(key)
1241
+ added.append(copy.deepcopy(raw))
1242
+ return [*entrants, *added]
1243
+
1244
+
1245
+ # The evaluation.
1246
+
1247
+
1248
+ class _Run:
1249
+ """The matches that an evaluation needs, registered as the stages ask for them."""
1250
+
1251
+ def __init__(self, config: dict[str, Any], results: dict[str, Any]) -> None:
1252
+ self.config = config
1253
+ self.results = results
1254
+ self.matches: dict[str, dict[str, Any]] = {}
1255
+ self.entrants = config['entrants']
1256
+
1257
+ def ability(self, entrant: int, stage: dict[str, Any]) -> float | None:
1258
+ # A file of before holds no standard, which leaves the scenario's as it did.
1259
+ return match_ability(self.entrants[entrant], stage, self.config.get('ability'))
1260
+
1261
+ def match(
1262
+ self,
1263
+ key: str,
1264
+ stage: int,
1265
+ settings: dict[str, Any],
1266
+ label: dict[str, Any],
1267
+ home: int,
1268
+ away: int,
1269
+ decider: bool = False,
1270
+ ) -> dict[str, Any] | None:
1271
+ """Registers a match whose sides are known and returns its result, or `None`.
1272
+
1273
+ The match keeps the English text of its label in `label`, with its id and values beside.
1274
+ """
1275
+ entry = self.matches.get(key)
1276
+ if entry is None:
1277
+ persons = [self.entrants[side]['kind'] == 'person' for side in (home, away)]
1278
+ entry = {
1279
+ 'key': key,
1280
+ 'stage': stage,
1281
+ **_messages.named('label', label),
1282
+ 'home': home,
1283
+ 'away': away,
1284
+ 'scenario': settings['decider_scenario' if decider else 'scenario'],
1285
+ 'length': settings['decider_length' if decider else 'length'],
1286
+ 'ability': [self.ability(home, settings), self.ability(away, settings)],
1287
+ 'seed': match_seed(self.config['seed'], key),
1288
+ 'person': any(persons),
1289
+ 'persons': persons,
1290
+ 'decider': decider,
1291
+ 'result': self.results.get(key),
1292
+ }
1293
+ self.matches[key] = entry
1294
+ return entry['result']
1295
+
1296
+
1297
+ def _score(result: dict[str, Any]) -> list[int]:
1298
+ return list(result['score'])
1299
+
1300
+
1301
+ def _tie(
1302
+ run: _Run,
1303
+ stage: int,
1304
+ settings: dict[str, Any],
1305
+ key: str,
1306
+ label: dict[str, Any],
1307
+ first: Any,
1308
+ second: Any,
1309
+ legs: int,
1310
+ holder: Any = None,
1311
+ section: str | None = None,
1312
+ ) -> dict[str, Any]:
1313
+ """Plays a tie of `legs` matches between two sides and settles it, as far as results allow.
1314
+
1315
+ The first side is the higher seed, which plays the last leg at home and any replay too. A
1316
+ tie with a bye goes to the other side unplayed.
1317
+ """
1318
+ view: dict[str, Any] = {
1319
+ 'id': key,
1320
+ **_messages.named('label', label),
1321
+ 'home': _side(first),
1322
+ 'away': _side(second),
1323
+ 'matches': [],
1324
+ 'score': None,
1325
+ 'winner': None,
1326
+ 'loser': None,
1327
+ 'decided_by': None,
1328
+ 'state': 'waiting',
1329
+ 'section': section,
1330
+ }
1331
+ if isinstance(first, Unknown) or isinstance(second, Unknown):
1332
+ return view
1333
+ if first is None or second is None:
1334
+ view.update(winner=second if first is None else first, state='done', decided_by='bye')
1335
+ return view
1336
+ totals = {first: 0, second: 0}
1337
+ away = {first: 0, second: 0}
1338
+ pending = False
1339
+ played = False
1340
+ for leg in range(legs):
1341
+ home, guest = (first, second) if (legs - 1 - leg) % 2 == 0 else (second, first)
1342
+ name = label if legs == 1 else _label('leg', match=label, number=leg + 1)
1343
+ match_key = f'{key}/{leg + 1}'
1344
+ result = run.match(match_key, stage, settings, name, home, guest)
1345
+ view['matches'].append(match_key)
1346
+ if result is None:
1347
+ pending = True
1348
+ continue
1349
+ played = True
1350
+ score = _score(result)
1351
+ totals[home] += score[0]
1352
+ totals[guest] += score[1]
1353
+ away[guest] += score[1]
1354
+ # A tie that has played none of its matches has no score yet.
1355
+ view['score'] = [totals[first], totals[second]] if played else None
1356
+ if pending:
1357
+ view['state'] = 'ready'
1358
+ return view
1359
+
1360
+ def decide(winner: int, how: str) -> dict[str, Any]:
1361
+ view.update(
1362
+ winner=winner,
1363
+ loser=second if winner == first else first,
1364
+ decided_by=how,
1365
+ state='done',
1366
+ )
1367
+ return view
1368
+
1369
+ if totals[first] != totals[second]:
1370
+ return decide(
1371
+ first if totals[first] > totals[second] else second,
1372
+ 'aggregate' if legs > 1 else 'score',
1373
+ )
1374
+ if holder is not None:
1375
+ return decide(holder, 'holder')
1376
+ for method in settings['settle']:
1377
+ if method == 'away_goals':
1378
+ if legs % 2 == 0 and away[first] != away[second]:
1379
+ return decide(first if away[first] > away[second] else second, 'away goals')
1380
+ elif method == 'replay':
1381
+ for replay in range(settings['replays']):
1382
+ match_key = f'{key}/r{replay + 1}'
1383
+ result = run.match(
1384
+ match_key, stage, settings, _label('replay', match=label), first, second
1385
+ )
1386
+ view['matches'].append(match_key)
1387
+ if result is None:
1388
+ view['state'] = 'ready'
1389
+ return view
1390
+ score = _score(result)
1391
+ view['replay'] = score
1392
+ if score[0] != score[1]:
1393
+ return decide(first if score[0] > score[1] else second, 'replay')
1394
+ elif method == 'decider':
1395
+ keys = [f'{key}/d1', f'{key}/d2']
1396
+ decider = _label('decider', match=label)
1397
+ results = [
1398
+ run.match(keys[0], stage, settings, decider, first, second, True),
1399
+ run.match(keys[1], stage, settings, decider, second, first, True),
1400
+ ]
1401
+ view['matches'] += keys
1402
+ if None in results:
1403
+ view['state'] = 'ready'
1404
+ return view
1405
+ goals = [
1406
+ results[0]['score'][0] + results[1]['score'][1],
1407
+ results[0]['score'][1] + results[1]['score'][0],
1408
+ ]
1409
+ view['decider'] = goals
1410
+ if goals[0] != goals[1]:
1411
+ return decide(first if goals[0] > goals[1] else second, 'decider')
1412
+ else:
1413
+ draw = _rng(run.config['seed'], 'lots', key)
1414
+ return decide(draw.choice([first, second]), 'lots')
1415
+ raise AssertionError('the settlements end in lots')
1416
+
1417
+
1418
+ def _empty_row(entrant: int) -> dict[str, Any]:
1419
+ return {
1420
+ 'entrant': entrant,
1421
+ 'played': 0,
1422
+ 'won': 0,
1423
+ 'drawn': 0,
1424
+ 'lost': 0,
1425
+ 'for': 0,
1426
+ 'against': 0,
1427
+ 'difference': 0,
1428
+ 'points': 0,
1429
+ 'away_goals': 0,
1430
+ 'yellow': 0,
1431
+ 'red': 0,
1432
+ 'fair_play': 0,
1433
+ 'byes': 0,
1434
+ 'buchholz': 0,
1435
+ }
1436
+
1437
+
1438
+ def _add(rows: dict[int, dict[str, Any]], entry: dict[str, Any], points: dict[str, int]) -> None:
1439
+ """Adds one played match to the rows of its two sides."""
1440
+ result = entry['result']
1441
+ score = _score(result)
1442
+ cards = result.get('cards') or [[0, 0], [0, 0]]
1443
+ for side, entrant in enumerate((entry['home'], entry['away'])):
1444
+ row = rows.get(entrant)
1445
+ if row is None:
1446
+ continue
1447
+ own, other = score[side], score[1 - side]
1448
+ row['played'] += 1
1449
+ row['for'] += own
1450
+ row['against'] += other
1451
+ row['difference'] = row['for'] - row['against']
1452
+ if side == 1:
1453
+ row['away_goals'] += own
1454
+ outcome = 'win' if own > other else 'draw' if own == other else 'loss'
1455
+ row[{'win': 'won', 'draw': 'drawn', 'loss': 'lost'}[outcome]] += 1
1456
+ row['points'] += points[outcome]
1457
+ yellow, red = cards[side]
1458
+ row['yellow'] += yellow
1459
+ row['red'] += red
1460
+ row['fair_play'] = -(row['yellow'] + 3 * row['red'])
1461
+
1462
+
1463
+ def _rows(
1464
+ members: list[int], entries: list[dict[str, Any]], points: dict[str, int]
1465
+ ) -> dict[int, dict[str, Any]]:
1466
+ rows = {member: _empty_row(member) for member in members}
1467
+ for entry in entries:
1468
+ if entry['result'] is not None and not entry['decider']:
1469
+ _add(rows, entry, points)
1470
+ return rows
1471
+
1472
+
1473
+ def _order(
1474
+ members: list[int],
1475
+ rows: dict[int, dict[str, Any]],
1476
+ entries: list[dict[str, Any]],
1477
+ settings: dict[str, Any],
1478
+ seed: int,
1479
+ purpose: str,
1480
+ ) -> tuple[list[int], list[str | None]]:
1481
+ """Orders entrants by points, then by the tiebreakers, and returns why each stands above.
1482
+
1483
+ The second list holds, for each entrant but the last, the criterion that put it above the
1484
+ next one, or `None` when it has more points.
1485
+ """
1486
+ criteria = settings['tiebreakers']
1487
+
1488
+ def value(member: int, criterion: str) -> Any:
1489
+ row = rows[member]
1490
+ if criterion == 'goal_difference':
1491
+ return row['difference']
1492
+ if criterion == 'goals_for':
1493
+ return row['for']
1494
+ if criterion == 'goals_against':
1495
+ return -row['against']
1496
+ if criterion == 'wins':
1497
+ return row['won']
1498
+ return row[criterion]
1499
+
1500
+ def split(group: list[int], level: int) -> tuple[list[int], list[str | None]]:
1501
+ if len(group) == 1:
1502
+ return group, []
1503
+ criterion = criteria[level]
1504
+ if criterion == 'lots':
1505
+ order = sorted(group)
1506
+ _rng(seed, 'lots', purpose, *order).shuffle(order)
1507
+ return order, ['lots'] * (len(order) - 1)
1508
+ if criterion == 'head_to_head':
1509
+ inside = set(group)
1510
+ mini = _rows(
1511
+ group,
1512
+ [e for e in entries if e['home'] in inside and e['away'] in inside],
1513
+ settings['points'],
1514
+ )
1515
+ keys = {m: (mini[m]['points'], mini[m]['difference'], mini[m]['for']) for m in group}
1516
+ else:
1517
+ keys = {member: value(member, criterion) for member in group}
1518
+ order: list[int] = []
1519
+ reasons: list[str | None] = []
1520
+ for key in sorted(set(keys.values()), reverse=True):
1521
+ part = [member for member in group if keys[member] == key]
1522
+ sub, why = split(part, level + 1)
1523
+ if order:
1524
+ reasons.append(criterion)
1525
+ order += sub
1526
+ reasons += why
1527
+ return order, reasons
1528
+
1529
+ order: list[int] = []
1530
+ reasons: list[str | None] = []
1531
+ for points in sorted({rows[m]['points'] for m in members}, reverse=True):
1532
+ part = sorted(m for m in members if rows[m]['points'] == points)
1533
+ sub, why = split(part, 0)
1534
+ if order:
1535
+ reasons.append(None)
1536
+ order += sub
1537
+ reasons += why
1538
+ return order, reasons
1539
+
1540
+
1541
+ def _table(
1542
+ members: list[int],
1543
+ entries: list[dict[str, Any]],
1544
+ settings: dict[str, Any],
1545
+ seed: int,
1546
+ purpose: str,
1547
+ extra: dict[int, dict[str, Any]] | None = None,
1548
+ ) -> list[dict[str, Any]]:
1549
+ """Returns the table of `members` from their matches, ordered, each row with its reason."""
1550
+ rows = _rows(members, entries, settings['points'])
1551
+ for member, changes in (extra or {}).items():
1552
+ for key, amount in changes.items():
1553
+ rows[member][key] += amount
1554
+ played = {member: set() for member in members}
1555
+ for entry in entries:
1556
+ if entry['result'] is not None and entry['home'] in played and entry['away'] in played:
1557
+ played[entry['home']].add(entry['away'])
1558
+ played[entry['away']].add(entry['home'])
1559
+ for member in members:
1560
+ rows[member]['buchholz'] = sum(rows[other]['points'] for other in played[member])
1561
+ order, reasons = _order(members, rows, entries, settings, seed, purpose)
1562
+ # Before any match the order comes from lots alone, which is no tiebreak worth naming.
1563
+ begun = any(entry['result'] is not None and not entry['decider'] for entry in entries)
1564
+ table = []
1565
+ for position, member in enumerate(order):
1566
+ row = dict(rows[member], position=position + 1)
1567
+ row['tiebreak'] = reasons[position] if begun and position < len(reasons) else None
1568
+ table.append(row)
1569
+ return table
1570
+
1571
+
1572
+ def _stage_rows(
1573
+ run: _Run, index: int, members: list[int], settings: dict[str, Any]
1574
+ ) -> dict[int, dict[str, Any]]:
1575
+ entries = [entry for entry in run.matches.values() if entry['stage'] == index]
1576
+ return _rows(members, entries, settings['points'])
1577
+
1578
+
1579
+ def _strength(row: dict[str, Any]) -> tuple[int, int, int]:
1580
+ return (row['points'], row['difference'], row['for'])
1581
+
1582
+
1583
+ def _round_name(count: int) -> dict[str, Any]:
1584
+ name = _ROUND_NAMES.get(count)
1585
+ return _label(name) if name else _label('round_of', count=count)
1586
+
1587
+
1588
+ def _tie_name(count: int, number: int) -> dict[str, Any]:
1589
+ if count == 2:
1590
+ return _label('final')
1591
+ name = _TIE_NAMES.get(count)
1592
+ if name:
1593
+ return _label(name, number=number)
1594
+ return _label('round_of_tie', count=count, number=number)
1595
+
1596
+
1597
+ def _knockout(run: _Run, index: int, s: dict[str, Any], seeds: list[int]) -> dict[str, Any]:
1598
+ count = len(seeds)
1599
+ size = s['bracket_size']
1600
+ rounds: list[dict[str, Any]] = []
1601
+ eliminated: dict[int, float] = {}
1602
+ if s['bracket'] == 'play_in' and count > size:
1603
+ excess = count - size
1604
+ direct = seeds[: size - excess]
1605
+ playing = seeds[size - excess :]
1606
+ ties = []
1607
+ slots: list[Any] = list(direct)
1608
+ for number in range(excess):
1609
+ label = _label('play_in', number=number + 1)
1610
+ tie = _tie(
1611
+ run,
1612
+ index,
1613
+ s,
1614
+ f'{index}/p{number + 1}',
1615
+ label,
1616
+ playing[number],
1617
+ playing[-1 - number],
1618
+ s['legs'],
1619
+ section='play_in',
1620
+ )
1621
+ ties.append(tie)
1622
+ done = tie['state'] == 'done'
1623
+ slots.append(tie['winner'] if done else Unknown(_label('winner_of', match=label)))
1624
+ if tie['state'] == 'done':
1625
+ eliminated[tie['loser']] = -1
1626
+ named = _messages.named('name', _label('play_in_round'))
1627
+ rounds.append({**named, 'fixtures': ties, 'section': 'play_in'})
1628
+ else:
1629
+ slots = list(seeds) + [None] * (size - count)
1630
+ current = [slots[position] for position in bracket_order(size)]
1631
+ level = 0
1632
+ semi_losers: list[Any] = []
1633
+ while len(current) > s['until']:
1634
+ width = len(current)
1635
+ legs = s['final_legs'] if width == 2 and s['final_legs'] else s['legs']
1636
+ ties, following = [], []
1637
+ for number in range(width // 2):
1638
+ label = _tie_name(width, number + 1)
1639
+ tie = _tie(
1640
+ run,
1641
+ index,
1642
+ s,
1643
+ f'{index}/w{width}-{number + 1}',
1644
+ label,
1645
+ current[2 * number],
1646
+ current[2 * number + 1],
1647
+ legs,
1648
+ )
1649
+ ties.append(tie)
1650
+ done = tie['state'] == 'done'
1651
+ following.append(tie['winner'] if done else Unknown(_label('winner_of', match=label)))
1652
+ if done and tie['loser'] is not None:
1653
+ eliminated[tie['loser']] = level
1654
+ if width == 4:
1655
+ loser = tie['loser'] if done else Unknown(_label('loser_of', match=label))
1656
+ semi_losers.append(loser)
1657
+ named = _messages.named('name', _round_name(width))
1658
+ rounds.append({**named, 'fixtures': ties, 'section': 'main'})
1659
+ current = following
1660
+ level += 1
1661
+ if s['third_place'] and s['until'] == 1 and size >= 4:
1662
+ tie = _tie(
1663
+ run,
1664
+ index,
1665
+ s,
1666
+ f'{index}/third',
1667
+ _label('third_place'),
1668
+ semi_losers[0],
1669
+ semi_losers[1],
1670
+ s['legs'],
1671
+ section='third',
1672
+ )
1673
+ named = _messages.named('name', _label('third_place'))
1674
+ rounds.append({**named, 'fixtures': [tie], 'section': 'third'})
1675
+ if tie['state'] == 'done' and tie['winner'] is not None:
1676
+ eliminated[tie['winner']] = level - 2 + 0.5
1677
+ done = all(fixture['state'] == 'done' for r in rounds for fixture in r['fixtures'])
1678
+ ranking = None
1679
+ if done:
1680
+ for survivor in current:
1681
+ if survivor is not None:
1682
+ eliminated[survivor] = level + 1
1683
+ rows = _stage_rows(run, index, seeds, s)
1684
+ place = {entrant: position for position, entrant in enumerate(seeds)}
1685
+ ranking = sorted(
1686
+ seeds,
1687
+ key=lambda e: (-eliminated.get(e, -2), *(-v for v in _strength(rows[e])), place[e]),
1688
+ )
1689
+ return {'rounds': rounds, 'ranking': ranking, 'bracket': 'knockout'}
1690
+
1691
+
1692
+ def _double_elimination(
1693
+ run: _Run, index: int, s: dict[str, Any], seeds: list[int]
1694
+ ) -> dict[str, Any]:
1695
+ size = s['bracket_size']
1696
+ depth = size.bit_length() - 1
1697
+ slots = list(seeds) + [None] * (size - len(seeds))
1698
+ current: list[Any] = [slots[position] for position in bracket_order(size)]
1699
+ rounds: list[dict[str, Any]] = []
1700
+ losers_by_round: list[list[Any]] = []
1701
+ out: dict[int, float] = {}
1702
+
1703
+ def play(pairs: list[tuple[Any, Any]], name: dict[str, Any], section: str, legs: int):
1704
+ ties, winners, losers = [], [], []
1705
+ for number, (first, second) in enumerate(pairs):
1706
+ label = _label('tie', round=name, number=number + 1) if len(pairs) > 1 else name
1707
+ tie = _tie(
1708
+ run,
1709
+ index,
1710
+ s,
1711
+ f'{index}/{section[0]}{len(rounds) + 1}-{number + 1}',
1712
+ label,
1713
+ first,
1714
+ second,
1715
+ legs,
1716
+ section=section,
1717
+ )
1718
+ ties.append(tie)
1719
+ done = tie['state'] == 'done'
1720
+ winners.append(tie['winner'] if done else Unknown(_label('winner_of', match=label)))
1721
+ losers.append(tie['loser'] if done else Unknown(_label('loser_of', match=label)))
1722
+ rounds.append({**_messages.named('name', name), 'fixtures': ties, 'section': section})
1723
+ return winners, losers
1724
+
1725
+ for number in range(depth):
1726
+ width = len(current)
1727
+ final = width == 2
1728
+ name = _label('winners_final') if final else _label('winners_round', number=number + 1)
1729
+ pairs = [(current[2 * i], current[2 * i + 1]) for i in range(width // 2)]
1730
+ current, losers = play(pairs, name, 'winners', s['legs'])
1731
+ losers_by_round.append(losers)
1732
+ champion_side = current[0]
1733
+ if depth == 1:
1734
+ survivor = losers_by_round[0][0]
1735
+ else:
1736
+ level = 0
1737
+ first = losers_by_round[0]
1738
+ pairs = [(first[2 * i], first[2 * i + 1]) for i in range(len(first) // 2)]
1739
+ lb_round = 1
1740
+ survivors, beaten = play(
1741
+ pairs, _label('losers_round', number=lb_round), 'losers', s['legs']
1742
+ )
1743
+ for loser in beaten:
1744
+ if isinstance(loser, int):
1745
+ out[loser] = level
1746
+ for number in range(1, depth):
1747
+ level += 1
1748
+ lb_round += 1
1749
+ drop = losers_by_round[number]
1750
+ if number % 2 == 1:
1751
+ drop = list(reversed(drop))
1752
+ last = number == depth - 1
1753
+ name = _label('losers_final') if last else _label('losers_round', number=lb_round)
1754
+ survivors, beaten = play(
1755
+ list(zip(survivors, drop, strict=True)), name, 'losers', s['legs']
1756
+ )
1757
+ for loser in beaten:
1758
+ if isinstance(loser, int):
1759
+ out[loser] = level
1760
+ if not last:
1761
+ level += 1
1762
+ lb_round += 1
1763
+ pairs = [
1764
+ (survivors[2 * i], survivors[2 * i + 1]) for i in range(len(survivors) // 2)
1765
+ ]
1766
+ survivors, beaten = play(
1767
+ pairs, _label('losers_round', number=lb_round), 'losers', s['legs']
1768
+ )
1769
+ for loser in beaten:
1770
+ if isinstance(loser, int):
1771
+ out[loser] = level
1772
+ survivor = survivors[0]
1773
+ final_legs = s['final_legs'] or s['legs']
1774
+ winners, losers = play([(champion_side, survivor)], _label('grand_final'), 'final', final_legs)
1775
+ champion, runner_up = winners[0], losers[0]
1776
+ grand = rounds[-1]['fixtures'][0]
1777
+ if (
1778
+ s['grand_final_reset']
1779
+ and grand['state'] == 'done'
1780
+ and isinstance(survivor, int)
1781
+ and isinstance(champion_side, int)
1782
+ and grand['winner'] == survivor
1783
+ ):
1784
+ winners, losers = play(
1785
+ [(champion_side, survivor)], _label('grand_final_reset'), 'final', final_legs
1786
+ )
1787
+ champion, runner_up = winners[0], losers[0]
1788
+ done = all(f['state'] == 'done' for r in rounds for f in r['fixtures'])
1789
+ ranking = None
1790
+ if done:
1791
+ top = 10**6
1792
+ if isinstance(champion, int):
1793
+ out[champion] = top + 1
1794
+ if isinstance(runner_up, int):
1795
+ out[runner_up] = top
1796
+ rows = _stage_rows(run, index, seeds, s)
1797
+ place = {entrant: position for position, entrant in enumerate(seeds)}
1798
+ ranking = sorted(
1799
+ seeds, key=lambda e: (-out.get(e, -1), *(-v for v in _strength(rows[e])), place[e])
1800
+ )
1801
+ return {'rounds': rounds, 'ranking': ranking, 'bracket': 'double'}
1802
+
1803
+
1804
+ def _league_rounds(
1805
+ run: _Run, index: int, s: dict[str, Any], members: list[int], prefix: str, group: str | None
1806
+ ) -> list[list[dict[str, Any]]]:
1807
+ """Registers the matches of a round robin and returns the fixtures of each matchday.
1808
+
1809
+ The legs of a meeting play back to back with the venue in turn, leg by leg. Legs added to a
1810
+ stage that has begun, its `added_legs`, play after every matchday of before, each one a
1811
+ round robin of its own whose venue goes on in turn from the last leg, so the keys of the
1812
+ matches of before stay as they were.
1813
+ """
1814
+ schedule = round_robin(members)
1815
+ days: list[list[dict[str, Any]]] = []
1816
+
1817
+ def day_of(pairs: list[tuple[Any, Any]], flip: bool) -> None:
1818
+ day = len(days) + 1
1819
+ fixtures = []
1820
+ for slot, (first, second) in enumerate(pairs):
1821
+ if first is None or second is None:
1822
+ continue
1823
+ home, away = (second, first) if flip else (first, second)
1824
+ key = f'{prefix}/{day}-{slot + 1}'
1825
+ label = (
1826
+ _label('matchday', number=day)
1827
+ if group is None
1828
+ else _label('group_matchday', group=group, number=day)
1829
+ )
1830
+ result = run.match(key, index, s, label, home, away)
1831
+ fixtures.append(_fixture(key, home, away, result, group))
1832
+ days.append(fixtures)
1833
+
1834
+ for cycle in range(s['cycles']):
1835
+ for pairs in schedule:
1836
+ for leg in range(s['legs']):
1837
+ day_of(pairs, (cycle + leg) % 2 == 1)
1838
+ for extra in range(s.get('added_legs') or 0):
1839
+ for cycle in range(s['cycles']):
1840
+ for pairs in schedule:
1841
+ day_of(pairs, (cycle + s['legs'] + extra) % 2 == 1)
1842
+ return days
1843
+
1844
+
1845
+ def _fixture(
1846
+ key: str, home: int, away: int, result: Any, group: str | None = None
1847
+ ) -> dict[str, Any]:
1848
+ score = None if result is None else _score(result)
1849
+ winner = None
1850
+ if score is not None and score[0] != score[1]:
1851
+ winner = home if score[0] > score[1] else away
1852
+ return {
1853
+ 'id': key,
1854
+ 'home': home,
1855
+ 'away': away,
1856
+ 'matches': [key],
1857
+ 'score': score,
1858
+ 'winner': winner,
1859
+ 'state': 'ready' if result is None else 'done',
1860
+ 'group': group,
1861
+ }
1862
+
1863
+
1864
+ def _league(run: _Run, index: int, s: dict[str, Any], seeds: list[int]) -> dict[str, Any]:
1865
+ days = _league_rounds(run, index, s, seeds, f'{index}', None)
1866
+ rounds = [
1867
+ {**_messages.named('name', _label('matchday', number=day + 1)), 'fixtures': fixtures}
1868
+ for day, fixtures in enumerate(days)
1869
+ ]
1870
+ entries = [entry for entry in run.matches.values() if entry['stage'] == index]
1871
+ table = _table(seeds, entries, s, run.config['seed'], f'{index}/table')
1872
+ done = all(f['state'] == 'done' for r in rounds for f in r['fixtures'])
1873
+ return {
1874
+ 'rounds': rounds,
1875
+ 'tables': [{**named_from('name', s), 'rows': table, 'qualify': s['advance']}],
1876
+ 'ranking': [row['entrant'] for row in table] if done else None,
1877
+ }
1878
+
1879
+
1880
+ def draw_groups(seeds: list[int], s: dict[str, Any], seed: int, index: int) -> list[list[int]]:
1881
+ """Draws the groups of a stage from its pots, and returns the members of each group.
1882
+
1883
+ The seeded entrants fill the pots in order, the pots as even as their count allows. Each pot
1884
+ is shuffled by the tournament seed and dealt one entrant at a time to the next group with
1885
+ room, continuing around the groups from pot to pot.
1886
+ """
1887
+ sizes = s['group_sizes']
1888
+ pots = s['pots']
1889
+ base, extra = divmod(len(seeds), pots)
1890
+ groups: list[list[int]] = [[] for _ in sizes]
1891
+ start = 0
1892
+ turn = 0
1893
+ for pot in range(pots):
1894
+ width = base + (1 if pot < extra else 0)
1895
+ members = seeds[start : start + width]
1896
+ start += width
1897
+ _rng(seed, 'pots', index, pot).shuffle(members)
1898
+ for member in members:
1899
+ while len(groups[turn % len(sizes)]) >= sizes[turn % len(sizes)]:
1900
+ turn += 1
1901
+ groups[turn % len(sizes)].append(member)
1902
+ turn += 1
1903
+ return groups
1904
+
1905
+
1906
+ def _groups(run: _Run, index: int, s: dict[str, Any], seeds: list[int]) -> dict[str, Any]:
1907
+ seed = run.config['seed']
1908
+ members = draw_groups(seeds, s, seed, index)
1909
+ days: list[list[dict[str, Any]]] = []
1910
+ tables = []
1911
+ for number, group in enumerate(members):
1912
+ letter = group_name(number)
1913
+ group_days = _league_rounds(run, index, s, group, f'{index}/{letter}', letter)
1914
+ for day, fixtures in enumerate(group_days):
1915
+ while len(days) <= day:
1916
+ days.append([])
1917
+ days[day] += fixtures
1918
+ keys = {fixture['id'] for fixtures in group_days for fixture in fixtures}
1919
+ entries = [run.matches[key] for key in keys]
1920
+ rows = _table(group, entries, s, seed, f'{index}/{letter}')
1921
+ tables.append(
1922
+ {
1923
+ **_messages.named('name', _label('group', group=letter)),
1924
+ 'rows': rows,
1925
+ 'qualify': s['qualifiers'][number],
1926
+ }
1927
+ )
1928
+ rounds = [
1929
+ {**_messages.named('name', _label('matchday', number=day + 1)), 'fixtures': fixtures}
1930
+ for day, fixtures in enumerate(days)
1931
+ ]
1932
+ done = all(f['state'] == 'done' for r in rounds for f in r['fixtures'])
1933
+ ranking = None
1934
+ best: list[int] = []
1935
+ if done:
1936
+ ranking, best = _group_ranking(tables, s, seed, index)
1937
+ for table in tables:
1938
+ for row in table['rows']:
1939
+ row['best'] = row['entrant'] in best
1940
+ return {'rounds': rounds, 'tables': tables, 'ranking': ranking, 'best': best}
1941
+
1942
+
1943
+ def _group_ranking(
1944
+ tables: list[dict[str, Any]], s: dict[str, Any], seed: int, index: int
1945
+ ) -> tuple[list[int], list[int]]:
1946
+ """Ranks every entrant of a group stage across the groups, the qualifiers first.
1947
+
1948
+ Entrants of the same position compare by points, goal difference, goals and wins for each
1949
+ match played, which keeps groups of different sizes fair, and then by lots.
1950
+ """
1951
+
1952
+ def across(rows: list[dict[str, Any]], purpose: str) -> list[dict[str, Any]]:
1953
+ def key(row: dict[str, Any]) -> tuple[float, ...]:
1954
+ played = max(1, row['played'])
1955
+ return tuple(-row[name] / played for name in ('points', 'difference', 'for', 'won'))
1956
+
1957
+ order = sorted(rows, key=lambda row: row['entrant'])
1958
+ _rng(seed, 'across', index, purpose).shuffle(order)
1959
+ return sorted(order, key=key)
1960
+
1961
+ deepest = max(len(table['rows']) for table in tables)
1962
+ qualified: list[int] = []
1963
+ for position in range(deepest):
1964
+ rows = [
1965
+ table['rows'][position]
1966
+ for table in tables
1967
+ if position < table['qualify'] and position < len(table['rows'])
1968
+ ]
1969
+ qualified += [row['entrant'] for row in across(rows, f'q{position}')]
1970
+ best: list[int] = []
1971
+ if s['best_position'] is not None and s['best_count']:
1972
+ position = s['best_position'] - 1
1973
+ rows = [
1974
+ table['rows'][position]
1975
+ for table in tables
1976
+ if position < len(table['rows']) and position >= table['qualify']
1977
+ ]
1978
+ best = [row['entrant'] for row in across(rows, 'best')[: s['best_count']]]
1979
+ chosen = set(qualified) | set(best)
1980
+ rest: list[int] = []
1981
+ for position in range(deepest):
1982
+ rows = [
1983
+ table['rows'][position]
1984
+ for table in tables
1985
+ if position < len(table['rows']) and table['rows'][position]['entrant'] not in chosen
1986
+ ]
1987
+ rest += [row['entrant'] for row in across(rows, f'r{position}')]
1988
+ return qualified + best + rest, best
1989
+
1990
+
1991
+ def _palmer(count: int, adjacent: Callable[[int, int], bool]) -> list[int] | None:
1992
+ """Finds a Hamiltonian cycle by Palmer's algorithm, in a graph where Dirac's bound holds."""
1993
+ cycle = list(range(count))
1994
+ for _ in range(count * count + 10):
1995
+ gap = next(
1996
+ (i for i in range(count) if not adjacent(cycle[i], cycle[(i + 1) % count])), None
1997
+ )
1998
+ if gap is None:
1999
+ return cycle
2000
+ cycle = cycle[gap + 1 :] + cycle[: gap + 1]
2001
+ # The gap now lies between the last and the first vertex of the list.
2002
+ first, last = cycle[0], cycle[-1]
2003
+ for j in range(1, count - 2):
2004
+ if adjacent(first, cycle[j + 1]) and adjacent(last, cycle[j]):
2005
+ cycle = cycle[: j + 1] + list(reversed(cycle[j + 1 :]))
2006
+ break
2007
+ else:
2008
+ return None
2009
+ return None
2010
+
2011
+
2012
+ def swiss_pairs(
2013
+ order: list[int], met: set[frozenset[int]], byes: set[int], budget: int = 20000
2014
+ ) -> tuple[list[tuple[int, int]], int | None]:
2015
+ """Pairs a Swiss round in standing order, each pair new, with a bye for an odd count.
2016
+
2017
+ The bye goes to the lowest entrant who has had none. A search pairs the best placed entrant
2018
+ with the next one it has not met, and backs up when the rest cannot pair. When the search runs
2019
+ past its budget, Palmer's algorithm on the graph of pairs not yet played finds the pairing,
2020
+ which exists while each entrant has met fewer than half the field.
2021
+
2022
+ Returns:
2023
+ The pairs, the better placed first, and the entrant with the bye, or `None`.
2024
+ """
2025
+ players = list(order)
2026
+ candidates: list[int | None] = [None]
2027
+ if len(players) % 2:
2028
+ candidates = [p for p in reversed(players) if p not in byes] or list(reversed(players))
2029
+ nodes = [0]
2030
+
2031
+ def search(rest: list[int]) -> list[tuple[int, int]] | None:
2032
+ if not rest:
2033
+ return []
2034
+ nodes[0] += 1
2035
+ if nodes[0] > budget:
2036
+ return None
2037
+ first = rest[0]
2038
+ for j in range(1, len(rest)):
2039
+ second = rest[j]
2040
+ if frozenset((first, second)) in met:
2041
+ continue
2042
+ tail = search(rest[1:j] + rest[j + 1 :])
2043
+ if tail is not None:
2044
+ return [(first, second), *tail]
2045
+ return None
2046
+
2047
+ for bye in candidates:
2048
+ rest = [p for p in players if p != bye]
2049
+ found = search(rest)
2050
+ if found is not None:
2051
+ return found, bye
2052
+ # A fallback for large rounds near the limit, with the bye as a vertex of its own.
2053
+ phantom = -1
2054
+ vertices = list(players) + ([phantom] if len(players) % 2 else [])
2055
+
2056
+ def adjacent(a: int, b: int) -> bool:
2057
+ x, y = vertices[a], vertices[b]
2058
+ if x == phantom or y == phantom:
2059
+ return (y if x == phantom else x) not in byes
2060
+ return frozenset((x, y)) not in met
2061
+
2062
+ cycle = _palmer(len(vertices), adjacent)
2063
+ if cycle is None:
2064
+ raise TournamentMessageError('server.tournament.swiss')
2065
+ pairs, bye = [], None
2066
+ place = {entrant: position for position, entrant in enumerate(order)}
2067
+ for i in range(0, len(cycle), 2):
2068
+ x, y = vertices[cycle[i]], vertices[cycle[i + 1]]
2069
+ if phantom in (x, y):
2070
+ bye = y if x == phantom else x
2071
+ continue
2072
+ pairs.append((x, y) if place[x] < place[y] else (y, x))
2073
+ pairs.sort(key=lambda pair: place[pair[0]])
2074
+ return pairs, bye
2075
+
2076
+
2077
+ def _swiss(run: _Run, index: int, s: dict[str, Any], seeds: list[int]) -> dict[str, Any]:
2078
+ seed = run.config['seed']
2079
+ met: set[frozenset[int]] = set()
2080
+ byes: set[int] = set()
2081
+ extra: dict[int, dict[str, int]] = {}
2082
+ homes = dict.fromkeys(seeds, 0)
2083
+ rounds = []
2084
+ entries: list[dict[str, Any]] = []
2085
+ complete = True
2086
+ for number in range(s['rounds']):
2087
+ if number == 0:
2088
+ order = list(seeds)
2089
+ else:
2090
+ table = _table(seeds, entries, s, seed, f'{index}/s{number}', extra)
2091
+ order = [row['entrant'] for row in table]
2092
+ pairs, bye = swiss_pairs(order, met, byes)
2093
+ fixtures = []
2094
+ for slot, (first, second) in enumerate(pairs):
2095
+ met.add(frozenset((first, second)))
2096
+ # The side with fewer home matches plays at home, the better placed when equal.
2097
+ if homes[second] < homes[first]:
2098
+ first, second = second, first
2099
+ for leg in range(s['legs']):
2100
+ home, away = (first, second) if leg % 2 == 0 else (second, first)
2101
+ homes[home] += 1
2102
+ key = f'{index}/s{number + 1}-{slot + 1}' + (f'-{leg + 1}' if s['legs'] > 1 else '')
2103
+ result = run.match(key, index, s, _label('round', number=number + 1), home, away)
2104
+ entries.append(run.matches[key])
2105
+ fixtures.append(_fixture(key, home, away, result))
2106
+ if bye is not None:
2107
+ byes.add(bye)
2108
+ points = {'win': s['points']['win'], 'draw': s['points']['draw'], 'none': 0}
2109
+ change = extra.setdefault(bye, {'points': 0, 'byes': 0})
2110
+ change['points'] += points[s['bye_points']]
2111
+ change['byes'] += 1
2112
+ named = _messages.named('name', _label('round', number=number + 1))
2113
+ rounds.append({**named, 'fixtures': fixtures, 'bye': bye})
2114
+ if any(fixture['state'] != 'done' for fixture in fixtures):
2115
+ complete = False
2116
+ break
2117
+ table = _table(seeds, entries, s, seed, f'{index}/table', extra)
2118
+ return {
2119
+ 'rounds': rounds,
2120
+ 'tables': [{**named_from('name', s), 'rows': table, 'qualify': s['advance']}],
2121
+ 'ranking': [row['entrant'] for row in table] if complete else None,
2122
+ }
2123
+
2124
+
2125
+ def _ladder(run: _Run, index: int, s: dict[str, Any], seeds: list[int]) -> dict[str, Any]:
2126
+ positions = list(seeds)
2127
+ count = len(positions)
2128
+ rounds = []
2129
+ complete = True
2130
+ for number in range(s['rounds']):
2131
+ start = number % 2 if count > 2 else 0
2132
+ ties = []
2133
+ for upper in range(start, count - 1, 2):
2134
+ first, second = positions[upper], positions[upper + 1]
2135
+ tie = _tie(
2136
+ run,
2137
+ index,
2138
+ s,
2139
+ f'{index}/l{number + 1}-{upper + 1}',
2140
+ _label('ladder_tie', round=number + 1, upper=upper + 1, lower=upper + 2),
2141
+ first,
2142
+ second,
2143
+ s['legs'],
2144
+ holder=first if s['draws'] == 'holder' else None,
2145
+ )
2146
+ ties.append(tie)
2147
+ rounds.append(
2148
+ {**_messages.named('name', _label('round', number=number + 1)), 'fixtures': ties}
2149
+ )
2150
+ if any(tie['state'] != 'done' for tie in ties):
2151
+ complete = False
2152
+ break
2153
+ for tie in ties:
2154
+ upper = positions.index(tie['home'])
2155
+ if tie['winner'] == tie['away']:
2156
+ positions[upper], positions[upper + 1] = positions[upper + 1], positions[upper]
2157
+ rows = _stage_rows(run, index, seeds, s)
2158
+ table = [dict(rows[e], position=p + 1, tiebreak=None) for p, e in enumerate(positions)]
2159
+ return {
2160
+ 'rounds': rounds,
2161
+ 'tables': [
2162
+ {
2163
+ **_messages.named('name', _label('stage.ladder')),
2164
+ 'rows': table,
2165
+ 'qualify': s['advance'],
2166
+ }
2167
+ ],
2168
+ 'ranking': positions if complete else None,
2169
+ }
2170
+
2171
+
2172
+ def _king(run: _Run, index: int, s: dict[str, Any], seeds: list[int]) -> dict[str, Any]:
2173
+ king = seeds[0]
2174
+ wins = dict.fromkeys(seeds, 0)
2175
+ reigns = dict.fromkeys(seeds, 0)
2176
+ reigns[king] = 1
2177
+ rounds = []
2178
+ complete = True
2179
+ number = 0
2180
+ for lap in range(s['laps']):
2181
+ challengers = seeds[1:] if lap == 0 else list(seeds)
2182
+ ties = []
2183
+ stop = False
2184
+ for challenger in challengers:
2185
+ if challenger == king:
2186
+ continue
2187
+ number += 1
2188
+ tie = _tie(
2189
+ run,
2190
+ index,
2191
+ s,
2192
+ f'{index}/k{number}',
2193
+ _label('challenge', number=number),
2194
+ king,
2195
+ challenger,
2196
+ s['legs'],
2197
+ holder=king if s['draws'] == 'holder' else None,
2198
+ )
2199
+ ties.append(tie)
2200
+ if tie['state'] != 'done':
2201
+ stop = True
2202
+ break
2203
+ wins[tie['winner']] += 1
2204
+ if tie['winner'] != king:
2205
+ king = tie['winner']
2206
+ reigns[king] += 1
2207
+ named = _messages.named('name', _label('lap', number=lap + 1))
2208
+ rounds.append({**named, 'fixtures': ties, 'king': king})
2209
+ if stop:
2210
+ complete = False
2211
+ break
2212
+ rows = _stage_rows(run, index, seeds, s)
2213
+ place = {entrant: position for position, entrant in enumerate(seeds)}
2214
+ order = sorted(
2215
+ seeds,
2216
+ key=lambda e: (e != king, -wins[e], *(-v for v in _strength(rows[e])), place[e]),
2217
+ )
2218
+ table = [
2219
+ dict(rows[e], position=p + 1, tiebreak=None, titles=wins[e], reigns=reigns[e])
2220
+ for p, e in enumerate(order)
2221
+ ]
2222
+ return {
2223
+ 'rounds': rounds,
2224
+ 'tables': [
2225
+ {
2226
+ **_messages.named('name', _label('challenges')),
2227
+ 'rows': table,
2228
+ 'qualify': s['advance'],
2229
+ }
2230
+ ],
2231
+ 'ranking': order if complete else None,
2232
+ 'king': king,
2233
+ }
2234
+
2235
+
2236
+ def _gauntlet(run: _Run, index: int, s: dict[str, Any], seeds: list[int]) -> dict[str, Any]:
2237
+ climber: Any = seeds[-1]
2238
+ out: list[int] = []
2239
+ rounds = []
2240
+ complete = True
2241
+ count = len(seeds)
2242
+ for step, waiting in enumerate(reversed(seeds[:-1])):
2243
+ final = step == count - 2
2244
+ label = _label('final') if final else _label('step', number=step + 1)
2245
+ tie = _tie(
2246
+ run,
2247
+ index,
2248
+ s,
2249
+ f'{index}/g{step + 1}',
2250
+ label,
2251
+ waiting,
2252
+ climber,
2253
+ s['legs'],
2254
+ holder=waiting if s['draws'] == 'holder' else None,
2255
+ )
2256
+ rounds.append({**_messages.named('name', label), 'fixtures': [tie]})
2257
+ if tie['state'] != 'done':
2258
+ complete = False
2259
+ break
2260
+ out.append(tie['loser'])
2261
+ climber = tie['winner']
2262
+ return {'rounds': rounds, 'ranking': [climber, *reversed(out)] if complete else None}
2263
+
2264
+
2265
+ _STAGES = {
2266
+ 'knockout': _knockout,
2267
+ 'double_elimination': _double_elimination,
2268
+ 'league': _league,
2269
+ 'groups': _groups,
2270
+ 'swiss': _swiss,
2271
+ 'ladder': _ladder,
2272
+ 'king_of_the_hill': _king,
2273
+ 'gauntlet': _gauntlet,
2274
+ }
2275
+
2276
+
2277
+ def seeding(config: dict[str, Any]) -> list[int]:
2278
+ """Returns the entrants of the first stage in seed order, by the tournament's seeding."""
2279
+ entrants = list(range(len(config['entrants'])))
2280
+ if config['seeding'] == 'random':
2281
+ _rng(config['seed'], 'seeding', 0).shuffle(entrants)
2282
+ elif config['seeding'] == 'rating':
2283
+ entrants.sort(key=lambda e: -config['entrants'][e]['rating'])
2284
+ return entrants
2285
+
2286
+
2287
+ def _reseed(config: dict[str, Any], stage: dict[str, Any], index: int, ranked: list[int]):
2288
+ entrants = list(ranked)
2289
+ if stage['seeding'] == 'random':
2290
+ _rng(config['seed'], 'seeding', index).shuffle(entrants)
2291
+ elif stage['seeding'] == 'rating':
2292
+ entrants.sort(key=lambda e: -config['entrants'][e]['rating'])
2293
+ return entrants
2294
+
2295
+
2296
+ def _planned(settings: dict[str, Any], count: int) -> int:
2297
+ """Returns the matches that a stage of `count` entrants plays by its plan.
2298
+
2299
+ The plan counts every tie that two entrants play, and leaves out the replays, deciding
2300
+ matches and the reset of a grand final, which only results bring.
2301
+ """
2302
+ legs = settings['legs']
2303
+ final_legs = settings.get('final_legs') or legs
2304
+ form = settings['format']
2305
+ if count < 2:
2306
+ return 0
2307
+ if form == 'knockout':
2308
+ until = settings['until']
2309
+ ties = count - until
2310
+ if until > 1:
2311
+ return ties * legs
2312
+ third = 1 if settings['third_place'] and count >= 4 else 0
2313
+ return (ties - 1) * legs + final_legs + third * legs
2314
+ if form == 'double_elimination':
2315
+ return (2 * count - 3) * legs + final_legs
2316
+ if form in ('league', 'groups'):
2317
+ sizes = settings['group_sizes'] if form == 'groups' else [count]
2318
+ legs += settings.get('added_legs') or 0
2319
+ return sum(size * (size - 1) // 2 for size in sizes) * settings['cycles'] * legs
2320
+ if form == 'swiss':
2321
+ return settings['rounds'] * (count // 2) * legs
2322
+ if form == 'ladder':
2323
+ ties = 0
2324
+ for number in range(settings['rounds']):
2325
+ start = number % 2 if count > 2 else 0
2326
+ ties += len(range(start, count - 1, 2))
2327
+ return ties * legs
2328
+ if form == 'king_of_the_hill':
2329
+ return settings['laps'] * (count - 1) * legs
2330
+ return (count - 1) * legs
2331
+
2332
+
2333
+ def expected_matches(config: dict[str, Any], matches: list[dict[str, Any]]) -> int:
2334
+ """Returns the matches that a tournament plays by its plan and by its results so far.
2335
+
2336
+ The replays and deciding matches that results have brought add to the plan, and the count is
2337
+ never below the matches already scheduled.
2338
+ """
2339
+ count = len(config['entrants'])
2340
+ planned = 0
2341
+ for settings in config['stages']:
2342
+ planned += _planned(settings, count)
2343
+ count = settings['advance']
2344
+ # A replay ends its key in r and a deciding match in d, each followed by its number.
2345
+ extra = sum(1 for match in matches if match['key'].rsplit('/', 1)[-1][:1] in ('r', 'd'))
2346
+ return max(planned + extra, len(matches))
2347
+
2348
+
2349
+ def evaluate(config: dict[str, Any], results: dict[str, Any]) -> dict[str, Any]:
2350
+ """Replays a tournament from its configuration and results, as far as the results reach.
2351
+
2352
+ Args:
2353
+ config: The configuration, as `check` returns it.
2354
+ results: The result of each played match by key, each `{'score': [home, away]}` with
2355
+ optional `cards`, `[[yellow, red], [yellow, red]]`, and `match`, its recording.
2356
+
2357
+ Returns:
2358
+ The stages with their rounds, fixtures, tables and rankings, every match that the known
2359
+ sides allow with its seed and result, the final ranking and the champion once decided.
2360
+ """
2361
+ run = _Run(config, results)
2362
+ stages = []
2363
+ entrants: list[int] | None = seeding(config)
2364
+ for index, settings in enumerate(config['stages']):
2365
+ view: dict[str, Any] = {
2366
+ 'index': index,
2367
+ **named_from('name', settings),
2368
+ 'format': settings['format'],
2369
+ 'scenario': settings['scenario'],
2370
+ 'advance': settings['advance'],
2371
+ 'entrants': entrants,
2372
+ 'rounds': [],
2373
+ 'tables': [],
2374
+ 'ranking': None,
2375
+ }
2376
+ if entrants is not None:
2377
+ if index > 0:
2378
+ entrants = _reseed(config, settings, index, entrants)
2379
+ view['entrants'] = entrants
2380
+ view.update(_STAGES[settings['format']](run, index, settings, entrants))
2381
+ fixtures = [f for r in view['rounds'] for f in r['fixtures']]
2382
+ if view['ranking'] is not None:
2383
+ view['state'] = 'done'
2384
+ elif entrants is None:
2385
+ view['state'] = 'waiting'
2386
+ else:
2387
+ view['state'] = 'playing' if any(f['state'] == 'done' for f in fixtures) else 'ready'
2388
+ stages.append(view)
2389
+ last = index == len(config['stages']) - 1
2390
+ if view['ranking'] is None:
2391
+ entrants = None
2392
+ elif not last:
2393
+ entrants = view['ranking'][: settings['advance']]
2394
+ ranking = None
2395
+ if stages[-1]['ranking'] is not None:
2396
+ ranking = list(stages[-1]['ranking'])
2397
+ for view in reversed(stages[:-1]):
2398
+ ranking += [e for e in view['ranking'] if e not in ranking]
2399
+ matches = list(run.matches.values())
2400
+ played = sum(1 for match in matches if match['result'] is not None)
2401
+ complete = ranking is not None
2402
+ return {
2403
+ 'stages': stages,
2404
+ 'matches': matches,
2405
+ 'ranking': ranking,
2406
+ 'champion': ranking[0] if ranking else None,
2407
+ 'complete': complete,
2408
+ 'played': played,
2409
+ # A finished tournament played what it expected, and one still to play counts its plan.
2410
+ 'expected': played if complete else expected_matches(config, matches),
2411
+ }
2412
+
2413
+
2414
+ def without_spoilers(
2415
+ entry: dict[str, Any], results: dict[str, Any], watched: Any
2416
+ ) -> dict[str, Any]:
2417
+ """Returns a tournament as a person sees it who has watched only some of its matches.
2418
+
2419
+ The tournament is evaluated again from the results of the watched matches alone, so its
2420
+ brackets, tables, ranking and champion tell nothing that an unwatched match decided, and a
2421
+ side that such a match decides reads as where it comes from. A played match that waits to be
2422
+ watched keeps its recording in `unseen` and loses its result, and `spoilers` lists the keys of
2423
+ those that can be watched now in the order of the schedule, as Watch next takes them, beside
2424
+ the keys of every played match and the counts of the hidden and the watched ones.
2425
+
2426
+ Args:
2427
+ entry: The tournament in full, as the runner's `get` or the mock server gives it.
2428
+ results: The result of every played match by key.
2429
+ watched: The keys of the matches that the person has watched or revealed.
2430
+
2431
+ Raises:
2432
+ TournamentError: If `watched` is no list of up to 10000 keys.
2433
+ """
2434
+ if (
2435
+ not isinstance(watched, list)
2436
+ or len(watched) > 10000
2437
+ or not all(isinstance(key, str) for key in watched)
2438
+ ):
2439
+ raise TournamentMessageError('server.tournament.watched')
2440
+ seen = set(watched)
2441
+ config = entry['config']
2442
+ view = evaluate(config, {key: value for key, value in results.items() if key in seen})
2443
+ full = {match['key']: match for match in entry['matches']}
2444
+ matches, unseen = [], []
2445
+ for match in view['matches']:
2446
+ shown = dict(full.get(match['key'], {**match, 'state': 'ready'}))
2447
+ result = results.get(match['key'])
2448
+ if result is not None and match['key'] not in seen:
2449
+ shown.update(
2450
+ result=None,
2451
+ state='unseen',
2452
+ unseen={'match': result.get('match'), 'forfeit': bool(result.get('forfeit'))},
2453
+ )
2454
+ unseen.append(match['key'])
2455
+ matches.append(shown)
2456
+ champion = view['champion']
2457
+ return {
2458
+ **entry,
2459
+ # The label of a champion still to watch goes with its text.
2460
+ 'champion_id': None,
2461
+ 'champion_values': None,
2462
+ **_messages.named(
2463
+ 'champion', None if champion is None else label_of(config['entrants'][champion])
2464
+ ),
2465
+ 'champion_color': None if champion is None else config['entrants'][champion]['color'],
2466
+ 'stages_view': view['stages'],
2467
+ 'matches': matches,
2468
+ 'ranking': view['ranking'],
2469
+ 'champion_index': champion,
2470
+ # The keys of the played matches tell no outcome, and let Reveal all reveal them.
2471
+ 'spoilers': {
2472
+ 'unseen': unseen,
2473
+ 'played': sorted(results),
2474
+ 'hidden': sum(1 for key in results if key not in seen),
2475
+ 'watched': sum(1 for key in results if key in seen),
2476
+ },
2477
+ }
2478
+
2479
+
2480
+ def ready(view: dict[str, Any]) -> list[dict[str, Any]]:
2481
+ """Returns the matches of an evaluation that can play now, in the order of the schedule."""
2482
+ return [match for match in view['matches'] if match['result'] is None]
2483
+
2484
+
2485
+ def check_result(result: Any) -> dict[str, Any]:
2486
+ """Returns a match result in canonical form, or raises `TournamentError`.
2487
+
2488
+ A result may hold `rule`, the rule that a side broke under strict rules, which ended the match
2489
+ as a forfeit, and `stats`, the steps of the match with the calls, timings and actions of each
2490
+ side, which the ratings and the files of results read. Each keeps only the fields it knows,
2491
+ of the types it knows, so a file edited by hand reads as far as it can.
2492
+ """
2493
+ if not isinstance(result, dict) or set(result) - {
2494
+ 'score',
2495
+ 'cards',
2496
+ 'match',
2497
+ 'forfeit',
2498
+ 'rule',
2499
+ 'stats',
2500
+ }:
2501
+ raise TournamentMessageError('server.tournament.result', result=repr(result))
2502
+ score = result.get('score')
2503
+ if not (
2504
+ isinstance(score, list) and len(score) == 2 and all(_integer(v) and v >= 0 for v in score)
2505
+ ):
2506
+ raise TournamentMessageError('server.tournament.score', score=repr(score))
2507
+ cards = result.get('cards', [[0, 0], [0, 0]])
2508
+ if not (
2509
+ isinstance(cards, list)
2510
+ and len(cards) == 2
2511
+ and all(
2512
+ isinstance(side, list) and len(side) == 2 and all(_integer(v) and v >= 0 for v in side)
2513
+ for side in cards
2514
+ )
2515
+ ):
2516
+ raise TournamentMessageError('server.tournament.cards', cards=repr(cards))
2517
+ match = result.get('match')
2518
+ if match is not None and not isinstance(match, str):
2519
+ raise TournamentMessageError('server.tournament.recording', match=repr(match))
2520
+ checked = {
2521
+ 'score': list(score),
2522
+ 'cards': [list(side) for side in cards],
2523
+ 'match': match,
2524
+ 'forfeit': bool(result.get('forfeit', False)),
2525
+ }
2526
+ rule = _checked_rule(result.get('rule'))
2527
+ if rule is not None:
2528
+ checked['rule'] = rule
2529
+ stats = _checked_stats(result.get('stats'))
2530
+ if stats is not None:
2531
+ checked['stats'] = stats
2532
+ return checked
2533
+
2534
+
2535
+ # The rules that a side may break under strict rules, each ending the match as its forfeit.
2536
+ RULES = (
2537
+ 'load',
2538
+ 'reset',
2539
+ 'crash',
2540
+ 'timeout',
2541
+ 'invalid',
2542
+ 'mean',
2543
+ 'changed',
2544
+ 'memory',
2545
+ 'threads',
2546
+ 'process',
2547
+ 'total_time',
2548
+ 'deterministic',
2549
+ )
2550
+
2551
+
2552
+ def _checked_rule(rule: Any) -> dict[str, Any] | None:
2553
+ """Returns the rule that a result says a side broke, with the fields of their types."""
2554
+ if not isinstance(rule, dict) or rule.get('side') not in ('home', 'away', 'both'):
2555
+ return None
2556
+ found: dict[str, Any] = {
2557
+ 'side': rule['side'],
2558
+ 'kind': rule.get('kind') if rule.get('kind') in RULES else 'crash',
2559
+ 'reason': str(rule.get('reason', ''))[:2000],
2560
+ 'reason_id': rule.get('reason_id') if isinstance(rule.get('reason_id'), str) else None,
2561
+ 'reason_values': (
2562
+ rule.get('reason_values') if isinstance(rule.get('reason_values'), dict) else None
2563
+ ),
2564
+ 'detail': rule.get('detail')
2565
+ if rule.get('detail') in ('count', 'type', 'range', 'size')
2566
+ else None,
2567
+ 'step': rule.get('step') if _integer(rule.get('step')) else 0,
2568
+ 'at': [0, 0],
2569
+ }
2570
+ at = rule.get('at')
2571
+ if isinstance(at, list) and len(at) == 2 and all(_integer(v) and v >= 0 for v in at):
2572
+ found['at'] = list(at)
2573
+ return found
2574
+
2575
+
2576
+ def _checked_side_stats(side: Any) -> dict[str, Any] | None:
2577
+ if not isinstance(side, dict):
2578
+ return None
2579
+ found: dict[str, Any] = {'calls': side.get('calls') if _integer(side.get('calls')) else 0}
2580
+ for key in ('first_ms', 'mean_ms', 'max_ms', 'mean_after_first_ms', 'memory_mb'):
2581
+ value = side.get(key)
2582
+ found[key] = round(float(value), 3) if _number(value) and value >= 0 else None
2583
+ actions = side.get('actions')
2584
+ found['actions'] = (
2585
+ {
2586
+ str(name): count
2587
+ for name, count in actions.items()
2588
+ if str(name).isdigit() and _integer(count) and count >= 0
2589
+ }
2590
+ if isinstance(actions, dict)
2591
+ else {}
2592
+ )
2593
+ return found
2594
+
2595
+
2596
+ def _checked_stats(stats: Any) -> dict[str, Any] | None:
2597
+ """Returns the steps, call timings and action counts that a result keeps of its match."""
2598
+ if not isinstance(stats, dict):
2599
+ return None
2600
+ sides = stats.get('sides')
2601
+ sides = sides if isinstance(sides, list) and len(sides) == 2 else [None, None]
2602
+ return {
2603
+ 'steps': stats.get('steps') if _integer(stats.get('steps')) else None,
2604
+ 'cut_short': stats.get('cut_short') if isinstance(stats.get('cut_short'), bool) else None,
2605
+ 'sides': [_checked_side_stats(side) for side in sides],
2606
+ }
2607
+
2608
+
2609
+ def update_ratings(
2610
+ ratings: dict[str, dict[str, Any]], home: dict[str, Any], away: dict[str, Any], score: list[int]
2611
+ ) -> None:
2612
+ """Moves the ratings of two entrants after a match, by the Elo formula.
2613
+
2614
+ Two entrants of one team share its rating, so a match between them leaves it as it is.
2615
+ """
2616
+ if home['key'] == away['key']:
2617
+ return
2618
+ first = ratings.setdefault(
2619
+ home['key'], {'name': home['name'], 'rating': BASE_RATING, 'matches': 0}
2620
+ )
2621
+ second = ratings.setdefault(
2622
+ away['key'], {'name': away['name'], 'rating': BASE_RATING, 'matches': 0}
2623
+ )
2624
+ expected = 1 / (1 + 10 ** ((second['rating'] - first['rating']) / 400))
2625
+ actual = 1.0 if score[0] > score[1] else 0.5 if score[0] == score[1] else 0.0
2626
+ change = RATING_STEP * (actual - expected)
2627
+ first['rating'] = round(first['rating'] + change, 2)
2628
+ second['rating'] = round(second['rating'] - change, 2)
2629
+ first['matches'] += 1
2630
+ second['matches'] += 1
2631
+ # The ratings keep the name of each entrant as the tournament does, a label when made.
2632
+ set_named(first, 'name', label_of(home))
2633
+ set_named(second, 'name', label_of(away))
2634
+
2635
+
2636
+ def match_title(tournament: dict[str, Any], home: Any, away: Any) -> dict[str, Any]:
2637
+ """Returns the title of the recording of a tournament match, as a label.
2638
+
2639
+ The title reads as the tournament, the match and its two sides, such as Spring cup, Final,
2640
+ Built-in AI 0.80 against Rex, and the page shows each part in its own language.
2641
+
2642
+ Args:
2643
+ tournament: What the recording keeps of its tournament, as the runner gives it, whose
2644
+ name and match label may be labels or, in a recording of before, text.
2645
+ home: The name of the home side, a label or text.
2646
+ away: The name of the away side, a label or text.
2647
+ """
2648
+ return _label(
2649
+ 'title',
2650
+ tournament=label_of(tournament, 'name') or '',
2651
+ match=label_of(tournament, 'label') or '',
2652
+ home=home,
2653
+ away=away,
2654
+ )
2655
+
2656
+
2657
+ # The settings that a preset holds, the format part of a configuration.
2658
+ _PRESET_KEYS = (
2659
+ 'seeding',
2660
+ 'concurrency',
2661
+ 'forfeit_score',
2662
+ 'ability',
2663
+ 'strict',
2664
+ 'preflight',
2665
+ 'include',
2666
+ 'defaults',
2667
+ 'stages',
2668
+ )
2669
+
2670
+
2671
+ def add_legs(config: dict[str, Any], count: Any) -> dict[str, Any]:
2672
+ """Returns a configuration whose last stage, a league or groups, plays `count` legs more.
2673
+
2674
+ The matches of before keep their keys, seeds and results, and the added legs play after
2675
+ them, so a finished or running league takes more legs later and plays only the missing
2676
+ matches. The venue goes on in turn leg by leg.
2677
+
2678
+ Raises:
2679
+ TournamentError: If the last stage is no league or groups, or the legs would pass the most.
2680
+ """
2681
+ stage = config['stages'][-1]
2682
+ where = _where(len(config['stages']) - 1)
2683
+ if stage['format'] not in ('league', 'groups'):
2684
+ raise TournamentMessageError('server.tournament.legs_format', where=where)
2685
+ added = stage.get('added_legs') or 0
2686
+ room = MAX_LEGS + MAX_ADDED_LEGS - stage['legs'] - added
2687
+ if room < 1:
2688
+ raise TournamentMessageError(
2689
+ 'server.tournament.legs_most', where=where, most=MAX_LEGS + MAX_ADDED_LEGS
2690
+ )
2691
+ _count(count, _field('added_legs', where=where), 1, room)
2692
+ changed = copy.deepcopy(config)
2693
+ changed['stages'][-1]['added_legs'] = added + count
2694
+ return changed
2695
+
2696
+
2697
+ def legs_of(stage: dict[str, Any]) -> int:
2698
+ """Returns the legs that each meeting of a stage plays, with those added later."""
2699
+ return stage['legs'] + (stage.get('added_legs') or 0)
2700
+
2701
+
2702
+ def preset_config(name: str, presets: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
2703
+ """Returns the format part of a preset, its seeding, defaults and stages, as a copy."""
2704
+ table = PRESETS if presets is None else presets
2705
+ if name not in table:
2706
+ raise TournamentMessageError(
2707
+ 'server.tournament.preset', presets=', '.join(sorted(table)), name=repr(name)
2708
+ )
2709
+ preset = table[name]
2710
+ config = {key: copy.deepcopy(preset[key]) for key in _PRESET_KEYS if key in preset}
2711
+ config['preset'] = name
2712
+ return config
2713
+
2714
+
2715
+ def check_preset(preset: Any) -> dict[str, Any]:
2716
+ """Returns a preset that the user saves, the format part of a configuration with a title."""
2717
+ if not isinstance(preset, dict):
2718
+ raise TournamentMessageError('server.tournament.preset_object')
2719
+ allowed = {'title', 'description', *_PRESET_KEYS}
2720
+ unknown = set(preset) - allowed
2721
+ if unknown:
2722
+ raise TournamentMessageError('server.tournament.preset_settings', settings=sorted(unknown))
2723
+ result = {'title': _plain_text(preset.get('title'), _field('preset_title'), 60)}
2724
+ description = preset.get('description', '')
2725
+ if not isinstance(description, str) or len(description) > 400:
2726
+ raise TournamentMessageError('server.tournament.description')
2727
+ result['description'] = description
2728
+ stages = preset.get('stages')
2729
+ if not isinstance(stages, list) or not 1 <= len(stages) <= MAX_STAGES:
2730
+ raise TournamentMessageError('server.tournament.stages', most=MAX_STAGES)
2731
+ defaults = preset.get('defaults', {})
2732
+ for index, stage in enumerate(stages):
2733
+ _merge(stage, defaults if isinstance(defaults, dict) else {}, index)
2734
+ check_ability(preset.get('ability'), 'server.tournament.standard_ability')
2735
+ check_strict(preset.get('strict'))
2736
+ check_include(preset.get('include'))
2737
+ if not isinstance(preset.get('preflight', False), bool):
2738
+ raise TournamentMessageError('server.tournament.preflight', value=repr(preset['preflight']))
2739
+ for key in _PRESET_KEYS:
2740
+ if key in preset:
2741
+ result[key] = copy.deepcopy(preset[key])
2742
+ return result