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.
- cuju/render/__init__.py +759 -0
- cuju/render/__main__.py +1065 -0
- cuju/render/_export/__init__.py +3 -0
- cuju/render/_export/feed.py +542 -0
- cuju/render/_export/sizes.py +157 -0
- cuju/render/_export/video.py +1618 -0
- cuju/render/_export/video_jobs.py +408 -0
- cuju/render/_format/__init__.py +3 -0
- cuju/render/_format/annotations.py +235 -0
- cuju/render/_format/codec.py +307 -0
- cuju/render/_format/keep.py +121 -0
- cuju/render/_format/recording.py +860 -0
- cuju/render/_format/writer.py +54 -0
- cuju/render/_guide/__init__.py +3 -0
- cuju/render/_guide/doctor.py +523 -0
- cuju/render/_guide/help.py +62 -0
- cuju/render/_guide/tutorial.py +254 -0
- cuju/render/_http/__init__.py +3 -0
- cuju/render/_http/commands.py +57 -0
- cuju/render/_http/headers.py +103 -0
- cuju/render/_http/launch.py +500 -0
- cuju/render/_http/launchers.py +408 -0
- cuju/render/_http/pwa.py +128 -0
- cuju/render/_http/server.py +2105 -0
- cuju/render/_http/window.py +360 -0
- cuju/render/_moments/__init__.py +3 -0
- cuju/render/_moments/captions.py +284 -0
- cuju/render/_moments/events.py +1123 -0
- cuju/render/_moments/search.py +694 -0
- cuju/render/_packages/__init__.py +3 -0
- cuju/render/_packages/glsl.py +746 -0
- cuju/render/_packages/loudness.py +334 -0
- cuju/render/_packages/pack.py +1959 -0
- cuju/render/_packages/presentation.py +1376 -0
- cuju/render/_packages/vocabulary.py +609 -0
- cuju/render/_playing/__init__.py +3 -0
- cuju/render/_playing/achievements.py +463 -0
- cuju/render/_playing/bench.py +609 -0
- cuju/render/_playing/challenges.py +1813 -0
- cuju/render/_playing/demo.py +323 -0
- cuju/render/_playing/draw.py +211 -0
- cuju/render/_playing/errors.py +20 -0
- cuju/render/_playing/game.py +645 -0
- cuju/render/_playing/limits.py +339 -0
- cuju/render/_playing/play.py +926 -0
- cuju/render/_playing/policy_worker.py +250 -0
- cuju/render/_playing/strict.py +810 -0
- cuju/render/_registry/__init__.py +3 -0
- cuju/render/_registry/clips.py +394 -0
- cuju/render/_registry/joints.py +108 -0
- cuju/render/_registry/teams.py +2058 -0
- cuju/render/_store/__init__.py +3 -0
- cuju/render/_store/home.py +70 -0
- cuju/render/_store/library.py +604 -0
- cuju/render/_store/samples.py +163 -0
- cuju/render/_text/__init__.py +3 -0
- cuju/render/_text/languages.py +637 -0
- cuju/render/_text/messages.py +1396 -0
- cuju/render/_tournaments/__init__.py +3 -0
- cuju/render/_tournaments/results.py +547 -0
- cuju/render/_tournaments/tournament.py +2742 -0
- cuju/render/_tournaments/tournament_cli.py +587 -0
- cuju/render/_tournaments/tournament_runner.py +1975 -0
- cuju/render/_version.py +6 -0
- cuju/render/_watch/__init__.py +3 -0
- cuju/render/_watch/grid.py +265 -0
- cuju/render/_watch/live.py +664 -0
- cuju/render/_watch/notebook.py +79 -0
- cuju/render/_watch/show.py +171 -0
- cuju/render/samples/sample-11v11.cjr +0 -0
- cuju/render/samples/sample-5v5.cjr +0 -0
- cuju/render/samples/teams/starter/main.py +33 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
- cuju/render/web/assets/adboard_polygon.webp +0 -0
- cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
- cuju/render/web/assets/ball.glb +0 -0
- cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/corner.mp4 +0 -0
- cuju/render/web/assets/challenges/corner.webp +0 -0
- cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
- cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
- cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
- cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
- cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
- cuju/render/web/assets/challenges/empty_goal.webp +0 -0
- cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
- cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
- cuju/render/web/assets/challenges/manifest.json +375 -0
- cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
- cuju/render/web/assets/challenges/one_on_one.webp +0 -0
- cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
- cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
- cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
- cuju/render/web/assets/challenges/run_to_score.webp +0 -0
- cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
- cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
- cuju/render/web/assets/crowd.webp +0 -0
- cuju/render/web/assets/crowd_normal.webp +0 -0
- cuju/render/web/assets/cuju-logo-384.png +0 -0
- cuju/render/web/assets/cuju-logo.png +0 -0
- cuju/render/web/assets/cuju-logo.svg +46 -0
- cuju/render/web/assets/cuju-mark-light.svg +33 -0
- cuju/render/web/assets/cuju-mark.svg +33 -0
- cuju/render/web/assets/goal.glb +0 -0
- cuju/render/web/assets/grass.webp +0 -0
- cuju/render/web/assets/hair_black.png +0 -0
- cuju/render/web/assets/hair_blonde.png +0 -0
- cuju/render/web/assets/hair_brown.png +0 -0
- cuju/render/web/assets/hair_darkblonde.png +0 -0
- cuju/render/web/assets/hair_long01.glb +0 -0
- cuju/render/web/assets/hair_long02.glb +0 -0
- cuju/render/web/assets/hair_medium01.glb +0 -0
- cuju/render/web/assets/hair_red.png +0 -0
- cuju/render/web/assets/hair_short01.glb +0 -0
- cuju/render/web/assets/hair_short02.glb +0 -0
- cuju/render/web/assets/icons/cuju-192.png +0 -0
- cuju/render/web/assets/icons/cuju-512.png +0 -0
- cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
- cuju/render/web/assets/kit_goalkeeper.webp +0 -0
- cuju/render/web/assets/kit_mask.png +0 -0
- cuju/render/web/assets/kit_normal.webp +0 -0
- cuju/render/web/assets/manifest.json +601 -0
- cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
- cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
- cuju/render/web/assets/models/kaykit-knight.glb +0 -0
- cuju/render/web/assets/models/kaykit-knight.json +23 -0
- cuju/render/web/assets/models/kaykit-mage.glb +0 -0
- cuju/render/web/assets/models/kaykit-mage.json +23 -0
- cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
- cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
- cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
- cuju/render/web/assets/models/kaykit-ranger.json +23 -0
- cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
- cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
- cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
- cuju/render/web/assets/models/kaykit-rogue.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
- cuju/render/web/assets/pitch.webp +0 -0
- cuju/render/web/assets/pitch_normal.webp +0 -0
- cuju/render/web/assets/player.glb +0 -0
- cuju/render/web/assets/shoe.webp +0 -0
- cuju/render/web/assets/shoe_sole.png +0 -0
- cuju/render/web/assets/skin_01.png +0 -0
- cuju/render/web/assets/skin_02.png +0 -0
- cuju/render/web/assets/skin_03.png +0 -0
- cuju/render/web/assets/skin_04.png +0 -0
- cuju/render/web/assets/sounds/applause.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
- cuju/render/web/assets/sounds/city-horn.ogg +0 -0
- cuju/render/web/assets/sounds/city-loop.ogg +0 -0
- cuju/render/web/assets/sounds/city-siren.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
- cuju/render/web/assets/sounds/gulls.ogg +0 -0
- cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
- cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
- cuju/render/web/assets/sounds/sounds.json +46 -0
- cuju/render/web/assets/sounds/space-beep.ogg +0 -0
- cuju/render/web/assets/sounds/space-hum.ogg +0 -0
- cuju/render/web/assets/sounds/space-radio.ogg +0 -0
- cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
- cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
- cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
- cuju/render/web/easy.css +357 -0
- cuju/render/web/export.html +23 -0
- cuju/render/web/feed.css +262 -0
- cuju/render/web/feed.html +28 -0
- cuju/render/web/flat.css +120 -0
- cuju/render/web/home.css +837 -0
- cuju/render/web/index.html +373 -0
- cuju/render/web/js/boot.js +50 -0
- cuju/render/web/js/broadcast/audio.js +2141 -0
- cuju/render/web/js/broadcast/celebrate.js +1238 -0
- cuju/render/web/js/broadcast/decisions.js +609 -0
- cuju/render/web/js/broadcast/hud.js +352 -0
- cuju/render/web/js/broadcast/show.js +3614 -0
- cuju/render/web/js/broadcast/transition.js +2231 -0
- cuju/render/web/js/broadcast/voices.js +153 -0
- cuju/render/web/js/capture.js +101 -0
- cuju/render/web/js/core/accessibility.js +90 -0
- cuju/render/web/js/core/api.js +429 -0
- cuju/render/web/js/core/comfort.js +135 -0
- cuju/render/web/js/core/i18n.js +705 -0
- cuju/render/web/js/core/icons.js +167 -0
- cuju/render/web/js/core/input-settings.js +156 -0
- cuju/render/web/js/core/keys.js +277 -0
- cuju/render/web/js/core/language-ui.js +125 -0
- cuju/render/web/js/core/menu.js +853 -0
- cuju/render/web/js/core/names.js +113 -0
- cuju/render/web/js/core/page-structure.js +110 -0
- cuju/render/web/js/core/scenarios.js +109 -0
- cuju/render/web/js/core/spoilers.js +236 -0
- cuju/render/web/js/core/typeahead.js +38 -0
- cuju/render/web/js/export/dom-picture.js +330 -0
- cuju/render/web/js/export/export-host.js +97 -0
- cuju/render/web/js/export/feed-dialog.js +191 -0
- cuju/render/web/js/export/feed-params.js +266 -0
- cuju/render/web/js/export/feed.js +363 -0
- cuju/render/web/js/export/mp4-mux.js +295 -0
- cuju/render/web/js/export/photo-core.js +313 -0
- cuju/render/web/js/export/photo.js +1314 -0
- cuju/render/web/js/export/safe-frame.js +59 -0
- cuju/render/web/js/export/size-fields.js +103 -0
- cuju/render/web/js/export/sizes.js +178 -0
- cuju/render/web/js/export/video-boot.js +55 -0
- cuju/render/web/js/export/video-browser.js +325 -0
- cuju/render/web/js/export/video-export.js +417 -0
- cuju/render/web/js/export/video-plan.js +207 -0
- cuju/render/web/js/export/video-range.js +185 -0
- cuju/render/web/js/export/webm-mux.js +198 -0
- cuju/render/web/js/feed-boot.js +151 -0
- cuju/render/web/js/feed-frame.js +101 -0
- cuju/render/web/js/help/doctor.js +488 -0
- cuju/render/web/js/help/firstrun.js +379 -0
- cuju/render/web/js/help/help-center.js +438 -0
- cuju/render/web/js/help/install-offer.js +164 -0
- cuju/render/web/js/help/install-state.js +104 -0
- cuju/render/web/js/help/lessons-core.js +207 -0
- cuju/render/web/js/help/lessons.js +463 -0
- cuju/render/web/js/help/palette.js +584 -0
- cuju/render/web/js/help/pwa.js +257 -0
- cuju/render/web/js/help/tour-core.js +241 -0
- cuju/render/web/js/help/tour.js +461 -0
- cuju/render/web/js/main.js +2513 -0
- cuju/render/web/js/pages/draw.js +188 -0
- cuju/render/web/js/pages/fieldpage.js +530 -0
- cuju/render/web/js/pages/home.js +521 -0
- cuju/render/web/js/pages/matches.js +779 -0
- cuju/render/web/js/pages/moments-ui.js +359 -0
- cuju/render/web/js/pages/queue-filters.js +63 -0
- cuju/render/web/js/pages/recording-groups.js +80 -0
- cuju/render/web/js/pages/recordings.js +1069 -0
- cuju/render/web/js/play/bench.js +347 -0
- cuju/render/web/js/play/challenges.js +1302 -0
- cuju/render/web/js/play/medals.js +127 -0
- cuju/render/web/js/play/play.js +1953 -0
- cuju/render/web/js/play/teasers.js +196 -0
- cuju/render/web/js/play/touch.js +350 -0
- cuju/render/web/js/play/tournament-layout.js +487 -0
- cuju/render/web/js/play/tournament-results.js +441 -0
- cuju/render/web/js/play/tournament-strict.js +560 -0
- cuju/render/web/js/play/tournament.js +2585 -0
- cuju/render/web/js/players/animate.js +972 -0
- cuju/render/web/js/players/clips.js +224 -0
- cuju/render/web/js/players/kaykit.js +162 -0
- cuju/render/web/js/players/kits.js +676 -0
- cuju/render/web/js/players/number-patch.js +82 -0
- cuju/render/web/js/players/players.js +2694 -0
- cuju/render/web/js/players/recolor.js +893 -0
- cuju/render/web/js/players/retarget.js +325 -0
- cuju/render/web/js/players/rig-numbers.js +174 -0
- cuju/render/web/js/presentation/animator.js +231 -0
- cuju/render/web/js/presentation/assign.js +85 -0
- cuju/render/web/js/presentation/behaviors.js +440 -0
- cuju/render/web/js/presentation/budget.js +209 -0
- cuju/render/web/js/presentation/catalog.js +1674 -0
- cuju/render/web/js/presentation/clocks.js +72 -0
- cuju/render/web/js/presentation/commands.js +301 -0
- cuju/render/web/js/presentation/contract.js +240 -0
- cuju/render/web/js/presentation/effects.js +904 -0
- cuju/render/web/js/presentation/flash.js +605 -0
- cuju/render/web/js/presentation/freeze-worklet.js +22 -0
- cuju/render/web/js/presentation/glsl.js +464 -0
- cuju/render/web/js/presentation/host.js +323 -0
- cuju/render/web/js/presentation/index.js +2066 -0
- cuju/render/web/js/presentation/loudness-worklet.js +235 -0
- cuju/render/web/js/presentation/loudness.js +740 -0
- cuju/render/web/js/presentation/moments.js +536 -0
- cuju/render/web/js/presentation/post.js +327 -0
- cuju/render/web/js/presentation/preview.js +180 -0
- cuju/render/web/js/presentation/protocol.js +174 -0
- cuju/render/web/js/presentation/reach.js +158 -0
- cuju/render/web/js/presentation/rigging.js +130 -0
- cuju/render/web/js/presentation/sandbox.js +157 -0
- cuju/render/web/js/presentation/shaders.js +235 -0
- cuju/render/web/js/presentation/stage.js +402 -0
- cuju/render/web/js/presentation/teamaudio.js +722 -0
- cuju/render/web/js/presentation/tracks.js +197 -0
- cuju/render/web/js/presentation/values.js +81 -0
- cuju/render/web/js/recording/annotation-data.js +203 -0
- cuju/render/web/js/recording/captions.js +491 -0
- cuju/render/web/js/recording/codec.js +185 -0
- cuju/render/web/js/recording/decode-worker.js +70 -0
- cuju/render/web/js/recording/decode.js +94 -0
- cuju/render/web/js/recording/episodes.js +46 -0
- cuju/render/web/js/recording/format.js +461 -0
- cuju/render/web/js/recording/live.js +348 -0
- cuju/render/web/js/recording/moments-query.js +59 -0
- cuju/render/web/js/recording/moments.js +149 -0
- cuju/render/web/js/recording/pose.js +86 -0
- cuju/render/web/js/recording/timeline.js +275 -0
- cuju/render/web/js/teams/art.js +50 -0
- cuju/render/web/js/teams/clip-editor.js +293 -0
- cuju/render/web/js/teams/editor.js +1979 -0
- cuju/render/web/js/teams/library.js +344 -0
- cuju/render/web/js/teams/painter.js +633 -0
- cuju/render/web/js/teams/surprise-ui.js +195 -0
- cuju/render/web/js/teams/surprise.js +497 -0
- cuju/render/web/js/teams/team-check.js +127 -0
- cuju/render/web/js/watch/annotations.js +270 -0
- cuju/render/web/js/watch/flat.js +366 -0
- cuju/render/web/js/watch/grid.js +436 -0
- cuju/render/web/js/watch/pitch2d.js +289 -0
- cuju/render/web/js/watch/player-tools.js +192 -0
- cuju/render/web/js/watch/presets.js +201 -0
- cuju/render/web/js/world/arenas.js +997 -0
- cuju/render/web/js/world/ball.js +204 -0
- cuju/render/web/js/world/cameras.js +982 -0
- cuju/render/web/js/world/crowd.js +376 -0
- cuju/render/web/js/world/field.js +227 -0
- cuju/render/web/js/world/framing.js +145 -0
- cuju/render/web/js/world/occlusion.js +48 -0
- cuju/render/web/js/world/pitch.js +703 -0
- cuju/render/web/js/world/scene.js +395 -0
- cuju/render/web/js/world/sky.js +325 -0
- cuju/render/web/js/world/stadium.js +1262 -0
- cuju/render/web/js/world/vendor-sky.js +367 -0
- cuju/render/web/js/world/weather.js +264 -0
- cuju/render/web/languages/en.json +4750 -0
- cuju/render/web/lessons.json +137 -0
- cuju/render/web/manifest.webmanifest +23 -0
- cuju/render/web/style.css +8399 -0
- cuju/render/web/sw.js +235 -0
- cuju/render/web/vendor/fabric/LICENSE +16 -0
- cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
- cuju/render/web/vendor/lucide/LICENSE +43 -0
- cuju/render/web/vendor/lucide/icons.js +141 -0
- cuju/render/web/vendor/three/LICENSE +21 -0
- cuju/render/web/vendor/three/build/three.core.js +60586 -0
- cuju/render/web/vendor/three/build/three.module.js +19719 -0
- cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
- cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
- cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
- cuju_render-0.1.0.dist-info/METADATA +116 -0
- cuju_render-0.1.0.dist-info/RECORD +382 -0
- cuju_render-0.1.0.dist-info/WHEEL +4 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
- 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
|