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,1975 @@
|
|
|
1
|
+
# Copyright 2026 The cuju Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Runs the renderer's tournaments, keeps them in files and reads the results of their matches.
|
|
5
|
+
|
|
6
|
+
Each tournament lives in a JSON file of the folder `tournaments`, its configuration, the result of
|
|
7
|
+
every match played and its state, written through a temporary file as the request history is, so a
|
|
8
|
+
tournament outlasts the server and resumes where it stopped. A match whose process was cut short
|
|
9
|
+
plays again from the start with the same seed, so it gives the same result.
|
|
10
|
+
|
|
11
|
+
The matches of agents play headless in processes of their own, as requested matches do, as many at
|
|
12
|
+
once as the tournament allows. A match with a person waits for that person to start it on the Play
|
|
13
|
+
page, and its result comes from the game's recording. One process at a time runs a tournament, the
|
|
14
|
+
server or `cuju render tournament run`, which a lock file beside the tournament names.
|
|
15
|
+
|
|
16
|
+
Every file that the renderer writes carries a seal, a keyed digest of its content under a key that
|
|
17
|
+
only this home folder holds. A file without a valid seal, one dropped into the folder or edited by
|
|
18
|
+
hand, loads stopped, so it plays only once a person resumes it, and every file plays at most as
|
|
19
|
+
many matches at once as the rules allow.
|
|
20
|
+
|
|
21
|
+
A tournament under strict rules plays each match of agents with each registered team in a process
|
|
22
|
+
of its own within the limits of its rules, and a side that breaks one forfeits the match at once,
|
|
23
|
+
by the forfeit score, and the tournament goes on. A tournament with a pre-flight first checks
|
|
24
|
+
every registered team, and leaves out those that fail, before its first match.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import collections
|
|
30
|
+
import contextlib
|
|
31
|
+
import hashlib
|
|
32
|
+
import hmac
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import secrets
|
|
37
|
+
import shutil
|
|
38
|
+
import struct
|
|
39
|
+
import subprocess
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from cuju.errors import ArgumentError
|
|
47
|
+
|
|
48
|
+
from .._format import recording as _recording
|
|
49
|
+
from .._registry import teams as _teams
|
|
50
|
+
from .._store import home as _home
|
|
51
|
+
from .._store import library as _library
|
|
52
|
+
from .._text import messages as _messages
|
|
53
|
+
from .._text.messages import MessageError
|
|
54
|
+
from . import results as _results
|
|
55
|
+
from . import tournament as rules
|
|
56
|
+
|
|
57
|
+
_LOGGER = logging.getLogger('cuju')
|
|
58
|
+
_STATES = ('running', 'stopped', 'done', 'cancelled')
|
|
59
|
+
# A match of a tournament ends after this many seconds, as a requested match does.
|
|
60
|
+
MATCH_SECONDS = 30 * 60
|
|
61
|
+
_SCORE = struct.Struct('<2i')
|
|
62
|
+
_FILE_VERSION = 1
|
|
63
|
+
# How long a change waits for the process that runs its tournament to apply it, in seconds.
|
|
64
|
+
_ASK_SECONDS = 10.0
|
|
65
|
+
# The file of the home folder that holds the key of the seals of its tournament files.
|
|
66
|
+
_KEY_FILE = 'tournaments.key'
|
|
67
|
+
# The key of the pre-flight among the matches that a tournament runs, which no match key takes.
|
|
68
|
+
_PREFLIGHT = '#preflight'
|
|
69
|
+
# A check of a team ends after this many seconds, as a long load and few calls take at most.
|
|
70
|
+
PREFLIGHT_SECONDS = 15 * 60
|
|
71
|
+
_KEYS: dict[str, bytes] = {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def folder() -> Path:
|
|
75
|
+
"""Returns the folder of the tournaments, created when missing."""
|
|
76
|
+
return _home.folder('tournaments')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _threads() -> int:
|
|
80
|
+
"""Returns the threads of this machine, which the matches at once share."""
|
|
81
|
+
return max(1, os.cpu_count() or 1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def replay_path(identifier: str, key: str) -> Path:
|
|
85
|
+
"""Returns the file that keeps what replays a match of a tournament, its seed and actions."""
|
|
86
|
+
return folder() / f'{identifier}.replays' / f'{key.replace("/", "_")}.json'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def replay_match(identifier: str, key: str) -> dict[str, Any]:
|
|
90
|
+
"""Plays a match of a tournament under strict rules again from its seed and actions.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The match's key, the steps and score that the replay gave, and those that the match kept,
|
|
94
|
+
with whether they agree.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ArgumentError: If the match kept nothing to replay.
|
|
98
|
+
"""
|
|
99
|
+
from .._playing import strict as _strict
|
|
100
|
+
|
|
101
|
+
found = _read(replay_path(identifier, key), None)
|
|
102
|
+
if not isinstance(found, dict) or not isinstance(found.get('actions'), list):
|
|
103
|
+
raise MessageError('server.tournament.no_replay', key=repr(key))
|
|
104
|
+
again = _strict.replay(found)
|
|
105
|
+
kept = {'steps': len(found['actions']), 'score': found.get('score')}
|
|
106
|
+
return {'key': key, 'replay': again, 'kept': kept, 'same': again == kept}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _write(path: Path, value: Any) -> None:
|
|
110
|
+
"""Writes JSON through a temporary file, so a stop halfway leaves the old file whole."""
|
|
111
|
+
temporary = path.with_name(path.name + '.part')
|
|
112
|
+
try:
|
|
113
|
+
temporary.write_text(json.dumps(value), encoding='utf-8')
|
|
114
|
+
os.replace(temporary, path)
|
|
115
|
+
except OSError:
|
|
116
|
+
with contextlib.suppress(OSError):
|
|
117
|
+
temporary.unlink()
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _key() -> bytes | None:
|
|
122
|
+
"""Returns the key that seals the tournament files of this home folder, made on first use.
|
|
123
|
+
|
|
124
|
+
Returns `None` when the key cannot be read or made, and every file then loads as one that
|
|
125
|
+
another install wrote.
|
|
126
|
+
"""
|
|
127
|
+
path = _home.home() / _KEY_FILE
|
|
128
|
+
if str(path) in _KEYS:
|
|
129
|
+
return _KEYS[str(path)]
|
|
130
|
+
for _ in range(50):
|
|
131
|
+
try:
|
|
132
|
+
text = path.read_text(encoding='ascii').strip()
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
try:
|
|
135
|
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
136
|
+
except FileExistsError:
|
|
137
|
+
continue
|
|
138
|
+
except OSError as error:
|
|
139
|
+
_LOGGER.warning('cuju render cannot make the key of its tournaments, %s', error)
|
|
140
|
+
return None
|
|
141
|
+
with os.fdopen(descriptor, 'w', encoding='ascii') as stream:
|
|
142
|
+
stream.write(secrets.token_hex(32))
|
|
143
|
+
continue
|
|
144
|
+
except (OSError, ValueError) as error:
|
|
145
|
+
_LOGGER.warning('cuju render cannot read the key of its tournaments, %s', error)
|
|
146
|
+
return None
|
|
147
|
+
if len(text) == 64:
|
|
148
|
+
with contextlib.suppress(ValueError):
|
|
149
|
+
_KEYS[str(path)] = bytes.fromhex(text)
|
|
150
|
+
return _KEYS[str(path)]
|
|
151
|
+
# Another process may be writing the key this moment.
|
|
152
|
+
time.sleep(0.02)
|
|
153
|
+
_LOGGER.warning('cuju render reads no key of its tournaments in %s', path.name)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _seal(text: str) -> str | None:
|
|
158
|
+
"""Returns the seal of the text of a tournament file, or `None` without a key."""
|
|
159
|
+
key = _key()
|
|
160
|
+
return None if key is None else hmac.new(key, text.encode(), hashlib.sha256).hexdigest()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _write_record(path: Path, record: dict[str, Any]) -> None:
|
|
164
|
+
"""Writes a tournament with the seal of its content last, which `_sealed` checks."""
|
|
165
|
+
body = {key: value for key, value in record.items() if key != 'seal'}
|
|
166
|
+
seal = _seal(json.dumps(body))
|
|
167
|
+
_write(path, body if seal is None else {**body, 'seal': seal})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _sealed(entry: dict[str, Any]) -> bool:
|
|
171
|
+
"""Takes the seal out of a tournament read from its file and returns whether it is valid.
|
|
172
|
+
|
|
173
|
+
The JSON text of what a file holds, written again, is the text it was written from, so the
|
|
174
|
+
seal of the rest of the entry is the seal written beside it.
|
|
175
|
+
"""
|
|
176
|
+
seal = entry.pop('seal', None)
|
|
177
|
+
if not isinstance(seal, str):
|
|
178
|
+
return False
|
|
179
|
+
expected = _seal(json.dumps(entry))
|
|
180
|
+
return expected is not None and hmac.compare_digest(seal, expected)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _read(path: Path, default: Any) -> Any:
|
|
184
|
+
try:
|
|
185
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
186
|
+
except (OSError, ValueError):
|
|
187
|
+
return default
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# Scenarios, fit and results.
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def scenario_duration(scenario: str) -> int:
|
|
194
|
+
"""Returns the steps that a shipped scenario lasts by its own configuration."""
|
|
195
|
+
from cuju.scenarios import Builder, load_scenario
|
|
196
|
+
|
|
197
|
+
builder = Builder(0)
|
|
198
|
+
load_scenario(_teams.scenario_path(scenario))(builder)
|
|
199
|
+
return int(builder.config().game_duration)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def length_scenario(scenario: str, length: int) -> str:
|
|
203
|
+
"""Returns a scenario file that plays `scenario` for `length` steps, written once.
|
|
204
|
+
|
|
205
|
+
The file builds the shipped scenario and changes only its duration, and the second half
|
|
206
|
+
starts at the same share of the match as in the original, so a shorter full match still has
|
|
207
|
+
two halves.
|
|
208
|
+
"""
|
|
209
|
+
if not rules.MIN_LENGTH <= int(length) <= rules.MAX_LENGTH:
|
|
210
|
+
raise MessageError(
|
|
211
|
+
'server.tournament.length',
|
|
212
|
+
lowest=rules.MIN_LENGTH,
|
|
213
|
+
highest=rules.MAX_LENGTH,
|
|
214
|
+
length=repr(length),
|
|
215
|
+
)
|
|
216
|
+
source = _teams.scenario_path(scenario)
|
|
217
|
+
if not source.is_file():
|
|
218
|
+
raise MessageError('server.scenario', scenario=repr(scenario))
|
|
219
|
+
target = _home.folder('scenarios') / f'{scenario}_steps_{int(length)}.py'
|
|
220
|
+
text = (
|
|
221
|
+
f'"""GRF\'s scenario `{scenario}` for {int(length)} steps, written by cuju render."""\n\n'
|
|
222
|
+
'from cuju.scenarios import load_scenario\n\n'
|
|
223
|
+
f'_BUILD = load_scenario({str(source)!r})\n\n\n'
|
|
224
|
+
'def build_scenario(builder):\n'
|
|
225
|
+
' _BUILD(builder)\n'
|
|
226
|
+
' config = builder.config()\n'
|
|
227
|
+
' duration, half = config.game_duration, config.second_half\n'
|
|
228
|
+
f' config.game_duration = {int(length)}\n'
|
|
229
|
+
' if 0 < half < duration:\n'
|
|
230
|
+
f' config.second_half = max(1, half * {int(length)} // duration)\n'
|
|
231
|
+
)
|
|
232
|
+
if _read_text(target) != text:
|
|
233
|
+
temporary = target.with_name(f'{target.name}.{os.getpid()}.part')
|
|
234
|
+
temporary.write_text(text, encoding='utf-8')
|
|
235
|
+
os.replace(temporary, target)
|
|
236
|
+
return str(target)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _read_text(path: Path) -> str | None:
|
|
240
|
+
try:
|
|
241
|
+
return path.read_text(encoding='utf-8')
|
|
242
|
+
except OSError:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def fits(
|
|
247
|
+
entrant: dict[str, Any], scenario: str, entries: dict[str, dict[str, Any]] | None = None
|
|
248
|
+
) -> str | dict[str, Any] | None:
|
|
249
|
+
"""Returns why an entrant cannot play a scenario on either side, or `None` when it can.
|
|
250
|
+
|
|
251
|
+
The reason is a label of its server message when it has one, so the page shows the whole of
|
|
252
|
+
the refusal in its own language, else its text. `entries` holds the teams of the registry by
|
|
253
|
+
identifier, read once for a whole check, since reading the registry for each entrant and
|
|
254
|
+
stage made the review of a few teams take seconds.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def entry(identifier: str) -> dict[str, Any]:
|
|
258
|
+
if entries is None:
|
|
259
|
+
return _teams.entry(identifier)
|
|
260
|
+
if identifier not in entries:
|
|
261
|
+
raise MessageError('server.teams.unknown', identifier=repr(identifier))
|
|
262
|
+
return entries[identifier]
|
|
263
|
+
|
|
264
|
+
builtin = entry(_teams.BUILTIN)
|
|
265
|
+
try:
|
|
266
|
+
# The random entrant's side is no team of the registry, which the rules name apart.
|
|
267
|
+
team = (
|
|
268
|
+
_teams.side_entry(entrant['team'])
|
|
269
|
+
if entrant['team'] == _teams.RANDOM_ACTIONS
|
|
270
|
+
else entry(entrant['team'])
|
|
271
|
+
)
|
|
272
|
+
if entrant['kind'] == 'person':
|
|
273
|
+
for humans in ((True, False), (False, True)):
|
|
274
|
+
sides = [team, builtin] if humans[0] else [builtin, team]
|
|
275
|
+
_teams.game_agents(sides, scenario, humans)
|
|
276
|
+
else:
|
|
277
|
+
_teams.check_scenario([team, builtin], scenario)
|
|
278
|
+
_teams.check_scenario([builtin, team], scenario)
|
|
279
|
+
except ArgumentError as error:
|
|
280
|
+
ids = _messages.id_fields(error)
|
|
281
|
+
values = ids.get('error_values') if isinstance(ids, dict) else None
|
|
282
|
+
# A team of the other size needs only its size, since the message names the rest.
|
|
283
|
+
if (
|
|
284
|
+
isinstance(ids, dict)
|
|
285
|
+
and ids.get('error_id') == 'server.teams.scenario_format'
|
|
286
|
+
and isinstance(values, dict)
|
|
287
|
+
):
|
|
288
|
+
return _messages.label('server.tournament.team_format', format=values.get('format'))
|
|
289
|
+
# The team's own refusal names the team and the scenario, which the tournament's message
|
|
290
|
+
# names too, so the tournament keeps only its reason.
|
|
291
|
+
if (
|
|
292
|
+
isinstance(ids, dict)
|
|
293
|
+
and ids.get('error_id') == 'server.teams.cannot_play'
|
|
294
|
+
and isinstance(values, dict)
|
|
295
|
+
and values.get('reason')
|
|
296
|
+
):
|
|
297
|
+
return values['reason']
|
|
298
|
+
return _error_label(str(error), ids)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _error_label(error: str, ids: dict[str, Any] | None) -> str | dict[str, Any]:
|
|
303
|
+
"""Returns an error as a label of its message, the value of another message, else its text.
|
|
304
|
+
|
|
305
|
+
The label passes on only with the values that its message names, as a report of a child
|
|
306
|
+
process does, so an error of a file of before stays its text.
|
|
307
|
+
"""
|
|
308
|
+
ids = ids if isinstance(ids, dict) else {}
|
|
309
|
+
found = {'id': ids.get('error_id'), 'text': error, 'values': ids.get('error_values')}
|
|
310
|
+
return found if _messages.valid_label(found) else error
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def warm() -> None:
|
|
314
|
+
"""Loads cuju's scenario builder and the list of scenarios, which every check of a tournament
|
|
315
|
+
reads, so the first review of the page answers at once.
|
|
316
|
+
|
|
317
|
+
The builder brings torch along, which takes seconds to import, so the server calls this in a
|
|
318
|
+
thread of its own as it starts. A failure here only leaves the first check to load them.
|
|
319
|
+
"""
|
|
320
|
+
try:
|
|
321
|
+
scenarios()
|
|
322
|
+
_teams.scenario_players('5_vs_5', 0)
|
|
323
|
+
except Exception as error:
|
|
324
|
+
_LOGGER.warning('cuju render could not load the scenarios ahead of time: %s', error)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def scenarios() -> list[str]:
|
|
328
|
+
"""Returns the scenarios that a stage may name."""
|
|
329
|
+
from cuju import bundled_scenarios
|
|
330
|
+
|
|
331
|
+
return sorted([*bundled_scenarios(), *_teams.renderer_scenarios()])
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def recording_result(identifier: str) -> dict[str, Any]:
|
|
335
|
+
"""Returns the score and cards of each side at the end of a recording of the library.
|
|
336
|
+
|
|
337
|
+
A player counts a yellow card when the last frame shows one and no red, and a red card when it
|
|
338
|
+
shows a red, so a second yellow counts as a red alone, as fair play rules count it.
|
|
339
|
+
"""
|
|
340
|
+
path = _library.path_of(identifier)
|
|
341
|
+
if path is None:
|
|
342
|
+
raise MessageError('server.library.unknown', identifier=repr(identifier))
|
|
343
|
+
header = _recording.read_header(path)
|
|
344
|
+
last = None
|
|
345
|
+
# A recording of version 2 decodes its last frame from the keyframe before it.
|
|
346
|
+
for frame in _recording.frames(path, start=max(0, (header.get('frame_count') or 0) - 1)):
|
|
347
|
+
last = frame
|
|
348
|
+
if last is None:
|
|
349
|
+
raise MessageError('server.tournament.no_frame', identifier=identifier)
|
|
350
|
+
players = header['players']
|
|
351
|
+
offset = _recording._FRAME_HEAD.size + len(players) * len(_recording.NODES) * 14
|
|
352
|
+
cards = [[0, 0], [0, 0]]
|
|
353
|
+
for index, player in enumerate(players):
|
|
354
|
+
state = last[offset + index]
|
|
355
|
+
team = 1 if player['team'] else 0
|
|
356
|
+
if state & 4:
|
|
357
|
+
cards[team][1] += 1
|
|
358
|
+
elif state & 2:
|
|
359
|
+
cards[team][0] += 1
|
|
360
|
+
return {'score': list(_SCORE.unpack_from(last, 8)), 'cards': cards, 'match': identifier}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def preflight(
|
|
364
|
+
team: str, scenario: str | None = None, limits: dict[str, Any] | None = None
|
|
365
|
+
) -> dict[str, Any]:
|
|
366
|
+
"""Checks a team in a process of its own, loading it and playing a few steps under strict rules.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
team: The team of the registry.
|
|
370
|
+
scenario: The scenario, by default the one of the team's size.
|
|
371
|
+
limits: The limits of strict rules, or `None` to check only that the team loads and plays.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
The report of `strict.preflight`, whether the team passed, the rule it broke otherwise and
|
|
375
|
+
its timings.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
ArgumentError: If the team is unknown, or the check itself fails.
|
|
379
|
+
"""
|
|
380
|
+
_teams.entry(team)
|
|
381
|
+
command = [*_home.python_command(), '-m', 'cuju.render._playing.strict', '--team', team]
|
|
382
|
+
if scenario is not None:
|
|
383
|
+
command += ['--scenario', scenario]
|
|
384
|
+
if limits is not None:
|
|
385
|
+
command += ['--limits', json.dumps(limits)]
|
|
386
|
+
try:
|
|
387
|
+
done = subprocess.run(
|
|
388
|
+
command,
|
|
389
|
+
stdin=subprocess.DEVNULL,
|
|
390
|
+
capture_output=True,
|
|
391
|
+
text=True,
|
|
392
|
+
encoding='utf-8',
|
|
393
|
+
errors='replace',
|
|
394
|
+
cwd=_home.process_folder(),
|
|
395
|
+
env=_home.process_environment(),
|
|
396
|
+
timeout=PREFLIGHT_SECONDS,
|
|
397
|
+
check=False,
|
|
398
|
+
)
|
|
399
|
+
except subprocess.TimeoutExpired as error:
|
|
400
|
+
raise MessageError('server.preflight.timeout', count=PREFLIGHT_SECONDS // 60) from error
|
|
401
|
+
report = None
|
|
402
|
+
for line in done.stdout.splitlines():
|
|
403
|
+
with contextlib.suppress(ValueError):
|
|
404
|
+
found = json.loads(line)
|
|
405
|
+
report = found if isinstance(found, dict) else report
|
|
406
|
+
if report is None:
|
|
407
|
+
found = _messages.exit_fields('server.requests.exit', done.returncode, done.stderr.strip())
|
|
408
|
+
raise MessageError('server.preflight.failed', reason=found['error'])
|
|
409
|
+
if 'error' in report and 'ok' not in report:
|
|
410
|
+
reported = _messages.report_fields(report)
|
|
411
|
+
if reported['error_id'] is not None:
|
|
412
|
+
raise MessageError(reported['error_id'], **reported['error_values'])
|
|
413
|
+
raise MessageError('server.preflight.failed', reason=reported['error'])
|
|
414
|
+
return report
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def check_team(team: str, document: Any) -> dict[str, Any]:
|
|
418
|
+
"""Checks one team as the Teams page asks, under strict rules with the limits it gives.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
team: The team of the registry.
|
|
422
|
+
document: An object with an optional `scenario` and optional `strict`, true or an object
|
|
423
|
+
of limits as a tournament takes them, the default limits when absent.
|
|
424
|
+
"""
|
|
425
|
+
document = document if isinstance(document, dict) else {}
|
|
426
|
+
scenario = document.get('scenario')
|
|
427
|
+
if scenario is not None and scenario not in scenarios():
|
|
428
|
+
raise MessageError('server.scenario', scenario=repr(scenario))
|
|
429
|
+
try:
|
|
430
|
+
limits = rules.check_strict(document.get('strict', True))
|
|
431
|
+
except rules.TournamentError as error:
|
|
432
|
+
raise _messages.argument_error(error) from error
|
|
433
|
+
return preflight(team, scenario, limits)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def preflight_config(config: Any) -> dict[str, Any]:
|
|
437
|
+
"""Checks every registered team of a tournament still to start, as its pre-flight will.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
The check of each team, under the tournament's strict rules or, without them, as a check
|
|
441
|
+
that each team loads and plays, and the entrants that a pre-flight now would leave out.
|
|
442
|
+
"""
|
|
443
|
+
checked = check_config(config)
|
|
444
|
+
scenario = checked['stages'][0]['scenario']
|
|
445
|
+
teams = list(dict.fromkeys(e['team'] for e in checked['entrants'] if e['kind'] == 'team'))
|
|
446
|
+
checks = {team: preflight(team, scenario, checked.get('strict')) for team in teams}
|
|
447
|
+
failed = {team for team, report in checks.items() if not report.get('ok')}
|
|
448
|
+
return {
|
|
449
|
+
'scenario': scenario,
|
|
450
|
+
'checks': list(checks.values()),
|
|
451
|
+
'excluded': [
|
|
452
|
+
{
|
|
453
|
+
**rules.named_from('name', entrant),
|
|
454
|
+
'team': entrant['team'],
|
|
455
|
+
**{
|
|
456
|
+
key: (checks[entrant['team']].get('rule') or {}).get(key)
|
|
457
|
+
for key in ('reason', 'reason_id', 'reason_values')
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
for entrant in checked['entrants']
|
|
461
|
+
if entrant['kind'] == 'team' and entrant['team'] in failed
|
|
462
|
+
],
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def forfeit_result(result: dict[str, Any], goals: int) -> dict[str, Any]:
|
|
467
|
+
"""Returns the result of a match that a side ended by breaking a rule, at the forfeit score.
|
|
468
|
+
|
|
469
|
+
The side that broke the rule loses by the forfeit score, and two sides that broke one at the
|
|
470
|
+
same step both lose, 0 to 0, while the rule keeps the reason and the score at that moment.
|
|
471
|
+
"""
|
|
472
|
+
side = result['rule']['side']
|
|
473
|
+
score = {'home': [0, goals], 'away': [goals, 0]}.get(side, [0, 0])
|
|
474
|
+
return {**result, 'score': score, 'cards': [[0, 0], [0, 0]], 'match': None, 'forfeit': True}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# Ratings and presets.
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def ratings() -> dict[str, dict[str, Any]]:
|
|
481
|
+
"""Returns the rating of every entrant that has played a tournament match, by entrant key."""
|
|
482
|
+
stored = _read(folder() / 'ratings.json', {})
|
|
483
|
+
if not isinstance(stored, dict):
|
|
484
|
+
return {}
|
|
485
|
+
return {
|
|
486
|
+
key: value
|
|
487
|
+
for key, value in stored.items()
|
|
488
|
+
if isinstance(value, dict) and rules._number(value.get('rating'))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _rating_values() -> dict[str, float]:
|
|
493
|
+
return {key: float(value['rating']) for key, value in ratings().items()}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def presets() -> dict[str, dict[str, Any]]:
|
|
497
|
+
"""Returns the presets, those that ship first and then the user's, by name."""
|
|
498
|
+
shipped = {name: {**preset, 'shipped': True} for name, preset in rules.PRESETS.items()}
|
|
499
|
+
own = _read(folder() / 'presets.json', {})
|
|
500
|
+
if isinstance(own, dict):
|
|
501
|
+
for name, preset in own.items():
|
|
502
|
+
try:
|
|
503
|
+
shipped[name] = {**rules.check_preset(preset), 'shipped': False}
|
|
504
|
+
except rules.TournamentError:
|
|
505
|
+
continue
|
|
506
|
+
return shipped
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def save_preset(name: str, preset: Any) -> dict[str, Any]:
|
|
510
|
+
"""Saves a preset of the user under `name` and returns it.
|
|
511
|
+
|
|
512
|
+
Raises:
|
|
513
|
+
ArgumentError: If the name is no plain identifier or names a shipped preset, or the
|
|
514
|
+
preset is invalid.
|
|
515
|
+
"""
|
|
516
|
+
if not isinstance(name, str) or not _home.valid_identifier(name) or name in rules.PRESETS:
|
|
517
|
+
raise MessageError('server.tournament.preset_name', name=repr(name))
|
|
518
|
+
try:
|
|
519
|
+
checked = rules.check_preset(preset)
|
|
520
|
+
except rules.TournamentError as error:
|
|
521
|
+
raise _messages.argument_error(error) from error
|
|
522
|
+
path = folder() / 'presets.json'
|
|
523
|
+
own = _read(path, {})
|
|
524
|
+
own = own if isinstance(own, dict) else {}
|
|
525
|
+
own[name] = checked
|
|
526
|
+
_write(path, own)
|
|
527
|
+
return {**checked, 'shipped': False}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def remove_preset(name: str) -> bool:
|
|
531
|
+
"""Removes a preset of the user and returns whether it existed."""
|
|
532
|
+
path = folder() / 'presets.json'
|
|
533
|
+
own = _read(path, {})
|
|
534
|
+
if not isinstance(own, dict) or name not in own:
|
|
535
|
+
return False
|
|
536
|
+
del own[name]
|
|
537
|
+
_write(path, own)
|
|
538
|
+
return True
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def check_config(config: Any) -> dict[str, Any]:
|
|
542
|
+
"""Returns a configuration resolved against the registry, the scenarios and the ratings.
|
|
543
|
+
|
|
544
|
+
A configuration that names a preset takes the preset's settings for those it leaves out.
|
|
545
|
+
|
|
546
|
+
Raises:
|
|
547
|
+
ArgumentError: With the reason, if it cannot make a tournament.
|
|
548
|
+
"""
|
|
549
|
+
if (
|
|
550
|
+
isinstance(config, dict)
|
|
551
|
+
and isinstance(config.get('preset'), str)
|
|
552
|
+
and ('stages' not in config)
|
|
553
|
+
):
|
|
554
|
+
try:
|
|
555
|
+
base = rules.preset_config(config['preset'], presets())
|
|
556
|
+
except rules.TournamentError as error:
|
|
557
|
+
raise _messages.argument_error(error) from error
|
|
558
|
+
config = {**base, **config}
|
|
559
|
+
registry = _teams.teams()
|
|
560
|
+
entries = {team['id']: team for team in registry}
|
|
561
|
+
try:
|
|
562
|
+
return rules.check(
|
|
563
|
+
config,
|
|
564
|
+
registry,
|
|
565
|
+
lambda entrant, scenario: fits(entrant, scenario, entries),
|
|
566
|
+
_rating_values(),
|
|
567
|
+
scenarios(),
|
|
568
|
+
)
|
|
569
|
+
except rules.TournamentError as error:
|
|
570
|
+
raise _messages.argument_error(error) from error
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def preview(config: Any) -> dict[str, Any]:
|
|
574
|
+
"""Returns a configuration resolved, with the draw and first matches it would give.
|
|
575
|
+
|
|
576
|
+
Raises:
|
|
577
|
+
ArgumentError: With the reason, if it cannot make a tournament.
|
|
578
|
+
"""
|
|
579
|
+
checked = check_config(config)
|
|
580
|
+
view = rules.evaluate(checked, {})
|
|
581
|
+
stages = [
|
|
582
|
+
{
|
|
583
|
+
**{key: stage[key] for key in ('format', 'entrants', 'advance', 'rounds', 'tables')},
|
|
584
|
+
**rules.named_from('name', stage),
|
|
585
|
+
}
|
|
586
|
+
for stage in view['stages']
|
|
587
|
+
]
|
|
588
|
+
return {'config': checked, 'stages': stages, 'matches': len(view['matches'])}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# The stored tournaments.
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _stored(path: Path) -> dict[str, Any] | None:
|
|
595
|
+
"""Returns the tournament that a file holds, or `None` with a warning."""
|
|
596
|
+
entry = _read(path, None)
|
|
597
|
+
sealed = isinstance(entry, dict) and _sealed(entry)
|
|
598
|
+
problem = None
|
|
599
|
+
if not isinstance(entry, dict):
|
|
600
|
+
problem = 'which holds no object'
|
|
601
|
+
elif entry.get('id') != path.stem or not _home.valid_identifier(path.stem):
|
|
602
|
+
problem = 'whose identifier differs from its name'
|
|
603
|
+
elif entry.get('state') not in _STATES:
|
|
604
|
+
problem = 'whose state is unknown'
|
|
605
|
+
elif not isinstance(entry.get('config'), dict) or not isinstance(entry.get('results'), dict):
|
|
606
|
+
problem = 'whose configuration or results are missing'
|
|
607
|
+
else:
|
|
608
|
+
try:
|
|
609
|
+
config = entry['config']
|
|
610
|
+
if not (
|
|
611
|
+
isinstance(config.get('entrants'), list)
|
|
612
|
+
and isinstance(config.get('stages'), list)
|
|
613
|
+
and rules._integer(config.get('seed'))
|
|
614
|
+
):
|
|
615
|
+
raise rules.TournamentError('expected entrants, stages and a seed')
|
|
616
|
+
entry['results'] = {
|
|
617
|
+
str(key): rules.check_result(value) for key, value in entry['results'].items()
|
|
618
|
+
}
|
|
619
|
+
rules.evaluate(config, entry['results'])
|
|
620
|
+
# No file plays more matches at once than the rules allow a new tournament.
|
|
621
|
+
concurrency = config.get('concurrency')
|
|
622
|
+
if not isinstance(concurrency, int) or isinstance(concurrency, bool):
|
|
623
|
+
concurrency = 1
|
|
624
|
+
config['concurrency'] = min(max(concurrency, 1), rules.MAX_CONCURRENCY)
|
|
625
|
+
except (rules.TournamentError, KeyError, TypeError, ValueError, IndexError) as error:
|
|
626
|
+
problem = f'whose tournament cannot be read, {error}'
|
|
627
|
+
if problem:
|
|
628
|
+
_LOGGER.warning('cuju render skips the tournament file %s, %s', path.name, problem)
|
|
629
|
+
return None
|
|
630
|
+
for key, kind, default in (
|
|
631
|
+
('error', str, None),
|
|
632
|
+
('error_id', str, None),
|
|
633
|
+
('error_values', dict, None),
|
|
634
|
+
('failures', dict, {}),
|
|
635
|
+
('failure_ids', dict, {}),
|
|
636
|
+
('games', dict, {}),
|
|
637
|
+
('notes', dict, {}),
|
|
638
|
+
('preflight', dict, None),
|
|
639
|
+
('excluded', list, []),
|
|
640
|
+
('digests', dict, {}),
|
|
641
|
+
('created', (int, float), time.time()),
|
|
642
|
+
('finished', (int, float), None),
|
|
643
|
+
):
|
|
644
|
+
if not isinstance(entry.get(key), kind):
|
|
645
|
+
entry[key] = default
|
|
646
|
+
# The id of the error's message passes on only with the error and the values it names.
|
|
647
|
+
if entry['error'] is None or entry['error_id'] not in _messages.MESSAGES:
|
|
648
|
+
entry['error_id'] = entry['error_values'] = None
|
|
649
|
+
elif entry['error_values'] is None:
|
|
650
|
+
entry['error_id'] = None
|
|
651
|
+
# A running file that this install did not write, or that changed since, waits for a person.
|
|
652
|
+
if not sealed and entry['state'] == 'running':
|
|
653
|
+
_LOGGER.warning(
|
|
654
|
+
'cuju render loads the tournament file %s stopped, since this install did not write it',
|
|
655
|
+
path.name,
|
|
656
|
+
)
|
|
657
|
+
entry.update(state='stopped', **_messages.fields('server.tournament.foreign'))
|
|
658
|
+
return entry
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _note(name: str, **values: Any) -> dict[str, Any]:
|
|
662
|
+
"""Returns a note of a match, such as a game that ended without a result, as a label."""
|
|
663
|
+
return _messages.label(f'server.tournament.note.{name}', **values)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _stored_note(note: Any) -> str | dict[str, Any] | None:
|
|
667
|
+
"""Returns a note that a file keeps, a label when it is a valid one, else its text."""
|
|
668
|
+
if _messages.valid_label(note):
|
|
669
|
+
return note
|
|
670
|
+
return note if isinstance(note, str) else None
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _alive(pid: int) -> bool:
|
|
674
|
+
from .._http.server import _alive as alive
|
|
675
|
+
|
|
676
|
+
return alive(pid)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
class Tournaments:
|
|
680
|
+
"""The tournaments of the renderer's folder, and the matches they play.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
games: The server's games, which play the matches of people, or `None` without people.
|
|
684
|
+
player: Plays one match of agents and returns its result, by default in a child process.
|
|
685
|
+
A test passes a function of its own.
|
|
686
|
+
only: The one tournament to run, or `None` for every tournament of the folder.
|
|
687
|
+
start: Whether to start the thread that runs the matches.
|
|
688
|
+
interval: The seconds between two looks at the tournaments.
|
|
689
|
+
checker: Checks one team before a tournament's first match and returns its report, by
|
|
690
|
+
default `preflight` in a child process. A test passes a function of its own.
|
|
691
|
+
"""
|
|
692
|
+
|
|
693
|
+
def __init__(
|
|
694
|
+
self,
|
|
695
|
+
games: Any = None,
|
|
696
|
+
player: Callable[..., dict[str, Any]] | None = None,
|
|
697
|
+
only: str | None = None,
|
|
698
|
+
start: bool = True,
|
|
699
|
+
interval: float = 0.5,
|
|
700
|
+
checker: Callable[..., dict[str, Any]] | None = None,
|
|
701
|
+
) -> None:
|
|
702
|
+
self._lock = threading.RLock()
|
|
703
|
+
self._records: dict[str, dict[str, Any]] = {}
|
|
704
|
+
self._stamps: dict[str, float] = {}
|
|
705
|
+
self._views: dict[str, tuple[int, dict[str, Any]]] = {}
|
|
706
|
+
self._versions: dict[str, int] = collections.defaultdict(int)
|
|
707
|
+
self._running: dict[tuple[str, str], dict[str, Any]] = {}
|
|
708
|
+
self._owned: set[str] = set()
|
|
709
|
+
self._games = games
|
|
710
|
+
self._player = player or self._play_process
|
|
711
|
+
self._checker = checker or preflight
|
|
712
|
+
self._analyses: dict[str, tuple[int, dict[str, Any]]] = {}
|
|
713
|
+
self._only = only
|
|
714
|
+
self._interval = interval
|
|
715
|
+
self._stop = threading.Event()
|
|
716
|
+
self._folder = folder()
|
|
717
|
+
for path in self._folder.glob('*.part'):
|
|
718
|
+
with contextlib.suppress(OSError):
|
|
719
|
+
path.unlink()
|
|
720
|
+
self._scan()
|
|
721
|
+
if start:
|
|
722
|
+
threading.Thread(target=self._loop, daemon=True, name='cuju-tournaments').start()
|
|
723
|
+
|
|
724
|
+
# Files.
|
|
725
|
+
|
|
726
|
+
def _path(self, identifier: str) -> Path:
|
|
727
|
+
return self._folder / f'{identifier}.json'
|
|
728
|
+
|
|
729
|
+
def _scan(self) -> None:
|
|
730
|
+
"""Loads new tournaments and those that another process changed, and applies cancels."""
|
|
731
|
+
with self._lock:
|
|
732
|
+
for path in self._folder.glob('*.json'):
|
|
733
|
+
if path.name in ('ratings.json', 'presets.json'):
|
|
734
|
+
continue
|
|
735
|
+
identifier = path.stem
|
|
736
|
+
if self._only is not None and identifier != self._only:
|
|
737
|
+
continue
|
|
738
|
+
try:
|
|
739
|
+
stamp = path.stat().st_mtime_ns
|
|
740
|
+
except OSError:
|
|
741
|
+
continue
|
|
742
|
+
if identifier in self._owned and identifier in self._records:
|
|
743
|
+
continue
|
|
744
|
+
if self._stamps.get(identifier) == stamp:
|
|
745
|
+
continue
|
|
746
|
+
record = _stored(path)
|
|
747
|
+
self._stamps[identifier] = stamp
|
|
748
|
+
if record is not None:
|
|
749
|
+
self._records[identifier] = record
|
|
750
|
+
self._versions[identifier] += 1
|
|
751
|
+
for identifier in list(self._records):
|
|
752
|
+
if not self._path(identifier).exists():
|
|
753
|
+
self._forget(identifier)
|
|
754
|
+
for marker in self._folder.glob('*.cancel'):
|
|
755
|
+
record = self._records.get(marker.stem)
|
|
756
|
+
if record is not None and self._claim(record):
|
|
757
|
+
with contextlib.suppress(OSError):
|
|
758
|
+
marker.unlink()
|
|
759
|
+
self._cancel(record)
|
|
760
|
+
for request in self._folder.glob('*.request'):
|
|
761
|
+
self._answer(request)
|
|
762
|
+
|
|
763
|
+
# Changes that another process asks for.
|
|
764
|
+
|
|
765
|
+
def _answer(self, request: Path) -> None:
|
|
766
|
+
"""Applies an award or a resume that another process asked for, and writes the answer.
|
|
767
|
+
|
|
768
|
+
The request names its tournament before its first dot, and the answer takes the same name
|
|
769
|
+
with `.answer` in place of `.request`, holding `ok` or the error fields of the refusal.
|
|
770
|
+
"""
|
|
771
|
+
record = self._records.get(request.name.split('.', 1)[0])
|
|
772
|
+
if record is None or record['id'] not in self._owned:
|
|
773
|
+
return
|
|
774
|
+
try:
|
|
775
|
+
asked = json.loads(request.read_text(encoding='utf-8'))
|
|
776
|
+
except (OSError, ValueError):
|
|
777
|
+
asked = None
|
|
778
|
+
with contextlib.suppress(OSError):
|
|
779
|
+
request.unlink()
|
|
780
|
+
if not isinstance(asked, dict) or asked.get('action') not in ('forfeit', 'resume', 'legs'):
|
|
781
|
+
return
|
|
782
|
+
try:
|
|
783
|
+
if asked['action'] == 'forfeit':
|
|
784
|
+
self.forfeit(record['id'], str(asked.get('key')), asked.get('winner'))
|
|
785
|
+
elif asked['action'] == 'legs':
|
|
786
|
+
self.add_legs(record['id'], asked.get('legs'))
|
|
787
|
+
else:
|
|
788
|
+
self.resume(record['id'])
|
|
789
|
+
answer: dict[str, Any] = {'ok': True}
|
|
790
|
+
except ArgumentError as error:
|
|
791
|
+
answer = {'ok': False, **_messages.error_fields(error)}
|
|
792
|
+
with contextlib.suppress(OSError):
|
|
793
|
+
_write(request.with_suffix('.answer'), answer)
|
|
794
|
+
|
|
795
|
+
def _ask_owner(self, identifier: str, action: dict[str, Any]) -> dict[str, Any]:
|
|
796
|
+
"""Asks the process that runs a tournament for a change and returns the tournament after.
|
|
797
|
+
|
|
798
|
+
Raises:
|
|
799
|
+
ArgumentError: If that process refuses the change, or does not answer in time.
|
|
800
|
+
"""
|
|
801
|
+
request = self._folder / f'{identifier}.{os.getpid()}-{time.monotonic_ns()}.request'
|
|
802
|
+
answer = request.with_suffix('.answer')
|
|
803
|
+
_write(request, action)
|
|
804
|
+
reply = None
|
|
805
|
+
deadline = time.monotonic() + _ASK_SECONDS
|
|
806
|
+
try:
|
|
807
|
+
while reply is None and time.monotonic() < deadline:
|
|
808
|
+
with contextlib.suppress(ValueError):
|
|
809
|
+
found = json.loads(_read_text(answer) or 'null')
|
|
810
|
+
reply = found if isinstance(found, dict) else None
|
|
811
|
+
if reply is None:
|
|
812
|
+
time.sleep(0.05)
|
|
813
|
+
finally:
|
|
814
|
+
for path in (request, answer):
|
|
815
|
+
with contextlib.suppress(OSError):
|
|
816
|
+
path.unlink()
|
|
817
|
+
if reply is None:
|
|
818
|
+
raise MessageError(
|
|
819
|
+
'server.tournament.other_process_silent',
|
|
820
|
+
owner=self.owner(identifier),
|
|
821
|
+
route=f'POST /api/tournaments/{identifier}/{action["action"]}',
|
|
822
|
+
)
|
|
823
|
+
if not reply.get('ok'):
|
|
824
|
+
reported = _messages.report_fields(reply)
|
|
825
|
+
if reported['error_id'] is not None:
|
|
826
|
+
raise MessageError(reported['error_id'], **reported['error_values'])
|
|
827
|
+
if reported['error']:
|
|
828
|
+
raise ArgumentError(reported['error'])
|
|
829
|
+
raise MessageError('server.tournament.refused')
|
|
830
|
+
self._scan()
|
|
831
|
+
return self.get(identifier)
|
|
832
|
+
|
|
833
|
+
def _forget(self, identifier: str) -> None:
|
|
834
|
+
self._records.pop(identifier, None)
|
|
835
|
+
self._views.pop(identifier, None)
|
|
836
|
+
self._analyses.pop(identifier, None)
|
|
837
|
+
self._stamps.pop(identifier, None)
|
|
838
|
+
self._release(identifier)
|
|
839
|
+
|
|
840
|
+
def _persist(self, record: dict[str, Any]) -> None:
|
|
841
|
+
"""Writes a tournament, and keeps a failed write as its error instead of stopping."""
|
|
842
|
+
self._versions[record['id']] += 1
|
|
843
|
+
try:
|
|
844
|
+
_write_record(self._path(record['id']), record)
|
|
845
|
+
self._stamps[record['id']] = self._path(record['id']).stat().st_mtime_ns
|
|
846
|
+
record.pop('persistence_error', None)
|
|
847
|
+
except OSError as error:
|
|
848
|
+
record['persistence_error'] = f'could not save the tournament, {error}'
|
|
849
|
+
|
|
850
|
+
# The lock between processes.
|
|
851
|
+
|
|
852
|
+
def _lock_path(self, identifier: str) -> Path:
|
|
853
|
+
return self._folder / f'{identifier}.lock'
|
|
854
|
+
|
|
855
|
+
def _claim(self, record: dict[str, Any]) -> bool:
|
|
856
|
+
"""Makes this process the one that runs a tournament, unless another live one does."""
|
|
857
|
+
identifier = record['id']
|
|
858
|
+
if identifier in self._owned:
|
|
859
|
+
return True
|
|
860
|
+
path = self._lock_path(identifier)
|
|
861
|
+
for _ in range(2):
|
|
862
|
+
try:
|
|
863
|
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
864
|
+
except FileExistsError:
|
|
865
|
+
text = _read_text(path) or ''
|
|
866
|
+
pid = int(text) if text.strip().isdigit() else 0
|
|
867
|
+
if pid and pid != os.getpid() and _alive(pid):
|
|
868
|
+
return False
|
|
869
|
+
with contextlib.suppress(OSError):
|
|
870
|
+
path.unlink()
|
|
871
|
+
continue
|
|
872
|
+
except OSError:
|
|
873
|
+
return False
|
|
874
|
+
with os.fdopen(descriptor, 'w', encoding='utf-8') as stream:
|
|
875
|
+
stream.write(str(os.getpid()))
|
|
876
|
+
self._owned.add(identifier)
|
|
877
|
+
# A game lives no longer than the process that started it, so the games of people
|
|
878
|
+
# that an earlier process followed are gone, and their matches wait to be played.
|
|
879
|
+
if record['games']:
|
|
880
|
+
for key in record['games']:
|
|
881
|
+
record['notes'][key] = _note('gone')
|
|
882
|
+
record['games'] = {}
|
|
883
|
+
self._persist(record)
|
|
884
|
+
return True
|
|
885
|
+
return False
|
|
886
|
+
|
|
887
|
+
def owner(self, identifier: str) -> int | None:
|
|
888
|
+
"""Returns the process that runs a tournament, when another live process does."""
|
|
889
|
+
text = _read_text(self._lock_path(identifier)) or ''
|
|
890
|
+
pid = int(text) if text.strip().isdigit() else 0
|
|
891
|
+
return pid if pid and pid != os.getpid() and _alive(pid) else None
|
|
892
|
+
|
|
893
|
+
def _release(self, identifier: str) -> None:
|
|
894
|
+
if identifier in self._owned:
|
|
895
|
+
self._owned.discard(identifier)
|
|
896
|
+
with contextlib.suppress(OSError):
|
|
897
|
+
self._lock_path(identifier).unlink()
|
|
898
|
+
|
|
899
|
+
def close(self) -> None:
|
|
900
|
+
"""Stops the thread, ends the matches it plays and lets other processes run them."""
|
|
901
|
+
self._stop.set()
|
|
902
|
+
with self._lock:
|
|
903
|
+
for running in self._running.values():
|
|
904
|
+
process = running.get('process')
|
|
905
|
+
if process is not None:
|
|
906
|
+
with contextlib.suppress(OSError):
|
|
907
|
+
process.kill()
|
|
908
|
+
for identifier in list(self._owned):
|
|
909
|
+
self._release(identifier)
|
|
910
|
+
|
|
911
|
+
# Views.
|
|
912
|
+
|
|
913
|
+
def _view(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
914
|
+
version = self._versions[record['id']]
|
|
915
|
+
cached = self._views.get(record['id'])
|
|
916
|
+
if cached is None or cached[0] != version:
|
|
917
|
+
cached = (version, rules.evaluate(record['config'], record['results']))
|
|
918
|
+
self._views[record['id']] = cached
|
|
919
|
+
return cached[1]
|
|
920
|
+
|
|
921
|
+
def _summary(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
922
|
+
view = self._view(record)
|
|
923
|
+
config = record['config']
|
|
924
|
+
champion = view['champion']
|
|
925
|
+
running = sum(1 for key in self._running if key[0] == record['id'])
|
|
926
|
+
waiting = [m for m in rules.ready(view) if m['person']]
|
|
927
|
+
return {
|
|
928
|
+
'id': record['id'],
|
|
929
|
+
**rules.named_from('name', config),
|
|
930
|
+
'state': record['state'],
|
|
931
|
+
'error': record['error'],
|
|
932
|
+
'error_id': record.get('error_id'),
|
|
933
|
+
'error_values': record.get('error_values'),
|
|
934
|
+
'created': record['created'],
|
|
935
|
+
'finished': record['finished'],
|
|
936
|
+
'seed': config['seed'],
|
|
937
|
+
'entrants': len(config['entrants']),
|
|
938
|
+
'stages': [
|
|
939
|
+
{
|
|
940
|
+
**rules.named_from('name', stage),
|
|
941
|
+
'format': stage['format'],
|
|
942
|
+
'scenario': stage['scenario'],
|
|
943
|
+
}
|
|
944
|
+
for stage in config['stages']
|
|
945
|
+
],
|
|
946
|
+
'played': view['played'],
|
|
947
|
+
'scheduled': len(view['matches']),
|
|
948
|
+
'expected': view['expected'],
|
|
949
|
+
'running': running,
|
|
950
|
+
'failed': len(record['failures']),
|
|
951
|
+
'people_waiting': len(waiting),
|
|
952
|
+
**_messages.named(
|
|
953
|
+
'champion',
|
|
954
|
+
None if champion is None else rules.label_of(config['entrants'][champion]),
|
|
955
|
+
),
|
|
956
|
+
'champion_color': None if champion is None else config['entrants'][champion]['color'],
|
|
957
|
+
'owner': self.owner(record['id']) if record['id'] not in self._owned else None,
|
|
958
|
+
'persistence_error': record.get('persistence_error'),
|
|
959
|
+
'strict': config.get('strict') is not None,
|
|
960
|
+
'preflight': self._preflight_state(record),
|
|
961
|
+
'excluded_count': len(record.get('excluded') or []),
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
def _preflight_state(self, record: dict[str, Any]) -> str | None:
|
|
965
|
+
"""Returns where a tournament's pre-flight stands, `None` for a tournament without one."""
|
|
966
|
+
if not record['config'].get('preflight'):
|
|
967
|
+
return None
|
|
968
|
+
if record.get('preflight') is not None:
|
|
969
|
+
return 'done'
|
|
970
|
+
if (record['id'], _PREFLIGHT) in self._running:
|
|
971
|
+
return 'running'
|
|
972
|
+
return 'waiting'
|
|
973
|
+
|
|
974
|
+
def entries(self) -> list[dict[str, Any]]:
|
|
975
|
+
"""Returns a summary of every tournament, newest first."""
|
|
976
|
+
with self._lock:
|
|
977
|
+
records = sorted(self._records.values(), key=lambda r: r['created'], reverse=True)
|
|
978
|
+
return [self._summary(record) for record in records]
|
|
979
|
+
|
|
980
|
+
def get(self, identifier: str) -> dict[str, Any] | None:
|
|
981
|
+
"""Returns a tournament in full, with the state of every match, or `None`."""
|
|
982
|
+
with self._lock:
|
|
983
|
+
record = self._records.get(identifier)
|
|
984
|
+
if record is None:
|
|
985
|
+
return None
|
|
986
|
+
view = self._view(record)
|
|
987
|
+
current = self._games.current() if self._games is not None else None
|
|
988
|
+
matches = []
|
|
989
|
+
for match in view['matches']:
|
|
990
|
+
entry = dict(match)
|
|
991
|
+
running = self._running.get((identifier, match['key']))
|
|
992
|
+
game = record['games'].get(match['key'])
|
|
993
|
+
if match['result'] is not None:
|
|
994
|
+
entry['state'] = 'done'
|
|
995
|
+
elif running is not None:
|
|
996
|
+
entry['state'] = 'running'
|
|
997
|
+
entry['progress'] = running['progress']
|
|
998
|
+
elif game is not None:
|
|
999
|
+
entry['state'] = 'playing'
|
|
1000
|
+
entry['game'] = game
|
|
1001
|
+
if current is not None and current.get('id') == game:
|
|
1002
|
+
entry['score'] = current.get('score')
|
|
1003
|
+
elif match['key'] in record['failures']:
|
|
1004
|
+
entry['state'] = 'failed'
|
|
1005
|
+
entry.update(self._failure(record, match['key']))
|
|
1006
|
+
else:
|
|
1007
|
+
entry['state'] = 'person' if match['person'] else 'ready'
|
|
1008
|
+
if match['key'] in record['notes']:
|
|
1009
|
+
entry.update(
|
|
1010
|
+
_messages.named('note', _stored_note(record['notes'][match['key']]))
|
|
1011
|
+
)
|
|
1012
|
+
matches.append(entry)
|
|
1013
|
+
return {
|
|
1014
|
+
**self._summary(record),
|
|
1015
|
+
'config': record['config'],
|
|
1016
|
+
'stages_view': view['stages'],
|
|
1017
|
+
'matches': matches,
|
|
1018
|
+
'ranking': view['ranking'],
|
|
1019
|
+
'champion_index': view['champion'],
|
|
1020
|
+
'checks': (record.get('preflight') or {}).get('checks', []),
|
|
1021
|
+
'excluded': list(record.get('excluded') or []),
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
def analysis(self, identifier: str) -> dict[str, Any] | None:
|
|
1025
|
+
"""Returns the ratings, matrix, standings and matches of a tournament, or `None`.
|
|
1026
|
+
|
|
1027
|
+
The analysis keeps until a result changes the tournament, since its bootstrap takes a
|
|
1028
|
+
while for a long league.
|
|
1029
|
+
"""
|
|
1030
|
+
with self._lock:
|
|
1031
|
+
record = self._records.get(identifier)
|
|
1032
|
+
if record is None:
|
|
1033
|
+
return None
|
|
1034
|
+
version = self._versions[identifier]
|
|
1035
|
+
cached = self._analyses.get(identifier)
|
|
1036
|
+
if cached is not None and cached[0] == version:
|
|
1037
|
+
return cached[1]
|
|
1038
|
+
entry = self.get(identifier)
|
|
1039
|
+
found = _results.analysis(entry)
|
|
1040
|
+
with self._lock:
|
|
1041
|
+
self._analyses[identifier] = (version, found)
|
|
1042
|
+
return found
|
|
1043
|
+
|
|
1044
|
+
def files(self, identifier: str) -> dict[str, str] | None:
|
|
1045
|
+
"""Returns the text of each file of a tournament's results by its name, or `None`."""
|
|
1046
|
+
found = self.analysis(identifier)
|
|
1047
|
+
if found is None:
|
|
1048
|
+
return None
|
|
1049
|
+
return _results.files(self.get(identifier), found)
|
|
1050
|
+
|
|
1051
|
+
def without_spoilers(self, identifier: str, watched: Any) -> dict[str, Any] | None:
|
|
1052
|
+
"""Returns a tournament as a person sees it who watched only the matches of `watched`.
|
|
1053
|
+
|
|
1054
|
+
Raises:
|
|
1055
|
+
ArgumentError: If `watched` is no list of match keys.
|
|
1056
|
+
"""
|
|
1057
|
+
with self._lock:
|
|
1058
|
+
entry = self.get(identifier)
|
|
1059
|
+
if entry is None:
|
|
1060
|
+
return None
|
|
1061
|
+
results = dict(self._records[identifier]['results'])
|
|
1062
|
+
try:
|
|
1063
|
+
return rules.without_spoilers(entry, results, watched)
|
|
1064
|
+
except rules.TournamentError as error:
|
|
1065
|
+
raise _messages.argument_error(error) from error
|
|
1066
|
+
|
|
1067
|
+
# Changes.
|
|
1068
|
+
|
|
1069
|
+
def create(self, config: Any, run: bool = True) -> dict[str, Any]:
|
|
1070
|
+
"""Checks a configuration, stores the tournament and returns its summary.
|
|
1071
|
+
|
|
1072
|
+
Raises:
|
|
1073
|
+
ArgumentError: If the configuration cannot make a tournament.
|
|
1074
|
+
OSError: If the tournament cannot be written.
|
|
1075
|
+
"""
|
|
1076
|
+
checked = check_config(config)
|
|
1077
|
+
with self._lock:
|
|
1078
|
+
identifier = _home.new_identifier('cup')
|
|
1079
|
+
while identifier in self._records or self._path(identifier).exists():
|
|
1080
|
+
identifier = _home.new_identifier('cup')
|
|
1081
|
+
record = {
|
|
1082
|
+
'version': _FILE_VERSION,
|
|
1083
|
+
'id': identifier,
|
|
1084
|
+
'created': time.time(),
|
|
1085
|
+
'finished': None,
|
|
1086
|
+
'config': checked,
|
|
1087
|
+
'results': {},
|
|
1088
|
+
'state': 'running' if run else 'stopped',
|
|
1089
|
+
**(
|
|
1090
|
+
_messages.entry_fields(None)
|
|
1091
|
+
if run
|
|
1092
|
+
else _messages.fields('server.tournament.waiting')
|
|
1093
|
+
),
|
|
1094
|
+
'failures': {},
|
|
1095
|
+
'failure_ids': {},
|
|
1096
|
+
'games': {},
|
|
1097
|
+
'notes': {},
|
|
1098
|
+
'preflight': None,
|
|
1099
|
+
'excluded': [],
|
|
1100
|
+
'digests': {},
|
|
1101
|
+
}
|
|
1102
|
+
_write_record(self._path(identifier), record)
|
|
1103
|
+
self._stamps[identifier] = self._path(identifier).stat().st_mtime_ns
|
|
1104
|
+
self._records[identifier] = record
|
|
1105
|
+
return self._summary(record)
|
|
1106
|
+
|
|
1107
|
+
def _record(self, identifier: str) -> dict[str, Any]:
|
|
1108
|
+
record = self._records.get(identifier)
|
|
1109
|
+
if record is None:
|
|
1110
|
+
raise KeyError(identifier)
|
|
1111
|
+
if not self._claim(record):
|
|
1112
|
+
raise MessageError(
|
|
1113
|
+
'server.tournament.other_process_change', owner=self.owner(identifier)
|
|
1114
|
+
)
|
|
1115
|
+
return record
|
|
1116
|
+
|
|
1117
|
+
def cancel(self, identifier: str) -> bool:
|
|
1118
|
+
"""Cancels a tournament, ending its matches, and returns whether it existed.
|
|
1119
|
+
|
|
1120
|
+
A tournament that another process runs gets a cancel marker, which that process applies.
|
|
1121
|
+
|
|
1122
|
+
Raises:
|
|
1123
|
+
ArgumentError: If the tournament has already ended, done or cancelled.
|
|
1124
|
+
"""
|
|
1125
|
+
with self._lock:
|
|
1126
|
+
record = self._records.get(identifier)
|
|
1127
|
+
if record is None:
|
|
1128
|
+
return False
|
|
1129
|
+
if record['state'] in ('done', 'cancelled'):
|
|
1130
|
+
raise MessageError('server.tournament.cancel_state', state=record['state'])
|
|
1131
|
+
if not self._claim(record):
|
|
1132
|
+
(self._folder / f'{identifier}.cancel').write_text('', encoding='utf-8')
|
|
1133
|
+
return True
|
|
1134
|
+
self._cancel(record)
|
|
1135
|
+
return True
|
|
1136
|
+
|
|
1137
|
+
def _cancel(self, record: dict[str, Any]) -> None:
|
|
1138
|
+
if record['state'] in ('done', 'cancelled'):
|
|
1139
|
+
return
|
|
1140
|
+
record.update(
|
|
1141
|
+
state='cancelled', finished=time.time(), games={}, **_messages.entry_fields(None)
|
|
1142
|
+
)
|
|
1143
|
+
for key, running in list(self._running.items()):
|
|
1144
|
+
if key[0] == record['id'] and running.get('process') is not None:
|
|
1145
|
+
with contextlib.suppress(OSError):
|
|
1146
|
+
running['process'].kill()
|
|
1147
|
+
self._persist(record)
|
|
1148
|
+
self._release(record['id'])
|
|
1149
|
+
|
|
1150
|
+
def resume(self, identifier: str) -> dict[str, Any]:
|
|
1151
|
+
"""Runs a stopped tournament again, retrying the matches that failed.
|
|
1152
|
+
|
|
1153
|
+
A running tournament with failed matches, which plays on while people play theirs, also
|
|
1154
|
+
retries them.
|
|
1155
|
+
"""
|
|
1156
|
+
with self._lock:
|
|
1157
|
+
record = self._records.get(identifier)
|
|
1158
|
+
if record is None:
|
|
1159
|
+
raise KeyError(identifier)
|
|
1160
|
+
if record['state'] != 'stopped' and not (
|
|
1161
|
+
record['state'] == 'running' and record['failures']
|
|
1162
|
+
):
|
|
1163
|
+
raise MessageError('server.tournament.stopped', state=record['state'])
|
|
1164
|
+
asked = not self._claim(record)
|
|
1165
|
+
if asked:
|
|
1166
|
+
return self._ask_owner(identifier, {'action': 'resume'})
|
|
1167
|
+
with self._lock:
|
|
1168
|
+
record = self._record(identifier)
|
|
1169
|
+
if record['state'] != 'stopped' and not (
|
|
1170
|
+
record['state'] == 'running' and record['failures']
|
|
1171
|
+
):
|
|
1172
|
+
raise MessageError('server.tournament.stopped', state=record['state'])
|
|
1173
|
+
record.update(
|
|
1174
|
+
state='running', failures={}, failure_ids={}, **_messages.entry_fields(None)
|
|
1175
|
+
)
|
|
1176
|
+
self._persist(record)
|
|
1177
|
+
return self._summary(record)
|
|
1178
|
+
|
|
1179
|
+
def add_legs(self, identifier: str, legs: Any) -> dict[str, Any]:
|
|
1180
|
+
"""Adds legs to the last stage of a tournament, a league or groups, and returns it.
|
|
1181
|
+
|
|
1182
|
+
The matches of before keep their keys, seeds and results, so only the added matches play.
|
|
1183
|
+
A finished tournament runs again to play them, and a running or stopped one keeps its
|
|
1184
|
+
state.
|
|
1185
|
+
|
|
1186
|
+
Raises:
|
|
1187
|
+
ArgumentError: If the tournament was cancelled, its last stage takes no legs, or the
|
|
1188
|
+
legs would pass the most.
|
|
1189
|
+
"""
|
|
1190
|
+
with self._lock:
|
|
1191
|
+
record = self._records.get(identifier)
|
|
1192
|
+
if record is None:
|
|
1193
|
+
raise KeyError(identifier)
|
|
1194
|
+
if record['state'] == 'cancelled':
|
|
1195
|
+
raise MessageError('server.tournament.legs_cancelled')
|
|
1196
|
+
try:
|
|
1197
|
+
rules.add_legs(record['config'], legs)
|
|
1198
|
+
except rules.TournamentError as error:
|
|
1199
|
+
raise _messages.argument_error(error) from error
|
|
1200
|
+
asked = not self._claim(record)
|
|
1201
|
+
if asked:
|
|
1202
|
+
return self._ask_owner(identifier, {'action': 'legs', 'legs': legs})
|
|
1203
|
+
with self._lock:
|
|
1204
|
+
record = self._record(identifier)
|
|
1205
|
+
try:
|
|
1206
|
+
record['config'] = rules.add_legs(record['config'], legs)
|
|
1207
|
+
except rules.TournamentError as error:
|
|
1208
|
+
raise _messages.argument_error(error) from error
|
|
1209
|
+
if record['state'] == 'done':
|
|
1210
|
+
record.update(state='running', finished=None, **_messages.entry_fields(None))
|
|
1211
|
+
self._persist(record)
|
|
1212
|
+
return self.get(identifier)
|
|
1213
|
+
|
|
1214
|
+
def remove(self, identifier: str) -> bool:
|
|
1215
|
+
"""Deletes a tournament that no longer runs, and returns whether it existed."""
|
|
1216
|
+
with self._lock:
|
|
1217
|
+
record = self._records.get(identifier)
|
|
1218
|
+
if record is None:
|
|
1219
|
+
return False
|
|
1220
|
+
if record['state'] == 'running':
|
|
1221
|
+
raise MessageError('server.tournament.ended')
|
|
1222
|
+
if not self._claim(record):
|
|
1223
|
+
raise MessageError('server.tournament.other_process')
|
|
1224
|
+
self._path(identifier).unlink(missing_ok=True)
|
|
1225
|
+
shutil.rmtree(folder() / f'{identifier}.replays', ignore_errors=True)
|
|
1226
|
+
self._forget(identifier)
|
|
1227
|
+
return True
|
|
1228
|
+
|
|
1229
|
+
def _match(self, record: dict[str, Any], key: str) -> dict[str, Any]:
|
|
1230
|
+
view = self._view(record)
|
|
1231
|
+
match = next((m for m in view['matches'] if m['key'] == key), None)
|
|
1232
|
+
if match is None or match['result'] is not None:
|
|
1233
|
+
raise MessageError('server.tournament.match_key', key=repr(key))
|
|
1234
|
+
return match
|
|
1235
|
+
|
|
1236
|
+
def forfeit(self, identifier: str, key: str, side: Any) -> dict[str, Any]:
|
|
1237
|
+
"""Awards a match to one side, by the forfeit score, and returns the tournament.
|
|
1238
|
+
|
|
1239
|
+
An awarded match leaves the ratings as they are. A tournament that stopped on failed
|
|
1240
|
+
matches runs again when the award leaves a match that can play.
|
|
1241
|
+
|
|
1242
|
+
Args:
|
|
1243
|
+
identifier: The tournament.
|
|
1244
|
+
key: The match.
|
|
1245
|
+
side: The side that wins, `home` or `away`.
|
|
1246
|
+
"""
|
|
1247
|
+
if side not in ('home', 'away'):
|
|
1248
|
+
raise MessageError('server.tournament.winner', side=repr(side))
|
|
1249
|
+
with self._lock:
|
|
1250
|
+
record = self._records.get(identifier)
|
|
1251
|
+
if record is None:
|
|
1252
|
+
raise KeyError(identifier)
|
|
1253
|
+
# The key is checked first, so a wrong one reads as such whichever process runs it.
|
|
1254
|
+
self._match(record, key)
|
|
1255
|
+
asked = not self._claim(record)
|
|
1256
|
+
if asked:
|
|
1257
|
+
return self._ask_owner(identifier, {'action': 'forfeit', 'key': key, 'winner': side})
|
|
1258
|
+
with self._lock:
|
|
1259
|
+
record = self._record(identifier)
|
|
1260
|
+
match = self._match(record, key)
|
|
1261
|
+
if (identifier, key) in self._running or key in record['games']:
|
|
1262
|
+
raise MessageError('server.tournament.playing')
|
|
1263
|
+
goals = record['config']['forfeit_score']
|
|
1264
|
+
score = [goals, 0] if side == 'home' else [0, goals]
|
|
1265
|
+
held = record['state'] == 'stopped' and bool(record['failures'])
|
|
1266
|
+
self._finish(
|
|
1267
|
+
record, match, {'score': score, 'cards': [[0, 0], [0, 0]], 'forfeit': True}
|
|
1268
|
+
)
|
|
1269
|
+
if record['state'] == 'stopped' and (held or self._view(record)['complete']):
|
|
1270
|
+
view = self._view(record)
|
|
1271
|
+
if view['complete']:
|
|
1272
|
+
record.update(
|
|
1273
|
+
state='done', finished=time.time(), **_messages.entry_fields(None)
|
|
1274
|
+
)
|
|
1275
|
+
elif self._playable(record, view):
|
|
1276
|
+
record.update(state='running', **_messages.entry_fields(None))
|
|
1277
|
+
else:
|
|
1278
|
+
record.update(self._failure_fields(record, view))
|
|
1279
|
+
self._persist(record)
|
|
1280
|
+
if record['state'] == 'done':
|
|
1281
|
+
self._release(record['id'])
|
|
1282
|
+
return self.get(identifier)
|
|
1283
|
+
|
|
1284
|
+
def play(self, identifier: str, key: str) -> dict[str, Any]:
|
|
1285
|
+
"""Starts a match of a person as a game of the Play page and returns the game.
|
|
1286
|
+
|
|
1287
|
+
Raises:
|
|
1288
|
+
ArgumentError: If the match has no person or cannot play now.
|
|
1289
|
+
GameBusyError: If another game plays.
|
|
1290
|
+
"""
|
|
1291
|
+
if self._games is None:
|
|
1292
|
+
raise MessageError('server.tournament.people_page')
|
|
1293
|
+
with self._lock:
|
|
1294
|
+
record = self._record(identifier)
|
|
1295
|
+
if record['state'] != 'running':
|
|
1296
|
+
raise MessageError('server.tournament.running', state=record['state'])
|
|
1297
|
+
match = self._match(record, key)
|
|
1298
|
+
if not match['person']:
|
|
1299
|
+
raise MessageError('server.tournament.person_match')
|
|
1300
|
+
if key in record['games']:
|
|
1301
|
+
raise MessageError('server.tournament.playing')
|
|
1302
|
+
entrants = record['config']['entrants']
|
|
1303
|
+
sides = [entrants[match['home']], entrants[match['away']]]
|
|
1304
|
+
entry = self._games.start(
|
|
1305
|
+
match['scenario'],
|
|
1306
|
+
match['persons'],
|
|
1307
|
+
match['ability'],
|
|
1308
|
+
match['seed'],
|
|
1309
|
+
True,
|
|
1310
|
+
[side['team'] for side in sides],
|
|
1311
|
+
length=match['length'],
|
|
1312
|
+
tournament=self._provenance(record, match),
|
|
1313
|
+
)
|
|
1314
|
+
record['games'][key] = entry['id']
|
|
1315
|
+
record['notes'].pop(key, None)
|
|
1316
|
+
self._persist(record)
|
|
1317
|
+
return entry
|
|
1318
|
+
|
|
1319
|
+
@staticmethod
|
|
1320
|
+
def _provenance(record: dict[str, Any], match: dict[str, Any]) -> dict[str, Any]:
|
|
1321
|
+
"""Returns what a match's recording keeps of its tournament.
|
|
1322
|
+
|
|
1323
|
+
The names are those of the two entrants, home first, as the tournament shows them, so a
|
|
1324
|
+
renamed team or a built-in AI at an ability keeps its name on Watch, in feeds and videos.
|
|
1325
|
+
The abilities are those that the tournament gives the built-in AI of each side, home
|
|
1326
|
+
first, from the entrant, the stage or the standard, with `None` for the scenario's own,
|
|
1327
|
+
beside the abilities that played, which the provenance keeps as `ability`.
|
|
1328
|
+
"""
|
|
1329
|
+
stage = record['config']['stages'][match['stage']]
|
|
1330
|
+
entrants = record['config']['entrants']
|
|
1331
|
+
sides = [entrants[match['home']], entrants[match['away']]]
|
|
1332
|
+
return {
|
|
1333
|
+
'id': record['id'],
|
|
1334
|
+
**rules.named_from('name', record['config']),
|
|
1335
|
+
**rules.named_from('stage', stage),
|
|
1336
|
+
**rules.named_from('label', match, 'label'),
|
|
1337
|
+
'key': match['key'],
|
|
1338
|
+
'names': [side['name'] for side in sides],
|
|
1339
|
+
# The names again as labels where the rules made them, for the title of the recording.
|
|
1340
|
+
'labels': [rules.label_of(side) for side in sides],
|
|
1341
|
+
'ability': list(match['ability']),
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
def _finish(
|
|
1345
|
+
self, record: dict[str, Any], match: dict[str, Any], result: dict[str, Any]
|
|
1346
|
+
) -> None:
|
|
1347
|
+
"""Keeps a match's result, moves the ratings, and ends a tournament that is complete.
|
|
1348
|
+
|
|
1349
|
+
A match awarded by forfeit was never played, so it leaves the ratings as they are, and so
|
|
1350
|
+
does a match that a side ended by breaking a rule, at the forfeit score.
|
|
1351
|
+
"""
|
|
1352
|
+
replay = result.pop('replay', None) if isinstance(result, dict) else None
|
|
1353
|
+
if isinstance(result.get('rule'), dict):
|
|
1354
|
+
score = list(result['rule'].get('at') or [0, 0])
|
|
1355
|
+
result = forfeit_result(result, record['config']['forfeit_score'])
|
|
1356
|
+
else:
|
|
1357
|
+
score = list(result.get('score') or [0, 0])
|
|
1358
|
+
checked = rules.check_result(result)
|
|
1359
|
+
if isinstance(replay, dict) and isinstance(replay.get('actions'), list):
|
|
1360
|
+
# A match under strict rules keeps its seed and the actions of every step, which
|
|
1361
|
+
# replay it exactly, beside the tournament's file.
|
|
1362
|
+
path = replay_path(record['id'], match['key'])
|
|
1363
|
+
try:
|
|
1364
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1365
|
+
_write(path, {**replay, 'key': match['key'], 'score': score})
|
|
1366
|
+
except OSError as error:
|
|
1367
|
+
_LOGGER.warning('cuju render could not keep the actions of a match, %s', error)
|
|
1368
|
+
record['results'][match['key']] = checked
|
|
1369
|
+
record['failures'].pop(match['key'], None)
|
|
1370
|
+
record['failure_ids'].pop(match['key'], None)
|
|
1371
|
+
record['games'].pop(match['key'], None)
|
|
1372
|
+
entrants = record['config']['entrants']
|
|
1373
|
+
try:
|
|
1374
|
+
if not checked['forfeit']:
|
|
1375
|
+
table = ratings()
|
|
1376
|
+
rules.update_ratings(
|
|
1377
|
+
table, entrants[match['home']], entrants[match['away']], checked['score']
|
|
1378
|
+
)
|
|
1379
|
+
_write(folder() / 'ratings.json', table)
|
|
1380
|
+
except OSError as error:
|
|
1381
|
+
_LOGGER.warning('cuju render could not save the ratings, %s', error)
|
|
1382
|
+
self._versions[record['id']] += 1
|
|
1383
|
+
if self._view(record)['complete'] and record['state'] == 'running':
|
|
1384
|
+
record.update(state='done', finished=time.time())
|
|
1385
|
+
self._persist(record)
|
|
1386
|
+
if record['state'] == 'done':
|
|
1387
|
+
self._release(record['id'])
|
|
1388
|
+
|
|
1389
|
+
# Running.
|
|
1390
|
+
|
|
1391
|
+
def _playable(self, record: dict[str, Any], view: dict[str, Any]) -> bool:
|
|
1392
|
+
"""Returns whether a match of a tournament plays now or can start, besides failed ones."""
|
|
1393
|
+
if record['games'] or any(key[0] == record['id'] for key in self._running):
|
|
1394
|
+
return True
|
|
1395
|
+
return any(match['key'] not in record['failures'] for match in rules.ready(view))
|
|
1396
|
+
|
|
1397
|
+
@staticmethod
|
|
1398
|
+
def _failure(record: dict[str, Any], key: str) -> dict[str, Any]:
|
|
1399
|
+
"""Returns the error fields of a failed match, with the id of its message when it has one.
|
|
1400
|
+
|
|
1401
|
+
The id passes on only with a text and the values that its message names, as a report of a
|
|
1402
|
+
child process does, so a file of an earlier version leaves the error without one.
|
|
1403
|
+
"""
|
|
1404
|
+
ids = record.get('failure_ids', {}).get(key)
|
|
1405
|
+
ids = ids if isinstance(ids, dict) else {}
|
|
1406
|
+
return _messages.report_fields({'error': record['failures'][key], **ids})
|
|
1407
|
+
|
|
1408
|
+
@staticmethod
|
|
1409
|
+
def _failure_fields(record: dict[str, Any], view: dict[str, Any]) -> dict[str, Any]:
|
|
1410
|
+
"""Returns the error fields of a tournament with failed matches, the first named in full.
|
|
1411
|
+
|
|
1412
|
+
The match and its error travel as labels, so the page shows the whole line in its own
|
|
1413
|
+
language.
|
|
1414
|
+
"""
|
|
1415
|
+
labels = {match['key']: rules.label_of(match, 'label') for match in view['matches']}
|
|
1416
|
+
first, *others = record['failures']
|
|
1417
|
+
error = _error_label(record['failures'][first], record['failure_ids'].get(first))
|
|
1418
|
+
values = {'label': labels.get(first, first), 'error': error}
|
|
1419
|
+
if not others:
|
|
1420
|
+
return _messages.fields('server.tournament.match_failed', **values)
|
|
1421
|
+
if len(others) == 1:
|
|
1422
|
+
return _messages.fields('server.tournament.match_failed_one_more', **values)
|
|
1423
|
+
return _messages.fields('server.tournament.match_failed_more', **values, count=len(others))
|
|
1424
|
+
|
|
1425
|
+
def _halt(self, record: dict[str, Any], view: dict[str, Any]) -> None:
|
|
1426
|
+
"""Stops a tournament that only failed matches hold up, until an award or a retry.
|
|
1427
|
+
|
|
1428
|
+
The stopped tournament is free for any process to change, so an award or a resume from
|
|
1429
|
+
the command line reaches it while the server runs.
|
|
1430
|
+
"""
|
|
1431
|
+
record.update(state='stopped', **self._failure_fields(record, view))
|
|
1432
|
+
self._persist(record)
|
|
1433
|
+
self._release(record['id'])
|
|
1434
|
+
|
|
1435
|
+
def _loop(self) -> None:
|
|
1436
|
+
while not self._stop.wait(self._interval):
|
|
1437
|
+
try:
|
|
1438
|
+
self.tick()
|
|
1439
|
+
except Exception as error:
|
|
1440
|
+
_LOGGER.warning('cuju render tournaments, %s: %s', type(error).__name__, error)
|
|
1441
|
+
|
|
1442
|
+
def tick(self) -> None:
|
|
1443
|
+
"""Starts the matches that can play, and takes the results of games that ended."""
|
|
1444
|
+
self._scan()
|
|
1445
|
+
with self._lock:
|
|
1446
|
+
for record in list(self._records.values()):
|
|
1447
|
+
if record['state'] != 'running' or not self._claim(record):
|
|
1448
|
+
continue
|
|
1449
|
+
self._follow_games(record)
|
|
1450
|
+
if self._preflight_pending(record):
|
|
1451
|
+
continue
|
|
1452
|
+
view = self._view(record)
|
|
1453
|
+
if view['complete']:
|
|
1454
|
+
record.update(state='done', finished=time.time())
|
|
1455
|
+
self._persist(record)
|
|
1456
|
+
self._release(record['id'])
|
|
1457
|
+
continue
|
|
1458
|
+
busy = sum(1 for key in self._running if key[0] == record['id'])
|
|
1459
|
+
load = sum(running.get('cost', 1) for running in self._running.values())
|
|
1460
|
+
for match in rules.ready(view):
|
|
1461
|
+
if busy >= record['config']['concurrency']:
|
|
1462
|
+
break
|
|
1463
|
+
cost = self._cost(record, match)
|
|
1464
|
+
# The processes of strict rules count against the threads of the machine,
|
|
1465
|
+
# so matches at once never crowd each other into timeouts that no team caused.
|
|
1466
|
+
if busy and load + cost > _threads():
|
|
1467
|
+
break
|
|
1468
|
+
key = (record['id'], match['key'])
|
|
1469
|
+
if (
|
|
1470
|
+
match['person']
|
|
1471
|
+
or key in self._running
|
|
1472
|
+
or match['key'] in record['failures']
|
|
1473
|
+
):
|
|
1474
|
+
continue
|
|
1475
|
+
self._running[key] = {'progress': 0.0, 'process': None, 'cost': cost}
|
|
1476
|
+
busy += 1
|
|
1477
|
+
load += cost
|
|
1478
|
+
threading.Thread(
|
|
1479
|
+
target=self._run_match, args=(record['id'], dict(match)), daemon=True
|
|
1480
|
+
).start()
|
|
1481
|
+
if record['failures'] and not self._playable(record, view):
|
|
1482
|
+
self._halt(record, view)
|
|
1483
|
+
|
|
1484
|
+
@staticmethod
|
|
1485
|
+
def _cost(record: dict[str, Any], match: dict[str, Any]) -> int:
|
|
1486
|
+
"""Returns the threads that a match takes, its own and those of each team's process.
|
|
1487
|
+
|
|
1488
|
+
Under strict rules each registered team plays in a process of its own with the threads
|
|
1489
|
+
that the rules give, beside the match's process, and a match without them takes one.
|
|
1490
|
+
"""
|
|
1491
|
+
strict = record['config'].get('strict')
|
|
1492
|
+
if not strict:
|
|
1493
|
+
return 1
|
|
1494
|
+
entrants = record['config']['entrants']
|
|
1495
|
+
teams = sum(
|
|
1496
|
+
1 for side in (match['home'], match['away']) if entrants[side]['kind'] == 'team'
|
|
1497
|
+
)
|
|
1498
|
+
return 1 + teams * int(strict.get('threads', 1))
|
|
1499
|
+
|
|
1500
|
+
def _preflight_pending(self, record: dict[str, Any]) -> bool:
|
|
1501
|
+
"""Starts a tournament's pre-flight when it is due, and returns whether it holds matches.
|
|
1502
|
+
|
|
1503
|
+
A tournament with a pre-flight checks its registered teams before its first match, so a
|
|
1504
|
+
file of before, or one that has played a match, plays on without one.
|
|
1505
|
+
"""
|
|
1506
|
+
if not record['config'].get('preflight') or record.get('preflight') is not None:
|
|
1507
|
+
return False
|
|
1508
|
+
if record['results']:
|
|
1509
|
+
return False
|
|
1510
|
+
key = (record['id'], _PREFLIGHT)
|
|
1511
|
+
if key not in self._running:
|
|
1512
|
+
self._running[key] = {'progress': 0.0, 'process': None}
|
|
1513
|
+
threading.Thread(target=self._run_preflight, args=(record['id'],), daemon=True).start()
|
|
1514
|
+
return True
|
|
1515
|
+
|
|
1516
|
+
def _run_preflight(self, identifier: str) -> None:
|
|
1517
|
+
"""Checks every registered team of a tournament and leaves out those that fail."""
|
|
1518
|
+
key = (identifier, _PREFLIGHT)
|
|
1519
|
+
try:
|
|
1520
|
+
with self._lock:
|
|
1521
|
+
record = self._records[identifier]
|
|
1522
|
+
config = record['config']
|
|
1523
|
+
scenario = config['stages'][0]['scenario']
|
|
1524
|
+
limits = config.get('strict')
|
|
1525
|
+
teams = list(
|
|
1526
|
+
dict.fromkeys(e['team'] for e in config['entrants'] if e['kind'] == 'team')
|
|
1527
|
+
)
|
|
1528
|
+
checks: dict[str, dict[str, Any]] = {}
|
|
1529
|
+
for number, team in enumerate(teams):
|
|
1530
|
+
try:
|
|
1531
|
+
report = self._checker(team, scenario, limits)
|
|
1532
|
+
except Exception as error:
|
|
1533
|
+
ids = _messages.id_fields(error)
|
|
1534
|
+
report = {
|
|
1535
|
+
'team': team,
|
|
1536
|
+
'ok': False,
|
|
1537
|
+
'rule': {
|
|
1538
|
+
'side': 'home',
|
|
1539
|
+
'kind': 'load',
|
|
1540
|
+
'reason': str(error),
|
|
1541
|
+
'reason_id': ids.get('error_id'),
|
|
1542
|
+
'reason_values': ids.get('error_values'),
|
|
1543
|
+
'step': 0,
|
|
1544
|
+
'at': [0, 0],
|
|
1545
|
+
},
|
|
1546
|
+
'timings': {},
|
|
1547
|
+
}
|
|
1548
|
+
checks[team] = report
|
|
1549
|
+
self._progress(key, (number + 1) / max(1, len(teams)))
|
|
1550
|
+
except Exception as error:
|
|
1551
|
+
_LOGGER.warning('cuju render could not check the teams, %s', error)
|
|
1552
|
+
checks = None
|
|
1553
|
+
with self._lock:
|
|
1554
|
+
self._running.pop(key, None)
|
|
1555
|
+
record = self._records.get(identifier)
|
|
1556
|
+
if (
|
|
1557
|
+
record is None
|
|
1558
|
+
or record['state'] != 'running'
|
|
1559
|
+
or record['results']
|
|
1560
|
+
or checks is None
|
|
1561
|
+
):
|
|
1562
|
+
return
|
|
1563
|
+
self._apply_preflight(record, checks, scenario)
|
|
1564
|
+
|
|
1565
|
+
def _apply_preflight(
|
|
1566
|
+
self, record: dict[str, Any], checks: dict[str, dict[str, Any]], scenario: str
|
|
1567
|
+
) -> None:
|
|
1568
|
+
"""Keeps the checks of a pre-flight, and makes the tournament again without the teams
|
|
1569
|
+
that failed, from its stages as given, so its draw fits the entrants that remain."""
|
|
1570
|
+
config = record['config']
|
|
1571
|
+
failed = {team for team, report in checks.items() if not report.get('ok')}
|
|
1572
|
+
excluded = []
|
|
1573
|
+
kept = []
|
|
1574
|
+
given = config.get('given_entrants') or []
|
|
1575
|
+
for index, entrant in enumerate(config['entrants']):
|
|
1576
|
+
if entrant['kind'] == 'team' and entrant['team'] in failed:
|
|
1577
|
+
rule = checks[entrant['team']].get('rule') or {}
|
|
1578
|
+
excluded.append(
|
|
1579
|
+
{
|
|
1580
|
+
**rules.named_from('name', entrant),
|
|
1581
|
+
'kind': entrant['kind'],
|
|
1582
|
+
'team': entrant['team'],
|
|
1583
|
+
'reason': rule.get('reason', ''),
|
|
1584
|
+
'reason_id': rule.get('reason_id'),
|
|
1585
|
+
'reason_values': rule.get('reason_values'),
|
|
1586
|
+
'rule': rule.get('kind'),
|
|
1587
|
+
'timings': checks[entrant['team']].get('timings') or {},
|
|
1588
|
+
}
|
|
1589
|
+
)
|
|
1590
|
+
elif index < len(given):
|
|
1591
|
+
kept.append(given[index])
|
|
1592
|
+
record['preflight'] = {
|
|
1593
|
+
'scenario': scenario,
|
|
1594
|
+
'checked': time.time(),
|
|
1595
|
+
'checks': [
|
|
1596
|
+
{
|
|
1597
|
+
'team': team,
|
|
1598
|
+
**rules.named_from('name', report),
|
|
1599
|
+
'ok': bool(report.get('ok')),
|
|
1600
|
+
'rule': report.get('rule'),
|
|
1601
|
+
'timings': report.get('timings') or {},
|
|
1602
|
+
}
|
|
1603
|
+
for team, report in checks.items()
|
|
1604
|
+
],
|
|
1605
|
+
}
|
|
1606
|
+
record['excluded'] = excluded
|
|
1607
|
+
# The digest of each team's folder that passed, which its matches hold it to.
|
|
1608
|
+
record['digests'] = {
|
|
1609
|
+
team: report['digest']
|
|
1610
|
+
for team, report in checks.items()
|
|
1611
|
+
if report.get('ok') and isinstance(report.get('digest'), str)
|
|
1612
|
+
}
|
|
1613
|
+
if excluded:
|
|
1614
|
+
source: dict[str, Any] = {
|
|
1615
|
+
key: config[key]
|
|
1616
|
+
for key in (
|
|
1617
|
+
'seed',
|
|
1618
|
+
'seeding',
|
|
1619
|
+
'concurrency',
|
|
1620
|
+
'forfeit_score',
|
|
1621
|
+
'ability',
|
|
1622
|
+
'preset',
|
|
1623
|
+
'strict',
|
|
1624
|
+
'preflight',
|
|
1625
|
+
)
|
|
1626
|
+
if key in config
|
|
1627
|
+
}
|
|
1628
|
+
if config.get('name_id') is None:
|
|
1629
|
+
source['name'] = config['name']
|
|
1630
|
+
source.update(
|
|
1631
|
+
entrants=kept,
|
|
1632
|
+
stages=config['given']['stages'],
|
|
1633
|
+
defaults=config['given']['defaults'],
|
|
1634
|
+
)
|
|
1635
|
+
try:
|
|
1636
|
+
changed = check_config(source)
|
|
1637
|
+
except ArgumentError as error:
|
|
1638
|
+
record.update(
|
|
1639
|
+
state='stopped',
|
|
1640
|
+
**_messages.fields(
|
|
1641
|
+
'server.preflight.left_out',
|
|
1642
|
+
error=_error_label(str(error), _messages.id_fields(error)),
|
|
1643
|
+
),
|
|
1644
|
+
)
|
|
1645
|
+
self._persist(record)
|
|
1646
|
+
self._release(record['id'])
|
|
1647
|
+
return
|
|
1648
|
+
changed['include'] = config.get('include', [])
|
|
1649
|
+
changed['given_entrants'] = kept
|
|
1650
|
+
record['config'] = changed
|
|
1651
|
+
self._persist(record)
|
|
1652
|
+
|
|
1653
|
+
def _follow_games(self, record: dict[str, Any]) -> None:
|
|
1654
|
+
"""Takes the result of a person's game that ended, or frees a match whose game is gone."""
|
|
1655
|
+
if not record['games'] or self._games is None:
|
|
1656
|
+
return
|
|
1657
|
+
current = self._games.current()
|
|
1658
|
+
view = self._view(record)
|
|
1659
|
+
for key, game in list(record['games'].items()):
|
|
1660
|
+
if current is None or current.get('id') != game:
|
|
1661
|
+
record['games'].pop(key)
|
|
1662
|
+
record['notes'][key] = _note('gone')
|
|
1663
|
+
self._persist(record)
|
|
1664
|
+
continue
|
|
1665
|
+
if current.get('state') not in ('ended', 'failed'):
|
|
1666
|
+
continue
|
|
1667
|
+
match = next((m for m in view['matches'] if m['key'] == key), None)
|
|
1668
|
+
natural = current.get('ending') in ('full time', 'episode')
|
|
1669
|
+
if match is not None and natural and current.get('match'):
|
|
1670
|
+
try:
|
|
1671
|
+
self._finish(record, match, recording_result(current['match']))
|
|
1672
|
+
continue
|
|
1673
|
+
except (ArgumentError, OSError, ValueError) as error:
|
|
1674
|
+
found = _error_label(str(error), _messages.id_fields(error))
|
|
1675
|
+
record['notes'][key] = _note('unreadable', error=found)
|
|
1676
|
+
elif isinstance(current.get('error'), str) and current['error']:
|
|
1677
|
+
# A game that a policy or its process ended names the failure, and Award settles
|
|
1678
|
+
# the match as well as another game does.
|
|
1679
|
+
ids = {k: current.get(k) for k in ('error_id', 'error_values')}
|
|
1680
|
+
if match is not None:
|
|
1681
|
+
error, ids = self._entrant_failure(record, match, current['error'], ids)
|
|
1682
|
+
found = _error_label(error, ids)
|
|
1683
|
+
else:
|
|
1684
|
+
found = _error_label(current['error'], ids)
|
|
1685
|
+
record['notes'][key] = _note('failed', error=found)
|
|
1686
|
+
else:
|
|
1687
|
+
record['notes'][key] = _note('early')
|
|
1688
|
+
record['games'].pop(key, None)
|
|
1689
|
+
self._persist(record)
|
|
1690
|
+
|
|
1691
|
+
def _changed(
|
|
1692
|
+
self, identifier: str, match: dict[str, Any], teams: list[str], kinds: list[str]
|
|
1693
|
+
) -> dict[str, Any] | None:
|
|
1694
|
+
"""Returns the forfeit of a match whose team's files changed since its pre-flight, or None.
|
|
1695
|
+
|
|
1696
|
+
Under strict rules with frozen folders, each team's folder keeps the digest that its
|
|
1697
|
+
pre-flight read, or that its first match read in a tournament without a pre-flight, and
|
|
1698
|
+
a team whose files differ at a later match breaks the rule `changed` before it plays.
|
|
1699
|
+
"""
|
|
1700
|
+
from .._playing import strict as _strict
|
|
1701
|
+
|
|
1702
|
+
strict = match.get('strict')
|
|
1703
|
+
if not strict or not strict.get('frozen', True):
|
|
1704
|
+
return None
|
|
1705
|
+
breaks = []
|
|
1706
|
+
for side, (team, kind) in enumerate(zip(teams, kinds, strict=True)):
|
|
1707
|
+
if kind != 'team':
|
|
1708
|
+
continue
|
|
1709
|
+
try:
|
|
1710
|
+
found = _strict.folder_digest(team)
|
|
1711
|
+
except OSError:
|
|
1712
|
+
found = None
|
|
1713
|
+
with self._lock:
|
|
1714
|
+
record = self._records.get(identifier)
|
|
1715
|
+
if record is None or found is None:
|
|
1716
|
+
continue
|
|
1717
|
+
kept = record.setdefault('digests', {}).get(team)
|
|
1718
|
+
if kept is None:
|
|
1719
|
+
record['digests'][team] = found
|
|
1720
|
+
self._persist(record)
|
|
1721
|
+
elif kept != found:
|
|
1722
|
+
name = record['config']['entrants'][match['home' if side == 0 else 'away']]
|
|
1723
|
+
breaks.append(
|
|
1724
|
+
(
|
|
1725
|
+
side,
|
|
1726
|
+
_messages.label('server.strict.changed', player=rules.label_of(name)),
|
|
1727
|
+
)
|
|
1728
|
+
)
|
|
1729
|
+
if not breaks:
|
|
1730
|
+
return None
|
|
1731
|
+
sides = {side for side, _ in breaks}
|
|
1732
|
+
reason = breaks[0][1]
|
|
1733
|
+
rule = {
|
|
1734
|
+
'side': 'both' if len(sides) > 1 else ('home', 'away')[breaks[0][0]],
|
|
1735
|
+
'kind': 'changed',
|
|
1736
|
+
'reason': reason['text'],
|
|
1737
|
+
'reason_id': reason['id'],
|
|
1738
|
+
'reason_values': reason['values'],
|
|
1739
|
+
'step': 0,
|
|
1740
|
+
'at': [0, 0],
|
|
1741
|
+
}
|
|
1742
|
+
stats = {'steps': 0, 'cut_short': True, 'sides': [None, None]}
|
|
1743
|
+
return {'score': [0, 0], 'rule': rule, 'forfeit': True, 'match': None, 'stats': stats}
|
|
1744
|
+
|
|
1745
|
+
def _run_match(self, identifier: str, match: dict[str, Any]) -> None:
|
|
1746
|
+
key = (identifier, match['key'])
|
|
1747
|
+
result, error, ids = None, None, {}
|
|
1748
|
+
try:
|
|
1749
|
+
with self._lock:
|
|
1750
|
+
record = self._records[identifier]
|
|
1751
|
+
provenance = self._provenance(record, match)
|
|
1752
|
+
entrants = record['config']['entrants']
|
|
1753
|
+
teams = [entrants[match['home']]['team'], entrants[match['away']]['team']]
|
|
1754
|
+
match = {**match, 'strict': record['config'].get('strict')}
|
|
1755
|
+
kinds = [entrants[match['home']]['kind'], entrants[match['away']]['kind']]
|
|
1756
|
+
result = self._changed(identifier, match, teams, kinds) or self._player(
|
|
1757
|
+
match, teams, provenance, lambda share: self._progress(key, share)
|
|
1758
|
+
)
|
|
1759
|
+
except Exception as failure:
|
|
1760
|
+
if isinstance(failure, (ArgumentError, RuntimeError)):
|
|
1761
|
+
error, ids = str(failure), _messages.id_fields(failure)
|
|
1762
|
+
else:
|
|
1763
|
+
error = _messages.described(failure)
|
|
1764
|
+
with self._lock:
|
|
1765
|
+
self._running.pop(key, None)
|
|
1766
|
+
record = self._records.get(identifier)
|
|
1767
|
+
if record is None or record['state'] != 'running':
|
|
1768
|
+
return
|
|
1769
|
+
if result is not None:
|
|
1770
|
+
try:
|
|
1771
|
+
self._finish(record, match, result)
|
|
1772
|
+
return
|
|
1773
|
+
except rules.TournamentError as failure:
|
|
1774
|
+
error, ids = str(failure), _messages.id_fields(failure)
|
|
1775
|
+
if not error:
|
|
1776
|
+
found = _messages.fields('server.tournament.no_result')
|
|
1777
|
+
error, ids = found.pop('error'), found
|
|
1778
|
+
error, ids = self._entrant_failure(record, match, error, ids)
|
|
1779
|
+
# A failed match waits for an award or a retry, and the matches that do not
|
|
1780
|
+
# depend on it play on. The tournament stops once nothing else can play. Its error
|
|
1781
|
+
# keeps the id of its message beside it, as a requested match's does.
|
|
1782
|
+
record['failures'][match['key']] = error
|
|
1783
|
+
record['failure_ids'].pop(match['key'], None)
|
|
1784
|
+
if ids:
|
|
1785
|
+
record['failure_ids'][match['key']] = ids
|
|
1786
|
+
self._persist(record)
|
|
1787
|
+
view = self._view(record)
|
|
1788
|
+
if not self._playable(record, view):
|
|
1789
|
+
self._halt(record, view)
|
|
1790
|
+
|
|
1791
|
+
@staticmethod
|
|
1792
|
+
def _entrant_failure(
|
|
1793
|
+
record: dict[str, Any], match: dict[str, Any], error: str, ids: dict[str, Any]
|
|
1794
|
+
) -> tuple[str, dict[str, Any]]:
|
|
1795
|
+
"""Returns a failure that names the player of a policy as its entrant, with its ids.
|
|
1796
|
+
|
|
1797
|
+
A match plays a team of the registry under the name of its entrant, so the failure of
|
|
1798
|
+
the team Crashers entered as Crash FC reads as the policy of Crash FC.
|
|
1799
|
+
"""
|
|
1800
|
+
values = ids.get('error_values') if isinstance(ids, dict) else None
|
|
1801
|
+
if not isinstance(values, dict) or not isinstance(values.get('player'), str):
|
|
1802
|
+
return error, ids
|
|
1803
|
+
entrants = record['config']['entrants']
|
|
1804
|
+
found = []
|
|
1805
|
+
for side in (match['home'], match['away']):
|
|
1806
|
+
entrant = entrants[side]
|
|
1807
|
+
if entrant['kind'] != 'team':
|
|
1808
|
+
continue
|
|
1809
|
+
try:
|
|
1810
|
+
team = _teams.entry(entrant['team'])
|
|
1811
|
+
except (ArgumentError, KeyError, OSError, ValueError):
|
|
1812
|
+
continue
|
|
1813
|
+
if team.get('name') == values['player']:
|
|
1814
|
+
found.append(entrant)
|
|
1815
|
+
# Two entrants of one team leave the failure as it reads, since either may have failed.
|
|
1816
|
+
if len(found) != 1 or found[0]['name'] == values['player']:
|
|
1817
|
+
return error, ids
|
|
1818
|
+
named = {**values, 'player': rules.label_of(found[0])}
|
|
1819
|
+
try:
|
|
1820
|
+
text = _messages.text(ids['error_id'], **named)
|
|
1821
|
+
except KeyError:
|
|
1822
|
+
return error, ids
|
|
1823
|
+
return text, {**ids, 'error_values': named}
|
|
1824
|
+
|
|
1825
|
+
def _progress(self, key: tuple[str, str], share: float, process: Any = None) -> None:
|
|
1826
|
+
with self._lock:
|
|
1827
|
+
running = self._running.get(key)
|
|
1828
|
+
if running is not None:
|
|
1829
|
+
running['progress'] = share
|
|
1830
|
+
if process is not None:
|
|
1831
|
+
running['process'] = process
|
|
1832
|
+
|
|
1833
|
+
def _play_process(
|
|
1834
|
+
self,
|
|
1835
|
+
match: dict[str, Any],
|
|
1836
|
+
teams: list[str],
|
|
1837
|
+
provenance: dict[str, Any],
|
|
1838
|
+
progress: Callable[..., None],
|
|
1839
|
+
) -> dict[str, Any]:
|
|
1840
|
+
"""Plays a match of agents in a child process and returns its result."""
|
|
1841
|
+
command = [
|
|
1842
|
+
*_home.python_command(),
|
|
1843
|
+
'-m',
|
|
1844
|
+
'cuju.render._playing.play',
|
|
1845
|
+
'--left',
|
|
1846
|
+
teams[0],
|
|
1847
|
+
'--right',
|
|
1848
|
+
teams[1],
|
|
1849
|
+
'--scenario',
|
|
1850
|
+
match['scenario'],
|
|
1851
|
+
'--seed',
|
|
1852
|
+
str(match['seed']),
|
|
1853
|
+
'--tournament',
|
|
1854
|
+
json.dumps(provenance),
|
|
1855
|
+
]
|
|
1856
|
+
if match['length'] is not None:
|
|
1857
|
+
command += ['--length', str(match['length'])]
|
|
1858
|
+
if match.get('strict') is not None:
|
|
1859
|
+
command += ['--strict', json.dumps(match['strict']), '--replay']
|
|
1860
|
+
for side, value in zip(('left', 'right'), match['ability'], strict=True):
|
|
1861
|
+
if value is not None:
|
|
1862
|
+
command += [f'--{side}-ability', repr(value)]
|
|
1863
|
+
process = subprocess.Popen(
|
|
1864
|
+
command,
|
|
1865
|
+
stdin=subprocess.DEVNULL,
|
|
1866
|
+
stdout=subprocess.PIPE,
|
|
1867
|
+
stderr=subprocess.PIPE,
|
|
1868
|
+
text=True,
|
|
1869
|
+
encoding='utf-8',
|
|
1870
|
+
errors='replace',
|
|
1871
|
+
cwd=_home.process_folder(),
|
|
1872
|
+
env=_home.process_environment(),
|
|
1873
|
+
)
|
|
1874
|
+
key = (provenance['id'], match['key'])
|
|
1875
|
+
self._progress(key, 0.0, process)
|
|
1876
|
+
with self._lock:
|
|
1877
|
+
record = self._records.get(provenance['id'])
|
|
1878
|
+
stopped = record is None or record['state'] != 'running' or self._stop.is_set()
|
|
1879
|
+
if stopped:
|
|
1880
|
+
process.kill()
|
|
1881
|
+
stderr: collections.deque[str] = collections.deque(maxlen=20)
|
|
1882
|
+
threading.Thread(target=lambda: stderr.extend(process.stderr), daemon=True).start()
|
|
1883
|
+
timer = threading.Timer(MATCH_SECONDS, process.kill)
|
|
1884
|
+
timer.daemon = True
|
|
1885
|
+
timer.start()
|
|
1886
|
+
saved, failure, reported, stats, rule, replay = None, None, {}, None, None, None
|
|
1887
|
+
try:
|
|
1888
|
+
for line in process.stdout:
|
|
1889
|
+
try:
|
|
1890
|
+
report = json.loads(line)
|
|
1891
|
+
except ValueError:
|
|
1892
|
+
continue
|
|
1893
|
+
if not isinstance(report, dict):
|
|
1894
|
+
continue
|
|
1895
|
+
if isinstance(report.get('progress'), (int, float)):
|
|
1896
|
+
progress(float(report['progress']))
|
|
1897
|
+
if isinstance(report.get('match'), str):
|
|
1898
|
+
saved = report['match']
|
|
1899
|
+
if isinstance(report.get('stats'), dict):
|
|
1900
|
+
stats = report['stats']
|
|
1901
|
+
if isinstance(report.get('forfeit'), dict):
|
|
1902
|
+
rule = report['forfeit']
|
|
1903
|
+
if isinstance(report.get('replay'), dict):
|
|
1904
|
+
replay = report['replay']
|
|
1905
|
+
if isinstance(report.get('error'), str):
|
|
1906
|
+
failure, reported = report['error'], report
|
|
1907
|
+
code = process.wait()
|
|
1908
|
+
finally:
|
|
1909
|
+
timer.cancel()
|
|
1910
|
+
if code == 0 and rule is not None:
|
|
1911
|
+
# A side broke a rule of strict rules, which the runner scores as its forfeit.
|
|
1912
|
+
found = {'score': [0, 0], 'rule': rule, 'stats': stats, 'forfeit': True, 'match': None}
|
|
1913
|
+
return {**found, 'replay': replay} if replay else found
|
|
1914
|
+
if code != 0 or saved is None:
|
|
1915
|
+
# The error that the match reported, or its exit code, with the id of its message.
|
|
1916
|
+
found = (
|
|
1917
|
+
_messages.report_fields(reported)
|
|
1918
|
+
if failure
|
|
1919
|
+
else _messages.exit_fields('server.requests.exit', code, ''.join(stderr).strip())
|
|
1920
|
+
)
|
|
1921
|
+
error = RuntimeError(found['error'])
|
|
1922
|
+
if found['error_id'] is not None:
|
|
1923
|
+
error.message_id, error.values = found['error_id'], found['error_values']
|
|
1924
|
+
raise error
|
|
1925
|
+
result = recording_result(saved)
|
|
1926
|
+
if stats is not None:
|
|
1927
|
+
result['stats'] = stats
|
|
1928
|
+
if replay:
|
|
1929
|
+
result['replay'] = replay
|
|
1930
|
+
return result
|
|
1931
|
+
|
|
1932
|
+
def run(self, identifier: str, report: Callable[[str], None] = print) -> str:
|
|
1933
|
+
"""Runs one tournament in this process until it ends or waits for people.
|
|
1934
|
+
|
|
1935
|
+
Returns:
|
|
1936
|
+
The state it stops in, `done`, `cancelled`, `stopped` or `people`.
|
|
1937
|
+
"""
|
|
1938
|
+
with self._lock:
|
|
1939
|
+
record = self._records.get(identifier)
|
|
1940
|
+
if record is None:
|
|
1941
|
+
raise MessageError(
|
|
1942
|
+
'server.tournament.unknown_identifier', identifier=repr(identifier)
|
|
1943
|
+
)
|
|
1944
|
+
if not self._claim(record):
|
|
1945
|
+
raise MessageError(
|
|
1946
|
+
'server.tournament.other_process_runs', owner=self.owner(identifier)
|
|
1947
|
+
)
|
|
1948
|
+
if record['state'] == 'stopped':
|
|
1949
|
+
record.update(
|
|
1950
|
+
state='running', failures={}, failure_ids={}, **_messages.entry_fields(None)
|
|
1951
|
+
)
|
|
1952
|
+
self._persist(record)
|
|
1953
|
+
played = -1
|
|
1954
|
+
while True:
|
|
1955
|
+
self.tick()
|
|
1956
|
+
with self._lock:
|
|
1957
|
+
record = self._records.get(identifier)
|
|
1958
|
+
if record is None:
|
|
1959
|
+
return 'cancelled'
|
|
1960
|
+
view = self._view(record)
|
|
1961
|
+
busy = any(key[0] == identifier for key in self._running)
|
|
1962
|
+
state = record['state']
|
|
1963
|
+
if view['played'] != played:
|
|
1964
|
+
played = view['played']
|
|
1965
|
+
report(f'{played} of {len(view["matches"])} scheduled matches played')
|
|
1966
|
+
if state != 'running' and not busy:
|
|
1967
|
+
if state == 'stopped':
|
|
1968
|
+
report(record['error'] or 'stopped')
|
|
1969
|
+
return state
|
|
1970
|
+
waiting = [m for m in rules.ready(view) if m['key'] not in record['failures']]
|
|
1971
|
+
if not busy and waiting and all(m['person'] for m in waiting):
|
|
1972
|
+
if record['failures']:
|
|
1973
|
+
report(self._failure_fields(record, view)['error'])
|
|
1974
|
+
return 'people'
|
|
1975
|
+
time.sleep(self._interval)
|