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,2058 @@
|
|
|
1
|
+
# Copyright 2026 The cuju Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""The team registry of the renderer, Kaggle submissions kept in the renderer's folder.
|
|
5
|
+
|
|
6
|
+
A team is a submission of Kaggle's GRF competition, a folder whose `main.py` defines the agent, as
|
|
7
|
+
Kaggle receives it, also accepted as one Python file or an archive. The built-in AI of GRF is always
|
|
8
|
+
a team as well. A submission is code, which runs with the permissions of the process that plays
|
|
9
|
+
it.
|
|
10
|
+
|
|
11
|
+
Each team carries a format, `5v5` or `11v11`, and a style, its kit, its goalkeeper's kit, its
|
|
12
|
+
players' names, numbers, skin and hair, its shirt's painted front and back, its sponsors and its
|
|
13
|
+
player model, GRF's, a shipped one or its own rigged binary glTF file. Only the renderer draws them,
|
|
14
|
+
and none reaches a match. They live in the folder `cuju-team` inside the submission, so a team
|
|
15
|
+
exported from the page carries them along, and a submission folder that holds one brings them back.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import bz2
|
|
21
|
+
import collections
|
|
22
|
+
import contextlib
|
|
23
|
+
import functools
|
|
24
|
+
import gzip
|
|
25
|
+
import hashlib
|
|
26
|
+
import io
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import struct
|
|
33
|
+
import sys
|
|
34
|
+
import tarfile
|
|
35
|
+
import tempfile
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
import types
|
|
39
|
+
import zipfile
|
|
40
|
+
import zlib
|
|
41
|
+
from pathlib import Path, PurePosixPath
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from cuju.errors import ArgumentError
|
|
45
|
+
|
|
46
|
+
from .._store import home as _home
|
|
47
|
+
from .._text import messages as _messages
|
|
48
|
+
from .._text.messages import MessageError
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
import lzma
|
|
52
|
+
except ImportError:
|
|
53
|
+
# A Python built without lzma reads no xz archive, as tarfile does then.
|
|
54
|
+
lzma = None # type: ignore[assignment]
|
|
55
|
+
_LZMA_ERRORS = () if lzma is None else (lzma.LZMAError,)
|
|
56
|
+
|
|
57
|
+
BUILTIN = 'builtin'
|
|
58
|
+
# The words that stand for something other than a team where a team's identifier goes, the random
|
|
59
|
+
# side of a requested match, as _playing/draw.py names it. A folder of teams by such a name, which
|
|
60
|
+
# only a hand could make, is left out of the registry, so no request mistakes one for the other.
|
|
61
|
+
RESERVED = frozenset({'random'})
|
|
62
|
+
# The side of a tournament's random entrant, which plays one player with actions at random. A
|
|
63
|
+
# requested match names a team to draw at random as `random`, so the entrant's side has a name of
|
|
64
|
+
# its own.
|
|
65
|
+
RANDOM_ACTIONS = 'random-actions'
|
|
66
|
+
# The start of a submission folder as the page uploads it, an index and the files' bytes.
|
|
67
|
+
FOLDER_MAGIC = b'CUJDIR1\n'
|
|
68
|
+
# The built-in AI's name until a person renames it, a label that the page shows in its language.
|
|
69
|
+
_BUILTIN_NAME = 'server.names.builtin'
|
|
70
|
+
_BUILTIN_ENTRY = {
|
|
71
|
+
'id': BUILTIN,
|
|
72
|
+
'name': _messages.text(_BUILTIN_NAME),
|
|
73
|
+
'kind': 'builtin',
|
|
74
|
+
'created': None,
|
|
75
|
+
}
|
|
76
|
+
# Kaggle: the file that an archive's agent lives in.
|
|
77
|
+
_MAIN = 'main.py'
|
|
78
|
+
# Submissions larger than this are refused, as Kaggle limits them too.
|
|
79
|
+
_MAXIMUM_BYTES = 100 * 1024 * 1024
|
|
80
|
+
# An archive may unpack to this much and this many files, so a small upload cannot fill the disk.
|
|
81
|
+
_MAXIMUM_UNPACKED = 500 * 1024 * 1024
|
|
82
|
+
_MAXIMUM_FILES = 10000
|
|
83
|
+
# The bytes of a tar archive after decompression that an upload reads at most, its members and
|
|
84
|
+
# their headers, and the bytes of the headers of one member, so a small compressed archive cannot
|
|
85
|
+
# make the server read or hold without end.
|
|
86
|
+
_MAXIMUM_TAR_BYTES = _MAXIMUM_UNPACKED + 64 * 1024 * 1024
|
|
87
|
+
_MAXIMUM_TAR_HEADER = 1024 * 1024
|
|
88
|
+
_LOGGER = logging.getLogger('cuju')
|
|
89
|
+
# One lock serializes every change of the registry, which the server makes from several threads.
|
|
90
|
+
_LOCK = threading.RLock()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_PATTERNS = ('plain', 'stripes', 'hoops', 'halves', 'sash', 'checkers')
|
|
94
|
+
# `grf` keeps the skin or hair that GRF gives the player's index.
|
|
95
|
+
_SKINS = ('grf', 'light', 'tan', 'medium', 'dark')
|
|
96
|
+
_HAIRS = ('grf', 'bald', 'short01', 'short02', 'medium01', 'long01', 'long02')
|
|
97
|
+
_HAIR_COLORS = ('grf', 'black', 'brown', 'darkblonde', 'blonde', 'red')
|
|
98
|
+
_NUMBERS = ('white', 'black', 'secondary')
|
|
99
|
+
_PARTS = ('primary', 'secondary', 'shorts', 'socks')
|
|
100
|
+
# The sizes of a team for competition, the players a side of `5_vs_5` and of the full matches.
|
|
101
|
+
FORMATS = {'5v5': 5, '11v11': 11}
|
|
102
|
+
ROLES = ('GK', 'CB', 'LB', 'RB', 'DM', 'CM', 'LM', 'RM', 'AM', 'CF')
|
|
103
|
+
# The players that a submission of each format may control, as agents of its side. GRF: the
|
|
104
|
+
# goalkeeper of `5_vs_5` is not controllable, every player of the eleven a side scenarios is.
|
|
105
|
+
CONTROLLABLE = {'5v5': 4, '11v11': 11}
|
|
106
|
+
# A submission controls the active player alone by default, as on Kaggle.
|
|
107
|
+
DEFAULT_CONTROL = {
|
|
108
|
+
'players': 1,
|
|
109
|
+
'assignment': 'dynamic',
|
|
110
|
+
'selection': [],
|
|
111
|
+
'goalkeeper': 'scenario',
|
|
112
|
+
'action_set': 'default',
|
|
113
|
+
}
|
|
114
|
+
# The images that a team may hold, the flat front and back of its shirt, the editor's documents
|
|
115
|
+
# that made them, and the sponsors that the adboards show while the team plays.
|
|
116
|
+
IMAGE_SLOTS = (
|
|
117
|
+
'shirt-front',
|
|
118
|
+
'shirt-back',
|
|
119
|
+
'shirt-front-edit',
|
|
120
|
+
'shirt-back-edit',
|
|
121
|
+
'sponsor-1',
|
|
122
|
+
'sponsor-2',
|
|
123
|
+
'sponsor-3',
|
|
124
|
+
'sponsor-4',
|
|
125
|
+
'sponsor-1-edit',
|
|
126
|
+
'sponsor-2-edit',
|
|
127
|
+
'sponsor-3-edit',
|
|
128
|
+
'sponsor-4-edit',
|
|
129
|
+
)
|
|
130
|
+
# The fields of an entry that the team's folder implies, which its file never stores.
|
|
131
|
+
_IMPLIED = ('images', 'sounds', 'sounds_version', 'clips', 'name_id', 'name_values')
|
|
132
|
+
# The folder of a submission that holds the renderer's files of its team.
|
|
133
|
+
TEAM_FOLDER = 'cuju-team'
|
|
134
|
+
_MAXIMUM_IMAGE_BYTES = 8 * 1024 * 1024
|
|
135
|
+
# The longest file of a team's entry that the registry reads, in bytes.
|
|
136
|
+
_MAXIMUM_ENTRY_BYTES = 1024 * 1024
|
|
137
|
+
# The sounds a team may hold, each played when one of its players does that, or its fans sing.
|
|
138
|
+
SOUND_SLOTS = ('goal', 'shot', 'pass', 'tackle', 'save', 'kick', 'celebration', 'chant')
|
|
139
|
+
_MAXIMUM_SOUND_BYTES = 2 * 1024 * 1024
|
|
140
|
+
_SOUND_SUFFIXES = ('.ogg', '.mp3', '.wav')
|
|
141
|
+
_IMAGE_SUFFIXES = ('.png', '.jpg', '.webp', '.json')
|
|
142
|
+
# An imported file that fails its checks keeps its bytes under its name followed by this.
|
|
143
|
+
_REJECTED = '.rejected'
|
|
144
|
+
# Windows: these names open a device in any folder, whatever their extension or case, the ports
|
|
145
|
+
# with a superscript digit as well.
|
|
146
|
+
_SUPERSCRIPTS = r'\N{SUPERSCRIPT ONE}\N{SUPERSCRIPT TWO}\N{SUPERSCRIPT THREE}'
|
|
147
|
+
_DEVICES = re.compile(
|
|
148
|
+
rf'(con|prn|aux|nul|conin\$|conout\$|(com|lpt)[0-9{_SUPERSCRIPTS}])', re.IGNORECASE
|
|
149
|
+
)
|
|
150
|
+
_KIT = {
|
|
151
|
+
'pattern': 'plain',
|
|
152
|
+
'primary': [214, 48, 49],
|
|
153
|
+
'secondary': [255, 255, 255],
|
|
154
|
+
'shorts': [255, 255, 255],
|
|
155
|
+
'socks': [214, 48, 49],
|
|
156
|
+
}
|
|
157
|
+
_GOALKEEPER = {
|
|
158
|
+
'primary': [46, 204, 113],
|
|
159
|
+
'secondary': [20, 20, 20],
|
|
160
|
+
'shorts': [20, 20, 20],
|
|
161
|
+
'socks': [46, 204, 113],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# The kits that new teams take in turn, so that two teams added one after the other differ. Each
|
|
166
|
+
# is a primary color and the color of the shorts, away from the built-in AI's blue and from the
|
|
167
|
+
# goalkeepers' green.
|
|
168
|
+
_NEW_KITS = (
|
|
169
|
+
([214, 48, 49], [255, 255, 255]),
|
|
170
|
+
([230, 126, 34], [20, 20, 20]),
|
|
171
|
+
([142, 68, 173], [255, 255, 255]),
|
|
172
|
+
([241, 196, 15], [20, 20, 20]),
|
|
173
|
+
([45, 52, 54], [255, 255, 255]),
|
|
174
|
+
([232, 67, 147], [255, 255, 255]),
|
|
175
|
+
([255, 255, 255], [20, 20, 20]),
|
|
176
|
+
([127, 0, 32], [255, 255, 255]),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _new_kit() -> dict[str, Any]:
|
|
181
|
+
"""Returns the kit of the next new team, the first of `_NEW_KITS` that fewest teams wear."""
|
|
182
|
+
worn: collections.Counter[tuple[int, ...]] = collections.Counter()
|
|
183
|
+
for path in _home.folder('teams').glob(f'*/{TEAM_FOLDER}/team.json'):
|
|
184
|
+
with contextlib.suppress(OSError, ValueError, TypeError, KeyError, AttributeError):
|
|
185
|
+
team = _read_entry(path)
|
|
186
|
+
worn[tuple(team['style']['kit']['primary'])] += 1
|
|
187
|
+
primary, shorts = min(_NEW_KITS, key=lambda kit: worn[tuple(kit[0])])
|
|
188
|
+
return {
|
|
189
|
+
**_KIT,
|
|
190
|
+
'primary': list(primary),
|
|
191
|
+
'shorts': list(shorts),
|
|
192
|
+
'socks': list(primary),
|
|
193
|
+
'secondary': [20, 20, 20] if primary == [255, 255, 255] else [255, 255, 255],
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _player_style(index: int) -> dict[str, Any]:
|
|
198
|
+
"""Returns the default style of the player at `index` of a team, GRF's own look."""
|
|
199
|
+
return {
|
|
200
|
+
'name': '',
|
|
201
|
+
'number': index + 1,
|
|
202
|
+
'skin': 'grf',
|
|
203
|
+
'hair': 'grf',
|
|
204
|
+
'hair_color': 'grf',
|
|
205
|
+
'model': 'team',
|
|
206
|
+
'celebration': 'team',
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def default_style(format: str = '11v11', kit: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
211
|
+
"""Returns the style of a new team of `format`, GRF's model in a plain kit."""
|
|
212
|
+
colors = kit or _KIT
|
|
213
|
+
return {
|
|
214
|
+
'model': 'grf',
|
|
215
|
+
'kit': dict(colors),
|
|
216
|
+
'goalkeeper': dict(_GOALKEEPER),
|
|
217
|
+
'badge': '',
|
|
218
|
+
'number': 'white',
|
|
219
|
+
'colors': {},
|
|
220
|
+
# The fans wear the kit's colors unless the team picks colors of their own.
|
|
221
|
+
'crowd': {'colors': [list(colors['primary']), list(colors['secondary'])]},
|
|
222
|
+
# The celebrations the page picks from after a goal, empty for every one it knows.
|
|
223
|
+
'celebrations': [],
|
|
224
|
+
# The models on whose backs the page leaves out the shirt numbers, which every model but
|
|
225
|
+
# GRF's, which paints its own, wears where its torso holds one.
|
|
226
|
+
'hidden_numbers': [],
|
|
227
|
+
'players': [_player_style(index) for index in range(FORMATS[format])],
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_BUILTIN_KIT = {
|
|
232
|
+
'pattern': 'plain',
|
|
233
|
+
'primary': [9, 132, 227],
|
|
234
|
+
'secondary': [255, 255, 255],
|
|
235
|
+
'shorts': [9, 132, 227],
|
|
236
|
+
'socks': [255, 255, 255],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _color(value: Any, name: Any) -> list[int]:
|
|
241
|
+
"""Returns an RGB color as three integers from 0 to 255, or raises `ArgumentError`."""
|
|
242
|
+
if (
|
|
243
|
+
not isinstance(value, (list, tuple))
|
|
244
|
+
or len(value) != 3
|
|
245
|
+
or not all(
|
|
246
|
+
isinstance(part, int) and not isinstance(part, bool) and 0 <= part <= 255
|
|
247
|
+
for part in value
|
|
248
|
+
)
|
|
249
|
+
):
|
|
250
|
+
raise MessageError('server.teams.color', name=name, value=repr(value))
|
|
251
|
+
return [int(part) for part in value]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _printable(text: str) -> str:
|
|
255
|
+
"""Returns `text` with every character that prints nothing of its own turned into a space.
|
|
256
|
+
|
|
257
|
+
These are the control characters, the escapes of a terminal and line breaks among them, and
|
|
258
|
+
the format characters, the marks that turn the direction of text around among them, which
|
|
259
|
+
`str.isprintable` finds, and every space but the plain one.
|
|
260
|
+
"""
|
|
261
|
+
return ''.join(character if character.isprintable() else ' ' for character in text)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _field(name: str) -> dict[str, Any]:
|
|
265
|
+
"""Returns the name of a field of a team in a message, the label `server.teams.field.<name>`."""
|
|
266
|
+
return _messages.label(f'server.teams.field.{name}')
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _choice(value: Any, choices: tuple[str, ...], name: Any) -> str:
|
|
270
|
+
"""Returns `value` if it is one of `choices`, or raises `ArgumentError`."""
|
|
271
|
+
if value not in choices:
|
|
272
|
+
raise MessageError(
|
|
273
|
+
'server.teams.choice', name=name, choices=', '.join(choices), value=repr(value)
|
|
274
|
+
)
|
|
275
|
+
return value
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _players(
|
|
279
|
+
value: Any, current: list[dict[str, Any]], models: tuple[str, ...] = ()
|
|
280
|
+
) -> list[dict[str, Any]]:
|
|
281
|
+
"""Returns the roster with the changes of `value`, a list of per-player dictionaries."""
|
|
282
|
+
if not isinstance(value, list) or len(value) > len(current):
|
|
283
|
+
raise MessageError('server.teams.players', count=len(current), value=repr(value))
|
|
284
|
+
roster = [dict(player) for player in current]
|
|
285
|
+
for index, changes in enumerate(value):
|
|
286
|
+
if not isinstance(changes, dict):
|
|
287
|
+
raise MessageError('server.teams.player', index=index, value=repr(changes))
|
|
288
|
+
for key, entry in changes.items():
|
|
289
|
+
if key == 'name':
|
|
290
|
+
if not isinstance(entry, str) or len(entry) > 24:
|
|
291
|
+
raise MessageError('server.teams.player_name', name=repr(entry))
|
|
292
|
+
roster[index][key] = _printable(entry)
|
|
293
|
+
elif key == 'number':
|
|
294
|
+
if isinstance(entry, bool) or not isinstance(entry, int) or not 0 <= entry <= 99:
|
|
295
|
+
raise MessageError('server.teams.shirt_number', number=repr(entry))
|
|
296
|
+
roster[index][key] = entry
|
|
297
|
+
elif key == 'skin':
|
|
298
|
+
roster[index][key] = _choice(entry, _SKINS, _field('skin'))
|
|
299
|
+
elif key == 'hair':
|
|
300
|
+
roster[index][key] = _choice(entry, _HAIRS, _field('hair'))
|
|
301
|
+
elif key == 'hair_color':
|
|
302
|
+
roster[index][key] = _choice(entry, _HAIR_COLORS, _field('hair_color'))
|
|
303
|
+
elif key == 'celebration':
|
|
304
|
+
# `team` picks among the team's celebrations, any other value is the player's own.
|
|
305
|
+
roster[index][key] = _celebration(entry)
|
|
306
|
+
elif key == 'model':
|
|
307
|
+
# `team` follows the team's model, any other value gives the player one of its own.
|
|
308
|
+
roster[index][key] = _choice(
|
|
309
|
+
entry, ('team', 'grf', 'custom', *models), _field('player_model')
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
raise MessageError('server.teams.player_entry', key=repr(key))
|
|
313
|
+
return roster
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_HEX = re.compile(r'#[0-9a-f]{6}')
|
|
317
|
+
# A celebration is named by the page, such as `knee_slide`, or `clip:` and an animation of a model.
|
|
318
|
+
_CELEBRATION = re.compile(r'[a-z0-9_]{1,40}(:[A-Za-z0-9_. -]{1,60})?')
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _celebration(value: Any) -> str:
|
|
322
|
+
"""Returns the name of a celebration, which the page knows or finds among a model's clips."""
|
|
323
|
+
if not isinstance(value, str) or not _CELEBRATION.fullmatch(value):
|
|
324
|
+
raise MessageError('server.teams.celebration', value=repr(value))
|
|
325
|
+
return value
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# A model's colors to replace, at most this many per model.
|
|
329
|
+
_MAXIMUM_RECOLORS = 32
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _colors(value: Any, models: tuple[str, ...]) -> dict[str, dict[str, str]]:
|
|
333
|
+
"""Returns the colors that replace others on each model, as `#rrggbb` pairs by model.
|
|
334
|
+
|
|
335
|
+
The page lists the colors present in a model's textures and materials, and each pair maps one
|
|
336
|
+
of them to the color the team wants instead. GRF's own model takes the kit's colors instead.
|
|
337
|
+
"""
|
|
338
|
+
if not isinstance(value, dict):
|
|
339
|
+
raise MessageError('server.teams.colors', value=repr(value))
|
|
340
|
+
result: dict[str, dict[str, str]] = {}
|
|
341
|
+
for model, pairs in value.items():
|
|
342
|
+
_choice(model, models, _field('colors_model'))
|
|
343
|
+
if not isinstance(pairs, dict) or len(pairs) > _MAXIMUM_RECOLORS:
|
|
344
|
+
raise MessageError(
|
|
345
|
+
'server.teams.colors_count', count=_MAXIMUM_RECOLORS, model=repr(model)
|
|
346
|
+
)
|
|
347
|
+
result[model] = {}
|
|
348
|
+
for old, new in pairs.items():
|
|
349
|
+
if not (
|
|
350
|
+
isinstance(old, str)
|
|
351
|
+
and isinstance(new, str)
|
|
352
|
+
and _HEX.fullmatch(old.lower())
|
|
353
|
+
and _HEX.fullmatch(new.lower())
|
|
354
|
+
):
|
|
355
|
+
raise MessageError('server.teams.color_pair', old=repr(old), new=repr(new))
|
|
356
|
+
result[model][old.lower()] = new.lower()
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _hidden_numbers(value: Any, models: tuple[str, ...]) -> list[str]:
|
|
361
|
+
"""Returns the models on which a team hides its shirt numbers, each listed once.
|
|
362
|
+
|
|
363
|
+
A model that is no longer shipped is left out, so a style saved with it still loads.
|
|
364
|
+
"""
|
|
365
|
+
if (
|
|
366
|
+
not isinstance(value, list)
|
|
367
|
+
or len(value) > 64
|
|
368
|
+
or not all(isinstance(model, str) and len(model) <= 64 for model in value)
|
|
369
|
+
):
|
|
370
|
+
raise MessageError('server.teams.hidden_numbers', value=repr(value))
|
|
371
|
+
return list(dict.fromkeys(model for model in value if model in models))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def style(
|
|
375
|
+
changes: dict[str, Any] | None, base: dict[str, Any], models: tuple[str, ...] = ()
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
"""Returns the complete style `base` with `changes` applied and checked.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
changes: Entries of the style to change, or `None`.
|
|
381
|
+
base: The style to change, whose roster sets the team's size.
|
|
382
|
+
models: The shipped models a team may choose besides `grf` and `custom`.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
ArgumentError: If a change names an unknown entry or holds a value outside its choices.
|
|
386
|
+
"""
|
|
387
|
+
result = json.loads(json.dumps(base))
|
|
388
|
+
changes = changes or {}
|
|
389
|
+
if not isinstance(changes, dict):
|
|
390
|
+
raise MessageError('server.teams.style', value=repr(changes))
|
|
391
|
+
for key, value in changes.items():
|
|
392
|
+
if key in ('kit', 'goalkeeper'):
|
|
393
|
+
if not isinstance(value, dict):
|
|
394
|
+
raise MessageError('server.teams.style_part_object', key=key, value=repr(value))
|
|
395
|
+
for part, entry in value.items():
|
|
396
|
+
if key == 'kit' and part == 'pattern':
|
|
397
|
+
result[key][part] = _choice(entry, _PATTERNS, _field('pattern'))
|
|
398
|
+
elif part in _PARTS:
|
|
399
|
+
result[key][part] = _color(entry, f'{key} {part}')
|
|
400
|
+
else:
|
|
401
|
+
raise MessageError('server.teams.style_part', key=key, part=repr(part))
|
|
402
|
+
elif key == 'model':
|
|
403
|
+
result[key] = _choice(value, ('grf', 'custom', *models), _field('model'))
|
|
404
|
+
elif key == 'number':
|
|
405
|
+
result[key] = _choice(value, _NUMBERS, _field('numbers'))
|
|
406
|
+
elif key == 'badge':
|
|
407
|
+
if not isinstance(value, str) or len(value) > 3:
|
|
408
|
+
raise MessageError('server.teams.badge', value=repr(value))
|
|
409
|
+
result[key] = value
|
|
410
|
+
elif key == 'players':
|
|
411
|
+
result[key] = _players(value, result[key], models)
|
|
412
|
+
elif key == 'colors':
|
|
413
|
+
result[key] = _colors(value, ('custom', *models))
|
|
414
|
+
elif key == 'celebrations':
|
|
415
|
+
if not isinstance(value, list) or len(value) > 32:
|
|
416
|
+
raise MessageError('server.teams.celebrations')
|
|
417
|
+
result[key] = [_celebration(item) for item in value]
|
|
418
|
+
elif key == 'hidden_numbers':
|
|
419
|
+
result[key] = _hidden_numbers(value, ('custom', *models))
|
|
420
|
+
elif key == 'crowd':
|
|
421
|
+
if not isinstance(value, dict) or set(value) - {'colors'}:
|
|
422
|
+
raise MessageError('server.teams.crowd', value=repr(value))
|
|
423
|
+
colors = value.get('colors', result[key]['colors'])
|
|
424
|
+
if not isinstance(colors, list) or not 1 <= len(colors) <= 4:
|
|
425
|
+
raise MessageError('server.teams.crowd_colors')
|
|
426
|
+
result[key] = {'colors': [_color(color, _field('fans_color')) for color in colors]}
|
|
427
|
+
else:
|
|
428
|
+
raise MessageError('server.teams.style_entry', key=repr(key))
|
|
429
|
+
return result
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _resize(style_: dict[str, Any], format: str) -> dict[str, Any]:
|
|
433
|
+
"""Returns the style with its roster cut or extended to the size of `format`."""
|
|
434
|
+
size = FORMATS[format]
|
|
435
|
+
roster = style_['players'][:size]
|
|
436
|
+
roster += [_player_style(index) for index in range(len(roster), size)]
|
|
437
|
+
return {**style_, 'players': roster}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def shipped_models() -> list[dict[str, Any]]:
|
|
441
|
+
"""Returns the description of every player model that the package ships."""
|
|
442
|
+
folder = Path(__file__).parents[1] / 'web' / 'assets' / 'models'
|
|
443
|
+
models = []
|
|
444
|
+
for path in sorted(folder.glob('*.json')):
|
|
445
|
+
with contextlib.suppress(OSError, ValueError):
|
|
446
|
+
description = json.loads(path.read_text(encoding='utf-8'))
|
|
447
|
+
if path.with_suffix('.glb').is_file():
|
|
448
|
+
description['url'] = f'assets/models/{path.stem}.glb'
|
|
449
|
+
models.append(description)
|
|
450
|
+
return models
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _model_names() -> tuple[str, ...]:
|
|
454
|
+
"""Returns the identifiers of the shipped models."""
|
|
455
|
+
return tuple(model['id'] for model in shipped_models())
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _complete(entry: dict[str, Any], kit: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
459
|
+
"""Returns a stored entry with its format, style, model and images filled and checked."""
|
|
460
|
+
entry = dict(entry)
|
|
461
|
+
if not isinstance(entry.get('format'), str) or entry['format'] not in FORMATS:
|
|
462
|
+
entry['format'] = '11v11'
|
|
463
|
+
base = default_style(entry['format'], kit)
|
|
464
|
+
saved = entry.get('style') if isinstance(entry.get('style'), dict) else {}
|
|
465
|
+
try:
|
|
466
|
+
entry['style'] = _resize(
|
|
467
|
+
style(saved, {**base, **_known(saved, base)}, _model_names()), entry['format']
|
|
468
|
+
)
|
|
469
|
+
except ArgumentError:
|
|
470
|
+
entry['style'] = base
|
|
471
|
+
entry.setdefault('model', None)
|
|
472
|
+
try:
|
|
473
|
+
entry['control'] = check_control(entry.get('control'), entry['format'])
|
|
474
|
+
except ArgumentError:
|
|
475
|
+
entry['control'] = dict(DEFAULT_CONTROL)
|
|
476
|
+
folder = _meta_path(entry['id']).parent
|
|
477
|
+
names = _names(folder)
|
|
478
|
+
entry['images'] = [slot for slot in IMAGE_SLOTS if _image_file(folder, slot, names)]
|
|
479
|
+
entry['sounds'] = [slot for slot in SOUND_SLOTS if _sound_file(folder, slot, names)]
|
|
480
|
+
entry['sounds_version'] = _sounds_version(folder)
|
|
481
|
+
from . import clips as _clips
|
|
482
|
+
|
|
483
|
+
entry['clips'] = _clips.listing(folder)
|
|
484
|
+
return entry
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _sounds_version(folder: Path) -> str:
|
|
488
|
+
"""Returns a short text that changes whenever a sound of a team's folder changes.
|
|
489
|
+
|
|
490
|
+
The page keeps the sounds it decoded under this text, so a sound replaced under the same slot
|
|
491
|
+
loads again. Each file's size, modification time and file number go in, and a sound stored
|
|
492
|
+
anew always lands as a new file.
|
|
493
|
+
"""
|
|
494
|
+
parts = []
|
|
495
|
+
names = _names(folder)
|
|
496
|
+
for slot in SOUND_SLOTS:
|
|
497
|
+
path = _sound_file(folder, slot, names)
|
|
498
|
+
with contextlib.suppress(OSError):
|
|
499
|
+
if path is not None:
|
|
500
|
+
stat = path.stat()
|
|
501
|
+
parts.append(f'{path.name}:{stat.st_size}:{stat.st_mtime_ns}:{stat.st_ino}')
|
|
502
|
+
if not parts:
|
|
503
|
+
return ''
|
|
504
|
+
return hashlib.sha256('/'.join(parts).encode()).hexdigest()[:16]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _known(saved: dict[str, Any], base: dict[str, Any]) -> dict[str, Any]:
|
|
508
|
+
"""Returns the entries of a saved style that the current style still has, for old files."""
|
|
509
|
+
return {key: value for key, value in saved.items() if key in base and key != 'players'}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _meta_path(identifier: str) -> Path:
|
|
513
|
+
"""Returns the file of a team's entry, the built-in AI's included.
|
|
514
|
+
|
|
515
|
+
Raises:
|
|
516
|
+
ArgumentError: If the identifier could name a file outside the folder of teams.
|
|
517
|
+
"""
|
|
518
|
+
if not isinstance(identifier, str) or not _home.valid_identifier(identifier):
|
|
519
|
+
raise MessageError('server.teams.unknown', identifier=repr(identifier))
|
|
520
|
+
return _home.folder('teams') / identifier / TEAM_FOLDER / 'team.json'
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _read_entry(path: Path) -> dict[str, Any]:
|
|
524
|
+
"""Returns the stored entry of a team's file, or raises `ValueError` for any other content.
|
|
525
|
+
|
|
526
|
+
The file is at most `_MAXIMUM_ENTRY_BYTES` long and holds a JSON object.
|
|
527
|
+
"""
|
|
528
|
+
with path.open('rb') as stream:
|
|
529
|
+
data = stream.read(_MAXIMUM_ENTRY_BYTES + 1)
|
|
530
|
+
if len(data) > _MAXIMUM_ENTRY_BYTES:
|
|
531
|
+
raise ValueError('the file of the team is too long')
|
|
532
|
+
try:
|
|
533
|
+
saved = json.loads(data)
|
|
534
|
+
except RecursionError as error:
|
|
535
|
+
raise ValueError('the file of the team nests too deeply') from error
|
|
536
|
+
if not isinstance(saved, dict):
|
|
537
|
+
raise ValueError('the file of the team holds no object')
|
|
538
|
+
return saved
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _builtin() -> dict[str, Any]:
|
|
542
|
+
"""Returns the entry of the built-in AI, with the name and style its file keeps, if any."""
|
|
543
|
+
entry = dict(_BUILTIN_ENTRY)
|
|
544
|
+
with contextlib.suppress(OSError, ValueError):
|
|
545
|
+
saved = _read_entry(_meta_path(BUILTIN))
|
|
546
|
+
entry.update({key: saved[key] for key in ('style', 'model', 'format') if key in saved})
|
|
547
|
+
name = _printable(saved['name']).strip()[:80] if isinstance(saved.get('name'), str) else ''
|
|
548
|
+
if name:
|
|
549
|
+
entry['name'] = name
|
|
550
|
+
return _complete(_builtin_name(entry), _BUILTIN_KIT)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _builtin_name(entry: dict[str, Any]) -> dict[str, Any]:
|
|
554
|
+
"""Returns the built-in AI's entry with the message of its name while it keeps the default.
|
|
555
|
+
|
|
556
|
+
The page sends the name back with every change of the style, so a stored name equal to the
|
|
557
|
+
English default still counts as the default.
|
|
558
|
+
"""
|
|
559
|
+
entry = {key: value for key, value in entry.items() if key not in ('name_id', 'name_values')}
|
|
560
|
+
if entry.get('name') == _BUILTIN_ENTRY['name']:
|
|
561
|
+
entry.update(_messages.named('name', _messages.label(_BUILTIN_NAME)))
|
|
562
|
+
return entry
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _reason(error: BaseException) -> dict[str, Any] | str:
|
|
566
|
+
"""Returns an error as a value of another message, a label when it carries a message id."""
|
|
567
|
+
message_id = getattr(error, 'message_id', None)
|
|
568
|
+
if isinstance(message_id, str) and message_id in _messages.MESSAGES:
|
|
569
|
+
return _messages.label(message_id, **getattr(error, 'values', {}))
|
|
570
|
+
return str(error)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def shown_name(team: dict[str, Any]) -> dict[str, Any] | str:
|
|
574
|
+
"""Returns the name of a team as a message value, a label for a name that cuju made."""
|
|
575
|
+
found = {'id': team.get('name_id'), 'text': team.get('name'), 'values': team.get('name_values')}
|
|
576
|
+
return found if _messages.valid_label(found) else str(team.get('name'))
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def teams() -> list[dict[str, Any]]:
|
|
580
|
+
"""Returns every team, the built-in AI first, then the submissions from newest to oldest.
|
|
581
|
+
|
|
582
|
+
A team's identifier is the name of its folder, whatever its file says, and a folder whose
|
|
583
|
+
name is no identifier, or a word of `RESERVED`, or whose file fails to read as an entry, is
|
|
584
|
+
left out alone.
|
|
585
|
+
"""
|
|
586
|
+
entries = []
|
|
587
|
+
for folder in _home.folder('teams').iterdir():
|
|
588
|
+
if folder.name in (BUILTIN, RANDOM_ACTIONS) or not _home.valid_identifier(folder.name):
|
|
589
|
+
continue
|
|
590
|
+
if folder.name in RESERVED:
|
|
591
|
+
_LOGGER.warning(
|
|
592
|
+
'cuju render leaves out the team folder %s, whose name stands for a random side, '
|
|
593
|
+
'rename the folder to keep the team',
|
|
594
|
+
folder.name,
|
|
595
|
+
)
|
|
596
|
+
continue
|
|
597
|
+
meta = folder / TEAM_FOLDER / 'team.json'
|
|
598
|
+
try:
|
|
599
|
+
if not (folder.is_dir() and meta.is_file()):
|
|
600
|
+
continue
|
|
601
|
+
saved = _read_entry(meta)
|
|
602
|
+
if not isinstance(saved.get('created'), str):
|
|
603
|
+
saved['created'] = ''
|
|
604
|
+
# A name of a file edited by hand prints in a terminal too, so its control and format
|
|
605
|
+
# characters, such as escapes and the marks that turn text around, become spaces.
|
|
606
|
+
name = saved.get('name')
|
|
607
|
+
name = _printable(name).strip()[:80] if isinstance(name, str) else ''
|
|
608
|
+
saved['name'] = name or folder.name
|
|
609
|
+
entry = _complete({**saved, 'id': folder.name, 'kind': 'kaggle'})
|
|
610
|
+
except Exception as error:
|
|
611
|
+
_LOGGER.warning('cuju render leaves out the team %s, %s', folder.name, error)
|
|
612
|
+
continue
|
|
613
|
+
entries.append(entry)
|
|
614
|
+
entries.sort(key=lambda entry: entry.get('created') or '', reverse=True)
|
|
615
|
+
return [_builtin(), *entries]
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def check_control(control: Any, format: str) -> dict[str, Any]:
|
|
619
|
+
"""Returns normalized team control and migrates earlier settings."""
|
|
620
|
+
control = dict(DEFAULT_CONTROL) if control is None else control
|
|
621
|
+
allowed = {'players', 'goalkeeper', 'assignment', 'selection', 'action_set'}
|
|
622
|
+
if not isinstance(control, dict) or set(control) - allowed:
|
|
623
|
+
raise MessageError('server.teams.control.fields')
|
|
624
|
+
players = control.get('players', 1)
|
|
625
|
+
most = FORMATS[format]
|
|
626
|
+
if isinstance(players, bool) or not isinstance(players, int) or not 1 <= players <= most:
|
|
627
|
+
raise MessageError('server.teams.control.players', most=most)
|
|
628
|
+
legacy = 'assignment' not in control
|
|
629
|
+
keeper = _choice(
|
|
630
|
+
control.get('goalkeeper', 'scenario'),
|
|
631
|
+
('agent', 'builtin', 'scenario'),
|
|
632
|
+
_field('goalkeeper'),
|
|
633
|
+
)
|
|
634
|
+
assignment = control.get('assignment', 'fixed' if keeper == 'builtin' else 'dynamic')
|
|
635
|
+
_choice(assignment, ('dynamic', 'fixed'), _field('assignment'))
|
|
636
|
+
if legacy and keeper == 'builtin' and (format != '11v11' or players != 10):
|
|
637
|
+
raise MessageError('server.teams.control.legacy_goalkeeper')
|
|
638
|
+
if legacy and keeper == 'agent':
|
|
639
|
+
keeper = 'scenario'
|
|
640
|
+
if assignment == 'dynamic' and keeper == 'builtin':
|
|
641
|
+
raise MessageError('server.teams.control.goalkeeper_fixed')
|
|
642
|
+
selection = control.get('selection', [])
|
|
643
|
+
if legacy and assignment == 'fixed':
|
|
644
|
+
selection = list(range(1, players + 1))
|
|
645
|
+
if not isinstance(selection, list):
|
|
646
|
+
raise MessageError('server.teams.control.selection_list')
|
|
647
|
+
if assignment == 'fixed' and len(selection) != players:
|
|
648
|
+
raise MessageError('server.teams.control.selection_count')
|
|
649
|
+
if assignment == 'dynamic' and selection:
|
|
650
|
+
raise MessageError('server.teams.control.selection_empty')
|
|
651
|
+
if selection:
|
|
652
|
+
indices = all(type(item) is int and 0 <= item <= 10 for item in selection)
|
|
653
|
+
roles = all(isinstance(item, str) and item in ROLES for item in selection)
|
|
654
|
+
if not (indices or roles) or (indices and len(set(selection)) != len(selection)):
|
|
655
|
+
raise MessageError('server.teams.control.selection_distinct')
|
|
656
|
+
action_set = _choice(
|
|
657
|
+
control.get('action_set', 'default'), ('default', 'v2'), _field('action_set')
|
|
658
|
+
)
|
|
659
|
+
return dict(
|
|
660
|
+
players=players,
|
|
661
|
+
assignment=assignment,
|
|
662
|
+
selection=list(selection),
|
|
663
|
+
goalkeeper=keeper,
|
|
664
|
+
action_set=action_set,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def scenario_path(scenario: str) -> Path:
|
|
669
|
+
"""Returns the source of a shipped or renderer scenario."""
|
|
670
|
+
local = Path(__file__).parents[1] / 'scenarios' / f'{scenario}.py'
|
|
671
|
+
if local.is_file():
|
|
672
|
+
return local
|
|
673
|
+
import cuju
|
|
674
|
+
|
|
675
|
+
return Path(cuju.__file__).parent / 'data' / 'scenarios' / f'{scenario}.py'
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def renderer_scenarios() -> list[str]:
|
|
679
|
+
"""Returns the additional training scenarios."""
|
|
680
|
+
return sorted(path.stem for path in (Path(__file__).parents[1] / 'scenarios').glob('*.py'))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def scenario_players(scenario: str, side: int) -> list[Any]:
|
|
684
|
+
"""Returns the initial formation of a side."""
|
|
685
|
+
from cuju.scenarios import Builder, load_scenario
|
|
686
|
+
|
|
687
|
+
builder = Builder(0)
|
|
688
|
+
load_scenario(scenario_path(scenario))(builder)
|
|
689
|
+
config = builder.config()
|
|
690
|
+
return config.left_team if side == 0 else config.right_team
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@functools.cache
|
|
694
|
+
def scenario_sides(scenario: str) -> tuple[tuple[tuple[int, bool], ...], ...]:
|
|
695
|
+
"""Returns the role of each player of both sides of a scenario and whether it is controllable.
|
|
696
|
+
|
|
697
|
+
The page checks with them which teams can play a side, as `check_scenario` checks it, and a
|
|
698
|
+
scenario loads once for each process, as loading one takes the better part of a second.
|
|
699
|
+
"""
|
|
700
|
+
from cuju.scenarios import Builder, load_scenario
|
|
701
|
+
|
|
702
|
+
builder = Builder(0)
|
|
703
|
+
load_scenario(scenario_path(scenario))(builder)
|
|
704
|
+
config = builder.config()
|
|
705
|
+
return tuple(
|
|
706
|
+
tuple((int(player.role), bool(player.controllable)) for player in team)
|
|
707
|
+
for team in (config.left_team, config.right_team)
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def agent_count(team: dict[str, Any], scenario: str | None = None, side: int = 0) -> int:
|
|
712
|
+
"""Returns the engine agents required by a side."""
|
|
713
|
+
if team['kind'] == 'builtin':
|
|
714
|
+
return 0
|
|
715
|
+
if team['kind'] == 'random':
|
|
716
|
+
return 1
|
|
717
|
+
control = check_control(team.get('control'), team['format'])
|
|
718
|
+
if control['assignment'] == 'dynamic':
|
|
719
|
+
return control['players']
|
|
720
|
+
if scenario is None:
|
|
721
|
+
return CONTROLLABLE[team['format']]
|
|
722
|
+
return sum(player.controllable for player in scenario_players(scenario, side))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def selected_players(control: dict[str, Any], formation: list[Any]) -> list[int]:
|
|
726
|
+
"""Resolves selections in agent order, with repeated roles in lineup order."""
|
|
727
|
+
available = [index for index, player in enumerate(formation) if player.controllable]
|
|
728
|
+
if control['players'] > len(available):
|
|
729
|
+
raise MessageError('server.teams.control.too_few', count=len(available))
|
|
730
|
+
keeper = [index for index, player in enumerate(formation) if player.role == 0]
|
|
731
|
+
if control['assignment'] == 'dynamic':
|
|
732
|
+
if control['goalkeeper'] == 'agent' and not any(i in available for i in keeper):
|
|
733
|
+
raise MessageError('server.teams.control.goalkeeper_uncontrollable')
|
|
734
|
+
return available
|
|
735
|
+
selected = []
|
|
736
|
+
for item in control['selection']:
|
|
737
|
+
candidates = (
|
|
738
|
+
[item]
|
|
739
|
+
if type(item) is int
|
|
740
|
+
else [index for index in available if formation[index].role == ROLES.index(item)]
|
|
741
|
+
)
|
|
742
|
+
found = next((i for i in candidates if i in available and i not in selected), None)
|
|
743
|
+
if found is None:
|
|
744
|
+
raise MessageError('server.teams.control.select_player', player=item)
|
|
745
|
+
selected.append(found)
|
|
746
|
+
if control['goalkeeper'] == 'builtin' and not any(i in available for i in keeper):
|
|
747
|
+
raise MessageError('server.teams.control.goalkeeper_builtin')
|
|
748
|
+
owns_keeper = any(i in keeper for i in selected)
|
|
749
|
+
if control['goalkeeper'] == 'agent' and not owns_keeper:
|
|
750
|
+
raise MessageError('server.teams.control.goalkeeper_selected')
|
|
751
|
+
if control['goalkeeper'] in ('builtin', 'scenario') and owns_keeper:
|
|
752
|
+
raise MessageError('server.teams.control.goalkeeper_outside')
|
|
753
|
+
if control['goalkeeper'] == 'scenario' and any(i in available for i in keeper):
|
|
754
|
+
raise MessageError('server.teams.control.goalkeeper_scenario')
|
|
755
|
+
return selected
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def update(
|
|
759
|
+
identifier: str,
|
|
760
|
+
name: str | None = None,
|
|
761
|
+
changes: dict[str, Any] | None = None,
|
|
762
|
+
format: str | None = None,
|
|
763
|
+
control: dict[str, Any] | None = None,
|
|
764
|
+
) -> dict[str, Any]:
|
|
765
|
+
"""Renames a team, changes its format, its control or its style, and returns its new entry.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
identifier: The team, the built-in AI included.
|
|
769
|
+
name: The new name, or `None` to keep it.
|
|
770
|
+
changes: Entries of the style to change, or `None`.
|
|
771
|
+
format: `5v5` or `11v11`, the size of the team for competition, which sets the length of
|
|
772
|
+
its roster, or `None` to keep it.
|
|
773
|
+
control: The players that the submission controls, as `check_control` takes them, or
|
|
774
|
+
`None` to keep them, or the default where the new format refuses them.
|
|
775
|
+
|
|
776
|
+
Raises:
|
|
777
|
+
ArgumentError: If the team is unknown or a change is invalid.
|
|
778
|
+
"""
|
|
779
|
+
current = entry(identifier)
|
|
780
|
+
if name is not None:
|
|
781
|
+
name = _printable(str(name)).strip()[:80]
|
|
782
|
+
if not name:
|
|
783
|
+
raise MessageError('server.teams.name')
|
|
784
|
+
current['name'] = name
|
|
785
|
+
if identifier == BUILTIN:
|
|
786
|
+
current = _builtin_name(current)
|
|
787
|
+
else:
|
|
788
|
+
current.pop('name_id', None)
|
|
789
|
+
current.pop('name_values', None)
|
|
790
|
+
if format is not None:
|
|
791
|
+
current['format'] = _choice(format, tuple(FORMATS), _field('format'))
|
|
792
|
+
current['style'] = _resize(current['style'], current['format'])
|
|
793
|
+
if control is not None:
|
|
794
|
+
current['control'] = check_control(control, current['format'])
|
|
795
|
+
else:
|
|
796
|
+
try:
|
|
797
|
+
current['control'] = check_control(current.get('control'), current['format'])
|
|
798
|
+
except ArgumentError:
|
|
799
|
+
current['control'] = dict(DEFAULT_CONTROL)
|
|
800
|
+
current['style'] = style(changes, current['style'], _model_names())
|
|
801
|
+
chosen = {
|
|
802
|
+
current['style']['model'],
|
|
803
|
+
*(player['model'] for player in current['style']['players']),
|
|
804
|
+
}
|
|
805
|
+
if 'custom' in chosen and model_path(identifier) is None:
|
|
806
|
+
raise MessageError('server.teams.custom_model')
|
|
807
|
+
_write(identifier, current)
|
|
808
|
+
return current
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _names(folder: Path) -> set[str]:
|
|
812
|
+
"""Returns the names of the entries of a folder as written, or none if it is missing."""
|
|
813
|
+
try:
|
|
814
|
+
return set(os.listdir(folder))
|
|
815
|
+
except OSError:
|
|
816
|
+
return set()
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _exact(folder: Path, name: str, names: set[str] | None = None) -> Path | None:
|
|
820
|
+
"""Returns the file of a folder named exactly `name`, or `None`.
|
|
821
|
+
|
|
822
|
+
Windows and macOS find a file whatever the case of its name, so the name must be one of the
|
|
823
|
+
folder's own, `names` when given. A link, which could point outside the team's folder, counts
|
|
824
|
+
as no file.
|
|
825
|
+
"""
|
|
826
|
+
if name not in (_names(folder) if names is None else names):
|
|
827
|
+
return None
|
|
828
|
+
path = folder / name
|
|
829
|
+
return path if path.is_file() and not path.is_symlink() else None
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _image_file(folder: Path, slot: str, names: set[str] | None = None) -> Path | None:
|
|
833
|
+
"""Returns the file of an image slot of a team's folder, of any of its extensions, or `None`.
|
|
834
|
+
|
|
835
|
+
Only the slot's own name with an extension of `_IMAGE_SUFFIXES`, in that case, names it.
|
|
836
|
+
"""
|
|
837
|
+
names = _names(folder) if names is None else names
|
|
838
|
+
for suffix in _IMAGE_SUFFIXES:
|
|
839
|
+
path = _exact(folder, f'{slot}{suffix}', names)
|
|
840
|
+
if path is not None:
|
|
841
|
+
return path
|
|
842
|
+
return None
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _image_suffix(slot: str, data: bytes) -> str:
|
|
846
|
+
"""Returns the extension of an image's bytes, or raises `ArgumentError` for another kind."""
|
|
847
|
+
if slot.endswith('-edit'):
|
|
848
|
+
try:
|
|
849
|
+
json.loads(data)
|
|
850
|
+
except (ValueError, RecursionError) as error:
|
|
851
|
+
raise MessageError('server.teams.editor', error=error) from error
|
|
852
|
+
return '.json'
|
|
853
|
+
if data.startswith(b'\x89PNG\r\n\x1a\n'):
|
|
854
|
+
return '.png'
|
|
855
|
+
if data.startswith(b'\xff\xd8\xff'):
|
|
856
|
+
return '.jpg'
|
|
857
|
+
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
|
858
|
+
return '.webp'
|
|
859
|
+
raise MessageError('server.teams.image_type')
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def set_image(identifier: str, slot: str, data: bytes) -> dict[str, Any]:
|
|
863
|
+
"""Stores an image of a team, a shirt side, its editor document or a sponsor.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
identifier: The team, the built-in AI included.
|
|
867
|
+
slot: One of `IMAGE_SLOTS`.
|
|
868
|
+
data: The image, PNG, JPEG or WebP, or for an `-edit` slot the editor's JSON document.
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
The team's new entry.
|
|
872
|
+
|
|
873
|
+
Raises:
|
|
874
|
+
ArgumentError: If the team or slot is unknown, or the data is too large or of another kind.
|
|
875
|
+
"""
|
|
876
|
+
entry(identifier)
|
|
877
|
+
_choice(slot, IMAGE_SLOTS, _field('image_slot'))
|
|
878
|
+
if len(data) > _MAXIMUM_IMAGE_BYTES:
|
|
879
|
+
raise MessageError('server.teams.image_size', size=len(data))
|
|
880
|
+
suffix = _image_suffix(slot, data)
|
|
881
|
+
folder = _meta_path(identifier).parent
|
|
882
|
+
with _LOCK:
|
|
883
|
+
old = _image_file(folder, slot)
|
|
884
|
+
_replace(folder / f'{slot}{suffix}', data)
|
|
885
|
+
if old is not None and old.name != f'{slot}{suffix}':
|
|
886
|
+
old.unlink()
|
|
887
|
+
return entry(identifier)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def image_path(identifier: str, slot: str) -> Path | None:
|
|
891
|
+
"""Returns the file of a team's image slot, or `None`."""
|
|
892
|
+
if slot not in IMAGE_SLOTS:
|
|
893
|
+
return None
|
|
894
|
+
if identifier != BUILTIN and not _home.valid_identifier(identifier):
|
|
895
|
+
return None
|
|
896
|
+
return _image_file(_meta_path(identifier).parent, slot)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def remove_image(identifier: str, slot: str) -> bool:
|
|
900
|
+
"""Deletes a team's image slot and returns whether it held an image."""
|
|
901
|
+
path = image_path(identifier, slot)
|
|
902
|
+
if path is None:
|
|
903
|
+
return False
|
|
904
|
+
path.unlink()
|
|
905
|
+
return True
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _sound_file(folder: Path, slot: str, names: set[str] | None = None) -> Path | None:
|
|
909
|
+
"""Returns the file of a sound slot of a team's folder, of any of its extensions, or `None`.
|
|
910
|
+
|
|
911
|
+
Only `sound-` and the slot with an extension of `_SOUND_SUFFIXES`, in that case, names it.
|
|
912
|
+
"""
|
|
913
|
+
names = _names(folder) if names is None else names
|
|
914
|
+
for suffix in _SOUND_SUFFIXES:
|
|
915
|
+
path = _exact(folder, f'sound-{slot}{suffix}', names)
|
|
916
|
+
if path is not None:
|
|
917
|
+
return path
|
|
918
|
+
return None
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _wav_whole(data: bytes) -> bool:
|
|
922
|
+
"""Returns whether a RIFF WAVE file holds a format chunk the page reads and some samples."""
|
|
923
|
+
offset, form, samples = 12, None, False
|
|
924
|
+
while offset + 8 <= len(data):
|
|
925
|
+
kind = data[offset : offset + 4]
|
|
926
|
+
size = int.from_bytes(data[offset + 4 : offset + 8], 'little')
|
|
927
|
+
body = data[offset + 8 : offset + 8 + size]
|
|
928
|
+
if kind == b'fmt ' and len(body) >= 16:
|
|
929
|
+
tag = int.from_bytes(body[0:2], 'little')
|
|
930
|
+
channels = int.from_bytes(body[2:4], 'little')
|
|
931
|
+
rate = int.from_bytes(body[4:8], 'little')
|
|
932
|
+
bits = int.from_bytes(body[14:16], 'little')
|
|
933
|
+
form = tag in (1, 3, 0xFFFE) and 0 < channels <= 8 and 8000 <= rate <= 192000
|
|
934
|
+
form = form and bits in (8, 16, 24, 32)
|
|
935
|
+
elif kind == b'data':
|
|
936
|
+
samples = len(body) > 0
|
|
937
|
+
offset += 8 + size + (size & 1)
|
|
938
|
+
return bool(form) and samples
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _mp3_frame(data: bytes, offset: int) -> bool:
|
|
942
|
+
"""Returns whether a valid MPEG audio frame header starts at `offset`."""
|
|
943
|
+
if offset + 4 > len(data) or data[offset] != 0xFF or data[offset + 1] & 0xE0 != 0xE0:
|
|
944
|
+
return False
|
|
945
|
+
version = (data[offset + 1] >> 3) & 3
|
|
946
|
+
layer = (data[offset + 1] >> 1) & 3
|
|
947
|
+
bitrate = data[offset + 2] >> 4
|
|
948
|
+
rate = (data[offset + 2] >> 2) & 3
|
|
949
|
+
return version != 1 and layer != 0 and bitrate not in (0, 15) and rate != 3
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _sound_suffix(data: bytes) -> str:
|
|
953
|
+
"""Returns the extension of a sound's bytes, or raises `ArgumentError` for another kind.
|
|
954
|
+
|
|
955
|
+
Each kind is read past its first bytes, so a file that only starts like a sound is refused.
|
|
956
|
+
A WAV file holds a format chunk the page reads and a data chunk of samples, an Ogg file starts
|
|
957
|
+
with a page of Vorbis, Opus or FLAC, and an MP3 file holds a valid frame header after its tag.
|
|
958
|
+
"""
|
|
959
|
+
if data.startswith(b'OggS'):
|
|
960
|
+
segments = data[26] if len(data) > 26 else 0
|
|
961
|
+
packet = data[27 + segments : 27 + segments + 8]
|
|
962
|
+
if data[4:5] == b'\0' and packet.startswith((b'\x01vorbis', b'OpusHead', b'\x7fFLAC')):
|
|
963
|
+
return '.ogg'
|
|
964
|
+
raise MessageError('server.teams.sound_damaged', kind='Ogg')
|
|
965
|
+
if data[:4] == b'RIFF' and data[8:12] == b'WAVE':
|
|
966
|
+
if _wav_whole(data):
|
|
967
|
+
return '.wav'
|
|
968
|
+
raise MessageError('server.teams.sound_damaged', kind='WAV')
|
|
969
|
+
if data.startswith(b'ID3') and len(data) >= 10:
|
|
970
|
+
size = 0
|
|
971
|
+
for byte in data[6:10]:
|
|
972
|
+
size = size << 7 | (byte & 0x7F)
|
|
973
|
+
start = 10 + size + (10 if data[5] & 0x10 else 0)
|
|
974
|
+
# Padding may sit between the tag and the first frame.
|
|
975
|
+
if any(_mp3_frame(data, at) for at in range(start, min(len(data), start + 4096))):
|
|
976
|
+
return '.mp3'
|
|
977
|
+
raise MessageError('server.teams.sound_damaged', kind='MP3')
|
|
978
|
+
# A bare MP3 file starts with its first frame and holds more than one header's worth.
|
|
979
|
+
if _mp3_frame(data, 0) and len(data) >= 128:
|
|
980
|
+
return '.mp3'
|
|
981
|
+
raise MessageError('server.teams.sound_type')
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def set_sound(identifier: str, slot: str, data: bytes) -> dict[str, Any]:
|
|
985
|
+
"""Stores a sound of a team, which the page plays when one of its players does that.
|
|
986
|
+
|
|
987
|
+
Args:
|
|
988
|
+
identifier: The team, the built-in AI included.
|
|
989
|
+
slot: One of `SOUND_SLOTS`.
|
|
990
|
+
data: The sound, Ogg, MP3 or WAV, of at most 2 MB.
|
|
991
|
+
|
|
992
|
+
Returns:
|
|
993
|
+
The team's new entry.
|
|
994
|
+
|
|
995
|
+
Raises:
|
|
996
|
+
ArgumentError: If the team or slot is unknown, or the data is too large or of another kind.
|
|
997
|
+
"""
|
|
998
|
+
entry(identifier)
|
|
999
|
+
_choice(slot, SOUND_SLOTS, _field('sound_slot'))
|
|
1000
|
+
if len(data) > _MAXIMUM_SOUND_BYTES:
|
|
1001
|
+
raise MessageError('server.teams.sound_size', size=len(data))
|
|
1002
|
+
suffix = _sound_suffix(data)
|
|
1003
|
+
folder = _meta_path(identifier).parent
|
|
1004
|
+
with _LOCK:
|
|
1005
|
+
old = _sound_file(folder, slot)
|
|
1006
|
+
_replace(folder / f'sound-{slot}{suffix}', data)
|
|
1007
|
+
if old is not None and old.name != f'sound-{slot}{suffix}':
|
|
1008
|
+
old.unlink()
|
|
1009
|
+
return entry(identifier)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def sound_path(identifier: str, slot: str) -> Path | None:
|
|
1013
|
+
"""Returns the file of a team's sound slot, or `None`."""
|
|
1014
|
+
if slot not in SOUND_SLOTS:
|
|
1015
|
+
return None
|
|
1016
|
+
if identifier != BUILTIN and not _home.valid_identifier(identifier):
|
|
1017
|
+
return None
|
|
1018
|
+
return _sound_file(_meta_path(identifier).parent, slot)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def remove_sound(identifier: str, slot: str) -> bool:
|
|
1022
|
+
"""Deletes a team's sound slot and returns whether it held a sound."""
|
|
1023
|
+
path = sound_path(identifier, slot)
|
|
1024
|
+
if path is None:
|
|
1025
|
+
return False
|
|
1026
|
+
path.unlink()
|
|
1027
|
+
return True
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
# The joints of GRF's skeleton that a model may map. Only `body`, the pelvis, is required, so a
|
|
1031
|
+
# simple rig without elbows or knees maps what it has, and its bones follow their parents.
|
|
1032
|
+
JOINTS = (
|
|
1033
|
+
'body',
|
|
1034
|
+
'middle',
|
|
1035
|
+
'neck',
|
|
1036
|
+
'left_shoulder',
|
|
1037
|
+
'left_elbow',
|
|
1038
|
+
'right_shoulder',
|
|
1039
|
+
'right_elbow',
|
|
1040
|
+
'left_thigh',
|
|
1041
|
+
'left_knee',
|
|
1042
|
+
'left_ankle',
|
|
1043
|
+
'right_thigh',
|
|
1044
|
+
'right_knee',
|
|
1045
|
+
'right_ankle',
|
|
1046
|
+
)
|
|
1047
|
+
_MODEL = 'model.glb'
|
|
1048
|
+
_MAXIMUM_MODEL_BYTES = 30 * 1024 * 1024
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _array(value: Any) -> list[Any]:
|
|
1052
|
+
"""Returns a value of a glTF document that should be an array, or an empty one if it is not."""
|
|
1053
|
+
return value if isinstance(value, list) else []
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _skin_joints(data: bytes) -> set[str]:
|
|
1057
|
+
"""Returns the names of the joints of every skin of a binary glTF 2.0 file.
|
|
1058
|
+
|
|
1059
|
+
Raises:
|
|
1060
|
+
ArgumentError: If the bytes are no binary glTF 2.0 file or hold no skin.
|
|
1061
|
+
"""
|
|
1062
|
+
# glTF: a binary file opens with `glTF`, the version, the length and a JSON chunk.
|
|
1063
|
+
if len(data) < 20 or data[:4] != b'glTF':
|
|
1064
|
+
raise MessageError('server.teams.model.binary')
|
|
1065
|
+
version, length = struct.unpack_from('<II', data, 4)
|
|
1066
|
+
chunk_length, chunk_type = struct.unpack_from('<II', data, 12)
|
|
1067
|
+
if version != 2 or length > len(data) or chunk_type != 0x4E4F534A:
|
|
1068
|
+
raise MessageError('server.teams.model.json_first')
|
|
1069
|
+
try:
|
|
1070
|
+
document = json.loads(data[20 : 20 + chunk_length])
|
|
1071
|
+
except (ValueError, RecursionError) as error:
|
|
1072
|
+
raise MessageError('server.teams.model.json', error=error) from error
|
|
1073
|
+
if not isinstance(document, dict):
|
|
1074
|
+
raise MessageError('server.teams.model.json_object')
|
|
1075
|
+
for kind in ('buffers', 'images'):
|
|
1076
|
+
for item in _array(document.get(kind)):
|
|
1077
|
+
uri = item.get('uri') if isinstance(item, dict) else None
|
|
1078
|
+
if isinstance(uri, str) and not uri.startswith('data:'):
|
|
1079
|
+
raise MessageError('server.teams.model.link', uri=repr(uri))
|
|
1080
|
+
nodes = _array(document.get('nodes'))
|
|
1081
|
+
skins = _array(document.get('skins'))
|
|
1082
|
+
if not skins:
|
|
1083
|
+
raise MessageError('server.teams.model.rigged')
|
|
1084
|
+
names = set()
|
|
1085
|
+
for skin in skins:
|
|
1086
|
+
joints = _array(skin.get('joints')) if isinstance(skin, dict) else []
|
|
1087
|
+
for joint in joints:
|
|
1088
|
+
if (
|
|
1089
|
+
isinstance(joint, int)
|
|
1090
|
+
and 0 <= joint < len(nodes)
|
|
1091
|
+
and isinstance(nodes[joint], dict)
|
|
1092
|
+
):
|
|
1093
|
+
names.add(str(nodes[joint].get('name', '')))
|
|
1094
|
+
return names
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def set_model(identifier: str, data: bytes, joints: dict[str, str]) -> dict[str, Any]:
|
|
1098
|
+
"""Gives a team a player model of its own and returns the team's new entry.
|
|
1099
|
+
|
|
1100
|
+
Args:
|
|
1101
|
+
identifier: The team, the built-in AI included.
|
|
1102
|
+
data: The binary glTF 2.0 file, with a skinned mesh.
|
|
1103
|
+
joints: The bone of the model that each joint of GRF's skeleton drives, `body` always and
|
|
1104
|
+
any other joint of `JOINTS` or `player` that the rig has.
|
|
1105
|
+
|
|
1106
|
+
Raises:
|
|
1107
|
+
ArgumentError: If the file is too large, is no rigged binary glTF file, or the mapping
|
|
1108
|
+
lacks `body` or names a bone outside the model's skins.
|
|
1109
|
+
"""
|
|
1110
|
+
current = entry(identifier)
|
|
1111
|
+
if len(data) > _MAXIMUM_MODEL_BYTES:
|
|
1112
|
+
raise MessageError('server.teams.model.size', size=len(data))
|
|
1113
|
+
bones = _skin_joints(data)
|
|
1114
|
+
if not isinstance(joints, dict) or not all(
|
|
1115
|
+
isinstance(key, str) and isinstance(value, str) for key, value in joints.items()
|
|
1116
|
+
):
|
|
1117
|
+
raise MessageError('server.teams.model.joints', joints=repr(joints))
|
|
1118
|
+
if 'body' not in joints:
|
|
1119
|
+
raise MessageError('server.teams.model.body')
|
|
1120
|
+
unknown = [joint for joint in joints if joint not in (*JOINTS, 'player')]
|
|
1121
|
+
if unknown:
|
|
1122
|
+
raise MessageError('server.teams.model.unknown_joints', joints=', '.join(unknown))
|
|
1123
|
+
absent = [bone for bone in joints.values() if bone not in bones]
|
|
1124
|
+
if absent:
|
|
1125
|
+
raise MessageError('server.teams.model.missing_bones', bones=', '.join(map(str, absent)))
|
|
1126
|
+
folder = _meta_path(identifier).parent
|
|
1127
|
+
_replace(folder / _MODEL, data)
|
|
1128
|
+
current['model'] = {
|
|
1129
|
+
'joints': {key: str(value) for key, value in joints.items()},
|
|
1130
|
+
'bytes': len(data),
|
|
1131
|
+
}
|
|
1132
|
+
current['style']['model'] = 'custom'
|
|
1133
|
+
_write(identifier, current)
|
|
1134
|
+
return current
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def remove_model(identifier: str) -> bool:
|
|
1138
|
+
"""Takes a team's own player model away and returns whether it had one."""
|
|
1139
|
+
current = entry(identifier)
|
|
1140
|
+
path = _exact(_meta_path(identifier).parent, _MODEL)
|
|
1141
|
+
if path is None:
|
|
1142
|
+
return False
|
|
1143
|
+
path.unlink()
|
|
1144
|
+
had = True
|
|
1145
|
+
current['model'] = None
|
|
1146
|
+
if current['style']['model'] == 'custom':
|
|
1147
|
+
current['style']['model'] = 'grf'
|
|
1148
|
+
_write(identifier, current)
|
|
1149
|
+
return had
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def model_path(identifier: str) -> Path | None:
|
|
1153
|
+
"""Returns the file of a team's player model, or `None`."""
|
|
1154
|
+
if identifier != BUILTIN and not _home.valid_identifier(identifier):
|
|
1155
|
+
return None
|
|
1156
|
+
return _exact(_meta_path(identifier).parent, _MODEL)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _replace(path: Path, data: bytes) -> None:
|
|
1160
|
+
"""Writes `path` through a temporary file of its own, so no reader sees half a file."""
|
|
1161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1162
|
+
handle, temporary = tempfile.mkstemp(dir=path.parent, suffix='.part')
|
|
1163
|
+
try:
|
|
1164
|
+
with os.fdopen(handle, 'wb') as stream:
|
|
1165
|
+
stream.write(data)
|
|
1166
|
+
os.replace(temporary, path)
|
|
1167
|
+
except BaseException:
|
|
1168
|
+
with contextlib.suppress(OSError):
|
|
1169
|
+
os.unlink(temporary)
|
|
1170
|
+
raise
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _write(identifier: str, current: dict[str, Any]) -> None:
|
|
1174
|
+
"""Writes a team's entry, without the fields the folder implies."""
|
|
1175
|
+
stored = {key: value for key, value in current.items() if key not in _IMPLIED}
|
|
1176
|
+
_replace(_meta_path(identifier), json.dumps(stored).encode())
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
# Kits whose shirts lie closer than this in RGB look alike on the pitch.
|
|
1180
|
+
_CLASH = 120
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def scenario_size(scenario: str) -> int | None:
|
|
1184
|
+
"""Returns the players a side of a full-match scenario, 5 or 11, or `None` for the others."""
|
|
1185
|
+
for size in (5, 11):
|
|
1186
|
+
if scenario.startswith(f'{size}_vs_{size}'):
|
|
1187
|
+
return size
|
|
1188
|
+
return None
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def check_ability(ability: Any) -> tuple[float | None, float | None]:
|
|
1192
|
+
"""Returns a pair of abilities, each a number from 0 to 1 or `None` for the scenario's.
|
|
1193
|
+
|
|
1194
|
+
Raises:
|
|
1195
|
+
ArgumentError: If `ability` is no pair, or a value is no number from 0 to 1.
|
|
1196
|
+
"""
|
|
1197
|
+
if not isinstance(ability, (list, tuple)) or len(ability) != 2:
|
|
1198
|
+
raise MessageError('server.teams.ability_pair', ability=repr(ability))
|
|
1199
|
+
pair = []
|
|
1200
|
+
for value in ability:
|
|
1201
|
+
if value is None:
|
|
1202
|
+
pair.append(None)
|
|
1203
|
+
continue
|
|
1204
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 1:
|
|
1205
|
+
raise MessageError('server.teams.ability', value=repr(value))
|
|
1206
|
+
pair.append(float(value))
|
|
1207
|
+
return (pair[0], pair[1])
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _check_size(team: dict[str, Any], scenario: str) -> None:
|
|
1211
|
+
"""Checks that a team plays a full-match scenario of its own size, 5v5 or 11v11.
|
|
1212
|
+
|
|
1213
|
+
Raises:
|
|
1214
|
+
ArgumentError: If the scenario is a full match of the other size.
|
|
1215
|
+
"""
|
|
1216
|
+
size = scenario_size(scenario.removeprefix('benchmark_full_game_'))
|
|
1217
|
+
if size is not None and size != FORMATS[team['format']]:
|
|
1218
|
+
raise MessageError(
|
|
1219
|
+
'server.teams.scenario_format',
|
|
1220
|
+
format=team['format'],
|
|
1221
|
+
team=shown_name(team),
|
|
1222
|
+
scenario=_messages.scenario(scenario),
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def check_scenario(teams_: list[dict[str, Any]], scenario: str) -> None:
|
|
1227
|
+
"""Checks the format, count, selected players and goalkeeper on each scenario side."""
|
|
1228
|
+
for side, team in enumerate(teams_):
|
|
1229
|
+
if team['kind'] in ('builtin', 'random'):
|
|
1230
|
+
continue
|
|
1231
|
+
_check_size(team, scenario)
|
|
1232
|
+
control = check_control(team.get('control'), team['format'])
|
|
1233
|
+
try:
|
|
1234
|
+
selected_players(control, scenario_players(scenario, side))
|
|
1235
|
+
except ArgumentError as error:
|
|
1236
|
+
raise MessageError(
|
|
1237
|
+
'server.teams.cannot_play',
|
|
1238
|
+
team=shown_name(team),
|
|
1239
|
+
scenario=_messages.scenario(scenario),
|
|
1240
|
+
reason=_reason(error),
|
|
1241
|
+
) from error
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def challenge_fit(team: dict[str, Any], scenario: str, side: int = 0) -> dict[str, Any]:
|
|
1245
|
+
"""Returns how a team plays its side of a challenge's scenario, with any extra players out.
|
|
1246
|
+
|
|
1247
|
+
A challenge's scenario may hold fewer controllable players than a team controls, as an academy
|
|
1248
|
+
drill does for a team trained for full games. The team's extra players are then out, as a
|
|
1249
|
+
selected player off the pitch after a red card is, and the team plays the players that the
|
|
1250
|
+
scenario has. With dynamic assignment the team plays as many agents as the side has
|
|
1251
|
+
controllable players. With fixed assignment the players of its selection that the side holds
|
|
1252
|
+
play, each found as before, and the others are out, while the built-in AI plays the side's
|
|
1253
|
+
players outside the selection, its goalkeeper among them. The policy is still called in its
|
|
1254
|
+
own form, one observation for each player it controls, and a player out sees the observation
|
|
1255
|
+
of the team's first player that plays, whose answer for the player out counts for nothing.
|
|
1256
|
+
|
|
1257
|
+
Args:
|
|
1258
|
+
team: The registry entry, or the entry of a policy with its control.
|
|
1259
|
+
scenario: The challenge's scenario.
|
|
1260
|
+
side: The side the team plays, 0 for the left one.
|
|
1261
|
+
|
|
1262
|
+
Returns:
|
|
1263
|
+
`agents`, the engine agents of the side, `players`, the players the team controls,
|
|
1264
|
+
`playing`, those that play, `slots`, the place among the players that play of each player
|
|
1265
|
+
the team controls in the order of its submission, `None` for a player out, or `None`
|
|
1266
|
+
itself when every player plays, and `selected`, the formation indices of the players of a
|
|
1267
|
+
fixed assignment that play, or `None` for dynamic assignment.
|
|
1268
|
+
|
|
1269
|
+
Raises:
|
|
1270
|
+
ArgumentError: If the scenario is a full match of the other size, or the team cannot play
|
|
1271
|
+
the side, such as a fixed selection of which the side holds no player.
|
|
1272
|
+
"""
|
|
1273
|
+
if team['kind'] == 'builtin':
|
|
1274
|
+
return {'agents': 0, 'players': 0, 'playing': 0, 'slots': None, 'selected': None}
|
|
1275
|
+
_check_size(team, scenario)
|
|
1276
|
+
control = check_control(team.get('control'), team['format'])
|
|
1277
|
+
formation = scenario_players(scenario, side)
|
|
1278
|
+
available = [index for index, player in enumerate(formation) if player.controllable]
|
|
1279
|
+
count = control['players']
|
|
1280
|
+
|
|
1281
|
+
def refused(error: BaseException) -> MessageError:
|
|
1282
|
+
return MessageError(
|
|
1283
|
+
'server.teams.cannot_play',
|
|
1284
|
+
team=shown_name(team),
|
|
1285
|
+
scenario=_messages.scenario(scenario),
|
|
1286
|
+
reason=_reason(error),
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
if control['assignment'] == 'dynamic':
|
|
1290
|
+
if count <= len(available):
|
|
1291
|
+
try:
|
|
1292
|
+
selected_players(control, formation)
|
|
1293
|
+
except ArgumentError as error:
|
|
1294
|
+
raise refused(error) from error
|
|
1295
|
+
return {
|
|
1296
|
+
'agents': count,
|
|
1297
|
+
'players': count,
|
|
1298
|
+
'playing': count,
|
|
1299
|
+
'slots': None,
|
|
1300
|
+
'selected': None,
|
|
1301
|
+
}
|
|
1302
|
+
if not available:
|
|
1303
|
+
raise refused(MessageError('server.teams.control.too_few', count=0))
|
|
1304
|
+
playing = len(available)
|
|
1305
|
+
slots = [*range(playing), *[None] * (count - playing)]
|
|
1306
|
+
return {
|
|
1307
|
+
'agents': playing,
|
|
1308
|
+
'players': count,
|
|
1309
|
+
'playing': playing,
|
|
1310
|
+
'slots': slots,
|
|
1311
|
+
'selected': None,
|
|
1312
|
+
}
|
|
1313
|
+
chosen: list[int] = []
|
|
1314
|
+
slots: list[int | None] = []
|
|
1315
|
+
for item in control['selection']:
|
|
1316
|
+
candidates = (
|
|
1317
|
+
[item]
|
|
1318
|
+
if type(item) is int
|
|
1319
|
+
else [index for index in available if formation[index].role == ROLES.index(item)]
|
|
1320
|
+
)
|
|
1321
|
+
found = next((i for i in candidates if i in available and i not in chosen), None)
|
|
1322
|
+
slots.append(None if found is None else len(chosen))
|
|
1323
|
+
if found is not None:
|
|
1324
|
+
chosen.append(found)
|
|
1325
|
+
if None not in slots:
|
|
1326
|
+
try:
|
|
1327
|
+
selected = selected_players(control, formation)
|
|
1328
|
+
except ArgumentError as error:
|
|
1329
|
+
raise refused(error) from error
|
|
1330
|
+
return {
|
|
1331
|
+
'agents': len(available),
|
|
1332
|
+
'players': count,
|
|
1333
|
+
'playing': count,
|
|
1334
|
+
'slots': None,
|
|
1335
|
+
'selected': selected,
|
|
1336
|
+
}
|
|
1337
|
+
if not chosen:
|
|
1338
|
+
raise refused(MessageError('server.teams.control.none_present'))
|
|
1339
|
+
keeper = [index for index, player in enumerate(formation) if player.role == 0]
|
|
1340
|
+
if control['goalkeeper'] in ('builtin', 'scenario') and any(i in keeper for i in chosen):
|
|
1341
|
+
raise refused(MessageError('server.teams.control.goalkeeper_outside'))
|
|
1342
|
+
return {
|
|
1343
|
+
'agents': len(available),
|
|
1344
|
+
'players': count,
|
|
1345
|
+
'playing': len(chosen),
|
|
1346
|
+
'slots': slots,
|
|
1347
|
+
'selected': chosen,
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def game_agents(teams_: list[dict[str, Any]], scenario: str, humans: Any) -> list[int]:
|
|
1352
|
+
"""Returns the agents of the team on each side of a game that people play, after checks.
|
|
1353
|
+
|
|
1354
|
+
A side without a person belongs to its team, the built-in AI or a submission, as in a requested
|
|
1355
|
+
match. A side with a person takes its team as the person's teammates, a submission with dynamic
|
|
1356
|
+
assignment whose players join the person's one, so the side needs one controllable player more
|
|
1357
|
+
than the team asks for.
|
|
1358
|
+
|
|
1359
|
+
Raises:
|
|
1360
|
+
ArgumentError: If a team cannot play its side of the scenario, or a team beside a person
|
|
1361
|
+
uses fixed assignment or asks for more players than the side has left.
|
|
1362
|
+
"""
|
|
1363
|
+
check_scenario(teams_, scenario)
|
|
1364
|
+
agents = []
|
|
1365
|
+
for side, team in enumerate(teams_):
|
|
1366
|
+
count = agent_count(team, scenario, side)
|
|
1367
|
+
if humans[side] and count:
|
|
1368
|
+
control = check_control(team.get('control'), team['format'])
|
|
1369
|
+
if control['assignment'] != 'dynamic':
|
|
1370
|
+
raise MessageError('server.teams.beside_dynamic', team=shown_name(team))
|
|
1371
|
+
available = sum(player.controllable for player in scenario_players(scenario, side))
|
|
1372
|
+
if count + 1 > available:
|
|
1373
|
+
raise MessageError(
|
|
1374
|
+
'server.teams.beside_count',
|
|
1375
|
+
team=shown_name(team),
|
|
1376
|
+
scenario=_messages.scenario(scenario),
|
|
1377
|
+
count=available,
|
|
1378
|
+
)
|
|
1379
|
+
agents.append(count)
|
|
1380
|
+
return agents
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def match_teams(left: str, right: str) -> list[dict[str, Any]]:
|
|
1384
|
+
"""Returns the two teams of a match as a recording's header holds them.
|
|
1385
|
+
|
|
1386
|
+
The right team plays in its away kit, its shirt and shorts colors swapped with its trim, when
|
|
1387
|
+
its shirt would look like the left team's, as a team of the same registry entry always does.
|
|
1388
|
+
"""
|
|
1389
|
+
teams = [header_team(left), header_team(right)]
|
|
1390
|
+
first, second = teams[0]['style']['kit'], teams[1]['style']['kit']
|
|
1391
|
+
distance = sum((a - b) ** 2 for a, b in zip(first['primary'], second['primary'], strict=True))
|
|
1392
|
+
if distance < _CLASH**2:
|
|
1393
|
+
kit = dict(second)
|
|
1394
|
+
kit['primary'], kit['secondary'] = second['secondary'], second['primary']
|
|
1395
|
+
kit['shorts'], kit['socks'] = second['socks'], second['shorts']
|
|
1396
|
+
if sum((a - b) ** 2 for a, b in zip(first['primary'], kit['primary'], strict=True)) < (
|
|
1397
|
+
_CLASH**2
|
|
1398
|
+
):
|
|
1399
|
+
kit['primary'] = [255 - value for value in first['primary']]
|
|
1400
|
+
teams[1] = {
|
|
1401
|
+
**teams[1],
|
|
1402
|
+
'color': kit['primary'],
|
|
1403
|
+
'secondary': kit['secondary'],
|
|
1404
|
+
'style': {**teams[1]['style'], 'kit': kit},
|
|
1405
|
+
'away': True,
|
|
1406
|
+
}
|
|
1407
|
+
return teams
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def header_team(identifier: str) -> dict[str, Any]:
|
|
1411
|
+
"""Returns a team as a recording's header holds it, with its identifier, name and style."""
|
|
1412
|
+
team = side_entry(identifier)
|
|
1413
|
+
kit = team['style']['kit']
|
|
1414
|
+
return {
|
|
1415
|
+
'id': team['id'],
|
|
1416
|
+
**_messages.named('name', shown_name(team)),
|
|
1417
|
+
'format': team['format'],
|
|
1418
|
+
'control': team.get('control', dict(DEFAULT_CONTROL)),
|
|
1419
|
+
'color': kit['primary'],
|
|
1420
|
+
'secondary': kit['secondary'],
|
|
1421
|
+
'style': team['style'],
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def folder_bundle(files: list[tuple[str, bytes]]) -> bytes:
|
|
1426
|
+
"""Returns the bytes of a submission folder as the page uploads it.
|
|
1427
|
+
|
|
1428
|
+
Args:
|
|
1429
|
+
files: The path of each file relative to the folder, with `/` between parts, and its bytes.
|
|
1430
|
+
"""
|
|
1431
|
+
index = json.dumps([{'path': path, 'size': len(data)} for path, data in files]).encode()
|
|
1432
|
+
return (
|
|
1433
|
+
FOLDER_MAGIC + struct.pack('<I', len(index)) + index + b''.join(data for _, data in files)
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _unbundle(data: bytes, target: Path) -> None:
|
|
1438
|
+
"""Writes the files of a folder bundle into `target`, without the folder's own name.
|
|
1439
|
+
|
|
1440
|
+
A browser names each file of a chosen folder with the folder in front, such as
|
|
1441
|
+
`submission/main.py`, so a single top folder above `main.py` is dropped.
|
|
1442
|
+
"""
|
|
1443
|
+
if len(data) < len(FOLDER_MAGIC) + 4:
|
|
1444
|
+
raise MessageError('server.teams.folder.short')
|
|
1445
|
+
(length,) = struct.unpack_from('<I', data, len(FOLDER_MAGIC))
|
|
1446
|
+
start = len(FOLDER_MAGIC) + 4
|
|
1447
|
+
try:
|
|
1448
|
+
index = json.loads(data[start : start + length])
|
|
1449
|
+
except ValueError as error:
|
|
1450
|
+
raise MessageError('server.teams.folder.index', error=error) from error
|
|
1451
|
+
if not isinstance(index, list) or len(index) > _MAXIMUM_FILES:
|
|
1452
|
+
raise MessageError('server.teams.folder.index_list')
|
|
1453
|
+
paths = [_check_member(str(entry['path'])) for entry in index]
|
|
1454
|
+
tops = {path.parts[0] for path in paths if len(path.parts) > 1}
|
|
1455
|
+
if (
|
|
1456
|
+
PurePosixPath(_MAIN) not in paths
|
|
1457
|
+
and len(tops) == 1
|
|
1458
|
+
and all(len(p.parts) > 1 for p in paths)
|
|
1459
|
+
):
|
|
1460
|
+
paths = [PurePosixPath(*path.parts[1:]) for path in paths]
|
|
1461
|
+
position = start + length
|
|
1462
|
+
for path, entry in zip(paths, index, strict=True):
|
|
1463
|
+
size = int(entry['size'])
|
|
1464
|
+
if size < 0 or position + size > len(data):
|
|
1465
|
+
raise MessageError('server.teams.folder.files')
|
|
1466
|
+
destination = _inside(target, path)
|
|
1467
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1468
|
+
destination.write_bytes(data[position : position + size])
|
|
1469
|
+
position += size
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _extract(data: bytes, filename: str, target: Path) -> None:
|
|
1473
|
+
"""Writes a submission into `target`, a folder bundle, a Python file or an archive."""
|
|
1474
|
+
lower = filename.lower()
|
|
1475
|
+
if data.startswith(FOLDER_MAGIC):
|
|
1476
|
+
_unbundle(data, target)
|
|
1477
|
+
elif lower.endswith('.py'):
|
|
1478
|
+
(target / _MAIN).write_bytes(data)
|
|
1479
|
+
elif lower.endswith(('.tar.gz', '.tgz', '.tar')):
|
|
1480
|
+
_untar(data, target)
|
|
1481
|
+
elif lower.endswith('.zip'):
|
|
1482
|
+
# zipfile reads every entry of the central directory as it opens, so their count comes
|
|
1483
|
+
# first, from the bytes alone.
|
|
1484
|
+
_check_unpacked(_zip_entries(data), 0)
|
|
1485
|
+
try:
|
|
1486
|
+
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
1487
|
+
except (zipfile.BadZipFile, OSError, ValueError, EOFError) as error:
|
|
1488
|
+
raise MessageError('server.teams.archive.zip', error=error) from error
|
|
1489
|
+
with archive:
|
|
1490
|
+
members = archive.infolist()
|
|
1491
|
+
_check_unpacked(len(members), sum(member.file_size for member in members))
|
|
1492
|
+
for member in members:
|
|
1493
|
+
_inside(target, _check_member(member.filename))
|
|
1494
|
+
archive.extractall(target)
|
|
1495
|
+
else:
|
|
1496
|
+
raise MessageError('server.teams.submission.kind', filename=repr(filename))
|
|
1497
|
+
if not (target / _MAIN).is_file():
|
|
1498
|
+
raise MessageError('server.teams.submission.main_top', main=_MAIN)
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
class _Bounded:
|
|
1502
|
+
"""The bytes of a tar archive after decompression, read within limits.
|
|
1503
|
+
|
|
1504
|
+
No read or move goes past `_MAXIMUM_TAR_BYTES` from the start. While `scanning` holds, the
|
|
1505
|
+
reads after each move, which are the headers of one member, take at most
|
|
1506
|
+
`_MAXIMUM_TAR_HEADER` bytes, so a long name, pax records or a sparse map never grows without
|
|
1507
|
+
end. tarfile moves past the bytes of each member to its next header, so the bytes of the
|
|
1508
|
+
members are never read while it scans.
|
|
1509
|
+
"""
|
|
1510
|
+
|
|
1511
|
+
def __init__(self, inner: Any) -> None:
|
|
1512
|
+
self._inner = inner
|
|
1513
|
+
self._start = 0
|
|
1514
|
+
self.scanning = True
|
|
1515
|
+
|
|
1516
|
+
def tell(self) -> int:
|
|
1517
|
+
return self._inner.tell()
|
|
1518
|
+
|
|
1519
|
+
def seek(self, offset: int, whence: int = 0) -> int:
|
|
1520
|
+
if whence != 0 or offset > _MAXIMUM_TAR_BYTES:
|
|
1521
|
+
raise MessageError('server.teams.archive.limits')
|
|
1522
|
+
self._start = offset
|
|
1523
|
+
return self._inner.seek(offset)
|
|
1524
|
+
|
|
1525
|
+
def read(self, size: int = -1) -> bytes:
|
|
1526
|
+
end = _MAXIMUM_TAR_BYTES
|
|
1527
|
+
if self.scanning:
|
|
1528
|
+
end = min(end, self._start + _MAXIMUM_TAR_HEADER)
|
|
1529
|
+
if size is None or size < 0 or self._inner.tell() + size > end:
|
|
1530
|
+
raise MessageError('server.teams.archive.limits')
|
|
1531
|
+
return self._inner.read(size)
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _decompressed(data: bytes) -> Any:
|
|
1535
|
+
"""Returns a file of a tar archive's bytes, decompressed as they are read when compressed.
|
|
1536
|
+
|
|
1537
|
+
gzip, bzip2 and xz are told apart by their first bytes, as tarfile tells them apart.
|
|
1538
|
+
"""
|
|
1539
|
+
stream = io.BytesIO(data)
|
|
1540
|
+
if data.startswith(b'\x1f\x8b'):
|
|
1541
|
+
return gzip.GzipFile(fileobj=stream, mode='rb')
|
|
1542
|
+
if data.startswith(b'BZh'):
|
|
1543
|
+
return bz2.BZ2File(stream)
|
|
1544
|
+
if data.startswith(b'\xfd7zXZ\x00') and lzma is not None:
|
|
1545
|
+
return lzma.LZMAFile(stream)
|
|
1546
|
+
return stream
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _untar(data: bytes, target: Path) -> None:
|
|
1550
|
+
"""Writes the members of a tar archive into `target`, after checking every one of them.
|
|
1551
|
+
|
|
1552
|
+
The members are read one at a time and counted against the limits of an archive as they come,
|
|
1553
|
+
so a small compressed archive of a great many members, or of members that claim a great size,
|
|
1554
|
+
is refused at the first member past a limit, before any member is written.
|
|
1555
|
+
"""
|
|
1556
|
+
stream = _Bounded(_decompressed(data))
|
|
1557
|
+
unreadable = (tarfile.TarError, EOFError, OSError, zlib.error, RecursionError, *_LZMA_ERRORS)
|
|
1558
|
+
try:
|
|
1559
|
+
archive = tarfile.open(fileobj=stream, mode='r:')
|
|
1560
|
+
except unreadable as error:
|
|
1561
|
+
raise MessageError('server.teams.archive.tar', error=error) from error
|
|
1562
|
+
with archive:
|
|
1563
|
+
count = size = 0
|
|
1564
|
+
try:
|
|
1565
|
+
while (member := archive.next()) is not None:
|
|
1566
|
+
count += 1
|
|
1567
|
+
size += member.size
|
|
1568
|
+
_check_unpacked(count, size)
|
|
1569
|
+
_inside(target, _check_member(member.name))
|
|
1570
|
+
if not (member.isfile() or member.isdir()):
|
|
1571
|
+
raise MessageError('server.teams.archive.plain', member=member.name)
|
|
1572
|
+
except unreadable as error:
|
|
1573
|
+
raise MessageError('server.teams.archive.tar', error=error) from error
|
|
1574
|
+
stream.scanning = False
|
|
1575
|
+
try:
|
|
1576
|
+
# Python 3.12, 3.11.4 and 3.10.12 brought the data filter. Older versions write each
|
|
1577
|
+
# member without its mode, owner or times, and the checks above cover the rest.
|
|
1578
|
+
if hasattr(tarfile, 'data_filter'):
|
|
1579
|
+
archive.extractall(target, filter='data')
|
|
1580
|
+
else:
|
|
1581
|
+
for member in archive.getmembers():
|
|
1582
|
+
archive.extract(member, target, set_attrs=False)
|
|
1583
|
+
except (tarfile.TarError, EOFError, zlib.error, *_LZMA_ERRORS) as error:
|
|
1584
|
+
raise MessageError('server.teams.archive.tar', error=error) from error
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _zip_entries(data: bytes) -> int:
|
|
1588
|
+
"""Returns at least as many entries as the central directory of a zip file can hold.
|
|
1589
|
+
|
|
1590
|
+
Every entry that zipfile reads from the central directory starts with the signature of an
|
|
1591
|
+
entry, and no two overlap, so the signatures of the whole file bound the entries, whichever end
|
|
1592
|
+
record and zip64 record a version of zipfile trusts.
|
|
1593
|
+
"""
|
|
1594
|
+
return data.count(b'PK\x01\x02')
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def add_folder(
|
|
1598
|
+
folder: str | Path,
|
|
1599
|
+
name: str | None = None,
|
|
1600
|
+
format: str = '11v11',
|
|
1601
|
+
control: dict[str, Any] | None = None,
|
|
1602
|
+
) -> dict[str, Any]:
|
|
1603
|
+
"""Stores a submission folder or archive as a new team and returns its entry.
|
|
1604
|
+
|
|
1605
|
+
Args:
|
|
1606
|
+
folder: A submission folder, submission file or exported archive.
|
|
1607
|
+
name: The name of the team, by default the folder's name.
|
|
1608
|
+
format: `5v5` or `11v11`, the size of the team for competition.
|
|
1609
|
+
control: The players that the submission controls, as `check_control` takes them, or
|
|
1610
|
+
`None` for the active player alone, or for the setting of an exported folder.
|
|
1611
|
+
"""
|
|
1612
|
+
folder = Path(folder)
|
|
1613
|
+
if folder.is_file():
|
|
1614
|
+
return add(name or '', folder.name, folder.read_bytes(), format, control)
|
|
1615
|
+
if not folder.is_dir():
|
|
1616
|
+
raise ArgumentError(
|
|
1617
|
+
f'expected a submission folder or archive, found nothing at {str(folder)!r}'
|
|
1618
|
+
)
|
|
1619
|
+
if not (folder / _MAIN).is_file():
|
|
1620
|
+
raise MessageError(
|
|
1621
|
+
'server.teams.submission.main_folder', main=_MAIN, folder=repr(str(folder))
|
|
1622
|
+
)
|
|
1623
|
+
# A link inside the folder could point anywhere, such as at a key of the user's, and its
|
|
1624
|
+
# target would then travel with every export of the team, so links are left out and logged.
|
|
1625
|
+
paths = []
|
|
1626
|
+
for root, folders, names in os.walk(folder):
|
|
1627
|
+
here = Path(root)
|
|
1628
|
+
for item in [*folders, *names]:
|
|
1629
|
+
if (here / item).is_symlink():
|
|
1630
|
+
_LOGGER.warning(
|
|
1631
|
+
'cuju render leaves out %s of the submission folder, since it is a link',
|
|
1632
|
+
(here / item).relative_to(folder).as_posix(),
|
|
1633
|
+
)
|
|
1634
|
+
folders[:] = [item for item in folders if item != '__pycache__']
|
|
1635
|
+
paths += [here / item for item in names if not (here / item).is_symlink()]
|
|
1636
|
+
files = [
|
|
1637
|
+
(path.relative_to(folder).as_posix(), path.read_bytes())
|
|
1638
|
+
for path in sorted(paths)
|
|
1639
|
+
if path.is_file()
|
|
1640
|
+
]
|
|
1641
|
+
return add(name or folder.resolve().name, folder.name, folder_bundle(files), format, control)
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _check_member(name: str) -> PurePosixPath:
|
|
1645
|
+
"""Returns an archive member's path, refusing one that could land outside the team's folder.
|
|
1646
|
+
|
|
1647
|
+
Windows reads `D:name` as a path on another drive and a backslash as a separator, so a part
|
|
1648
|
+
may hold neither a colon, a backslash nor a null, whatever system unpacks it. A part that
|
|
1649
|
+
opens a device on Windows, or ends in a dot or a space, is refused on every system too, so a
|
|
1650
|
+
submission imports alike everywhere.
|
|
1651
|
+
"""
|
|
1652
|
+
path = PurePosixPath(name)
|
|
1653
|
+
if (
|
|
1654
|
+
not name
|
|
1655
|
+
or not path.parts
|
|
1656
|
+
or path.is_absolute()
|
|
1657
|
+
or any(part in ('', '.', '..') or any(c in part for c in ':\\\0') for part in path.parts)
|
|
1658
|
+
):
|
|
1659
|
+
raise MessageError('server.teams.archive.outside', member=repr(name))
|
|
1660
|
+
if any(_device(part) for part in path.parts):
|
|
1661
|
+
raise MessageError('server.teams.archive.device', member=repr(name))
|
|
1662
|
+
return path
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def _device(part: str) -> bool:
|
|
1666
|
+
"""Returns whether a part of a path opens a device on Windows or names another file there.
|
|
1667
|
+
|
|
1668
|
+
Windows reads the part before the first dot, without the spaces at its end, as a device's
|
|
1669
|
+
name, and drops the dots and spaces at the end of a part, so `nul.txt`, `con .py` and
|
|
1670
|
+
`main.py.` all name something else than they seem.
|
|
1671
|
+
"""
|
|
1672
|
+
return part.endswith(('.', ' ')) or bool(_DEVICES.fullmatch(part.split('.')[0].rstrip(' ')))
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _inside(target: Path, path: PurePosixPath) -> Path:
|
|
1676
|
+
"""Returns `target / path`, refusing it unless it resolves inside `target`."""
|
|
1677
|
+
destination = target.joinpath(*path.parts)
|
|
1678
|
+
if not destination.resolve().is_relative_to(target.resolve()):
|
|
1679
|
+
raise MessageError('server.teams.archive.outside', member=repr(str(path)))
|
|
1680
|
+
return destination
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _check_unpacked(count: int, size: int) -> None:
|
|
1684
|
+
"""Refuses an archive of too many files or too large once unpacked."""
|
|
1685
|
+
if count > _MAXIMUM_FILES or size > _MAXIMUM_UNPACKED:
|
|
1686
|
+
raise MessageError('server.teams.archive.limits')
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def add(
|
|
1690
|
+
name: str,
|
|
1691
|
+
filename: str,
|
|
1692
|
+
data: bytes,
|
|
1693
|
+
format: str = '11v11',
|
|
1694
|
+
control: dict[str, Any] | None = None,
|
|
1695
|
+
) -> dict[str, Any]:
|
|
1696
|
+
"""Stores a Kaggle submission as a new team and returns its entry.
|
|
1697
|
+
|
|
1698
|
+
Args:
|
|
1699
|
+
name: The name that the page and recordings show.
|
|
1700
|
+
filename: The name of the uploaded file, whose extension says how to read it.
|
|
1701
|
+
data: The file's bytes.
|
|
1702
|
+
format: `5v5` or `11v11`, the size of the team for competition, which decides the
|
|
1703
|
+
scenarios it plays and the length of its roster.
|
|
1704
|
+
control: The players that the submission controls, as `check_control` takes them, or
|
|
1705
|
+
`None` for the active player alone, or for the setting of an exported folder.
|
|
1706
|
+
|
|
1707
|
+
Raises:
|
|
1708
|
+
ArgumentError: If the submission is too large, of another kind, holds no `main.py` or
|
|
1709
|
+
the control does not fit the format.
|
|
1710
|
+
"""
|
|
1711
|
+
if len(data) > _MAXIMUM_BYTES:
|
|
1712
|
+
raise MessageError('server.teams.submission.size', size=len(data))
|
|
1713
|
+
_choice(format, tuple(FORMATS), _field('format'))
|
|
1714
|
+
if control is not None:
|
|
1715
|
+
check_control(control, format)
|
|
1716
|
+
name_given = bool(name and name.strip())
|
|
1717
|
+
name = _printable((name or '').strip() or Path(filename).stem.strip() or 'team').strip()[:80]
|
|
1718
|
+
identifier, folder = _new_folder()
|
|
1719
|
+
# Every failure after the folder is made removes it, so no half imported folder, with the
|
|
1720
|
+
# unchecked team file of an archive, is ever left for the list of teams to read.
|
|
1721
|
+
try:
|
|
1722
|
+
try:
|
|
1723
|
+
_extract(data, filename, folder)
|
|
1724
|
+
except OSError as error:
|
|
1725
|
+
raise MessageError('server.teams.submission.unpack', error=error) from error
|
|
1726
|
+
entry = {
|
|
1727
|
+
'id': identifier,
|
|
1728
|
+
'name': name,
|
|
1729
|
+
'kind': 'kaggle',
|
|
1730
|
+
'created': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
1731
|
+
'format': format,
|
|
1732
|
+
'style': default_style(format, _new_kit()),
|
|
1733
|
+
'model': None,
|
|
1734
|
+
}
|
|
1735
|
+
# A folder exported from the page brings its format, style, images and model along, each
|
|
1736
|
+
# checked as an upload is. Whatever fails a check is set aside under a name that the page
|
|
1737
|
+
# never reads, and the other files of the folder stay as they came.
|
|
1738
|
+
try:
|
|
1739
|
+
_import(folder / TEAM_FOLDER, entry, name_given)
|
|
1740
|
+
except ArgumentError:
|
|
1741
|
+
raise
|
|
1742
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError, RecursionError) as error:
|
|
1743
|
+
raise MessageError('server.teams.submission.unpack', error=error) from error
|
|
1744
|
+
if control is not None:
|
|
1745
|
+
entry['control'] = check_control(control, entry['format'])
|
|
1746
|
+
completed = _complete(entry)
|
|
1747
|
+
_write(identifier, completed)
|
|
1748
|
+
except BaseException:
|
|
1749
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
1750
|
+
raise
|
|
1751
|
+
return completed
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _new_folder() -> tuple[str, Path]:
|
|
1755
|
+
"""Creates the folder of a new team and returns its identifier and path.
|
|
1756
|
+
|
|
1757
|
+
Identifiers sort by the second they are made in, so teams added in the same second differ by
|
|
1758
|
+
their random end alone. Creating the folder claims the identifier, and a draw that is taken
|
|
1759
|
+
is drawn again.
|
|
1760
|
+
"""
|
|
1761
|
+
while True:
|
|
1762
|
+
identifier = _home.new_identifier('t')
|
|
1763
|
+
folder = _home.folder('teams') / identifier
|
|
1764
|
+
try:
|
|
1765
|
+
folder.mkdir()
|
|
1766
|
+
except FileExistsError:
|
|
1767
|
+
continue
|
|
1768
|
+
return identifier, folder
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def set_aside(path: Path, reason: str) -> None:
|
|
1772
|
+
"""Renames a file of an imported `cuju-team` folder that fails its checks, and logs why.
|
|
1773
|
+
|
|
1774
|
+
The file keeps its bytes under its name followed by `.rejected`, which the page never reads,
|
|
1775
|
+
so the team plays without it and the owner still finds it in the folder.
|
|
1776
|
+
"""
|
|
1777
|
+
target = path.with_name(f'{path.name}{_REJECTED}')
|
|
1778
|
+
count = 1
|
|
1779
|
+
while target.exists() or target.is_symlink():
|
|
1780
|
+
count += 1
|
|
1781
|
+
target = path.with_name(f'{path.name}.{count}{_REJECTED}')
|
|
1782
|
+
try:
|
|
1783
|
+
path.rename(target)
|
|
1784
|
+
except OSError as error:
|
|
1785
|
+
_LOGGER.warning('cuju render leaves out %s of an imported team, %s', path.name, error)
|
|
1786
|
+
return
|
|
1787
|
+
_LOGGER.warning(
|
|
1788
|
+
'cuju render sets aside %s of an imported team as %s, since %s',
|
|
1789
|
+
path.name,
|
|
1790
|
+
target.name,
|
|
1791
|
+
reason,
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _kept(path: Path) -> bool:
|
|
1796
|
+
"""Returns whether a file of an imported `cuju-team` folder may stay, and removes it if not.
|
|
1797
|
+
|
|
1798
|
+
A link could point outside the team's folder and a name such as `con.txt` opens a device on
|
|
1799
|
+
Windows, so either is removed, the link alone and never what it points to, and logged. Every
|
|
1800
|
+
other file stays.
|
|
1801
|
+
"""
|
|
1802
|
+
if path.is_symlink():
|
|
1803
|
+
reason = 'it is a link'
|
|
1804
|
+
elif _device(path.name):
|
|
1805
|
+
reason = 'its name opens a device on Windows or ends in a dot or a space'
|
|
1806
|
+
else:
|
|
1807
|
+
return True
|
|
1808
|
+
_LOGGER.warning('cuju render removes %s of an imported team, since %s', path.name, reason)
|
|
1809
|
+
if path.is_dir() and not path.is_symlink():
|
|
1810
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
1811
|
+
else:
|
|
1812
|
+
path.unlink(missing_ok=True)
|
|
1813
|
+
return False
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def _near_slot(name: str) -> bool:
|
|
1817
|
+
"""Returns whether a name that no slot reads starts as the file of an image, sound or model."""
|
|
1818
|
+
lower = name.lower()
|
|
1819
|
+
if lower.endswith(_REJECTED):
|
|
1820
|
+
return False
|
|
1821
|
+
starts = [f'{slot}.' for slot in IMAGE_SLOTS] + [f'sound-{slot}.' for slot in SOUND_SLOTS]
|
|
1822
|
+
return lower == _MODEL or lower.startswith(tuple(starts))
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _import(team_folder: Path, entry: dict[str, Any], name_given: bool) -> None:
|
|
1826
|
+
"""Takes the format, style, images and model of an exported team into `entry`."""
|
|
1827
|
+
if not team_folder.is_dir():
|
|
1828
|
+
return
|
|
1829
|
+
saved: dict[str, Any] = {}
|
|
1830
|
+
with contextlib.suppress(OSError, ValueError):
|
|
1831
|
+
saved = _read_entry(team_folder / 'team.json')
|
|
1832
|
+
if isinstance(saved.get('format'), str) and saved['format'] in FORMATS:
|
|
1833
|
+
entry['format'] = saved['format']
|
|
1834
|
+
with contextlib.suppress(ArgumentError, TypeError, KeyError, AttributeError, ValueError):
|
|
1835
|
+
entry['control'] = check_control(saved.get('control'), entry['format'])
|
|
1836
|
+
with contextlib.suppress(ArgumentError, TypeError, KeyError, AttributeError, ValueError):
|
|
1837
|
+
# A team file that names no kit keeps the kit the new team took in turn, so teams of
|
|
1838
|
+
# such files differ as teams added on the page do.
|
|
1839
|
+
base = default_style(entry['format'], (entry.get('style') or {}).get('kit'))
|
|
1840
|
+
entry['style'] = _resize(
|
|
1841
|
+
style(_known(saved.get('style') or {}, base), base, _model_names()), entry['format']
|
|
1842
|
+
)
|
|
1843
|
+
if isinstance((saved.get('style') or {}).get('players'), list):
|
|
1844
|
+
entry['style']['players'] = _players(
|
|
1845
|
+
saved['style']['players'][: FORMATS[entry['format']]],
|
|
1846
|
+
entry['style']['players'],
|
|
1847
|
+
_model_names(),
|
|
1848
|
+
)
|
|
1849
|
+
name = _printable(saved['name']).strip()[:80] if isinstance(saved.get('name'), str) else ''
|
|
1850
|
+
if not name_given and name:
|
|
1851
|
+
entry['name'] = name
|
|
1852
|
+
for path in list(team_folder.iterdir()):
|
|
1853
|
+
slot, suffix = path.stem, path.suffix
|
|
1854
|
+
if path.name in ('team.json', _MODEL, 'clips', 'presentation') or not _kept(path):
|
|
1855
|
+
continue
|
|
1856
|
+
sound = slot.startswith('sound-')
|
|
1857
|
+
served = (sound and slot[len('sound-') :] in SOUND_SLOTS and suffix in _SOUND_SUFFIXES) or (
|
|
1858
|
+
not sound and slot in IMAGE_SLOTS and suffix in _IMAGE_SUFFIXES
|
|
1859
|
+
)
|
|
1860
|
+
if not served:
|
|
1861
|
+
# A name that differs from a slot's file only in its case, or holds more after the
|
|
1862
|
+
# slot, is set aside, so no system that finds names whatever their case reads it.
|
|
1863
|
+
if _near_slot(path.name):
|
|
1864
|
+
set_aside(path, 'its name is close to the name of a file the page reads')
|
|
1865
|
+
continue
|
|
1866
|
+
# A file that the page would read as an image or a sound is checked as an upload is, and
|
|
1867
|
+
# one that fails is set aside. Every other file stays as the submission brought it.
|
|
1868
|
+
try:
|
|
1869
|
+
data = path.read_bytes()
|
|
1870
|
+
if sound:
|
|
1871
|
+
valid = len(data) <= _MAXIMUM_SOUND_BYTES and _sound_suffix(data) == suffix
|
|
1872
|
+
else:
|
|
1873
|
+
valid = len(data) <= _MAXIMUM_IMAGE_BYTES and _image_suffix(slot, data) == suffix
|
|
1874
|
+
except Exception:
|
|
1875
|
+
# A file that fails any part of its check, however it fails, counts as failing.
|
|
1876
|
+
valid = False
|
|
1877
|
+
if not valid:
|
|
1878
|
+
set_aside(path, 'it fails the checks of an upload')
|
|
1879
|
+
from .._packages import presentation as _presentation
|
|
1880
|
+
from . import clips as _clips
|
|
1881
|
+
|
|
1882
|
+
_clips.import_folder(team_folder)
|
|
1883
|
+
_presentation.import_folder(team_folder)
|
|
1884
|
+
model = team_folder / _MODEL
|
|
1885
|
+
joints = (
|
|
1886
|
+
(saved.get('model') or {}).get('joints') if isinstance(saved.get('model'), dict) else None
|
|
1887
|
+
)
|
|
1888
|
+
try:
|
|
1889
|
+
data = model.read_bytes()
|
|
1890
|
+
if len(data) > _MAXIMUM_MODEL_BYTES:
|
|
1891
|
+
raise MessageError('server.teams.model.too_large')
|
|
1892
|
+
# A model without joints in team.json takes those the team editor guesses from its bones.
|
|
1893
|
+
from . import joints as _joints
|
|
1894
|
+
|
|
1895
|
+
joints, guessed = _joints.resolve(joints, _skin_joints(data))
|
|
1896
|
+
entry['model'] = {'joints': joints, 'bytes': len(data)}
|
|
1897
|
+
style_saved = saved.get('style')
|
|
1898
|
+
if guessed and not (style_saved.get('model') if isinstance(style_saved, dict) else None):
|
|
1899
|
+
entry['style']['model'] = 'custom'
|
|
1900
|
+
except Exception:
|
|
1901
|
+
if model.exists() or model.is_symlink():
|
|
1902
|
+
set_aside(model, 'it fails the checks of an uploaded model')
|
|
1903
|
+
entry['model'] = None
|
|
1904
|
+
if entry['style'].get('model') == 'custom':
|
|
1905
|
+
entry['style']['model'] = 'grf'
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def duplicate(identifier: str) -> dict[str, Any]:
|
|
1909
|
+
"""Copies a registered submission as a new team and returns the copy's entry.
|
|
1910
|
+
|
|
1911
|
+
The copy takes the submission's files, its format, controls, style, roster, images, sounds and
|
|
1912
|
+
model as they were last saved, under the name of the team followed by `copy`, and from then on
|
|
1913
|
+
each team changes alone.
|
|
1914
|
+
|
|
1915
|
+
Raises:
|
|
1916
|
+
ArgumentError: If the team is unknown or is the built-in AI, which has no files to copy.
|
|
1917
|
+
"""
|
|
1918
|
+
team = entry(identifier)
|
|
1919
|
+
if team['kind'] == 'builtin':
|
|
1920
|
+
raise MessageError('server.teams.duplicate_builtin')
|
|
1921
|
+
filename, data = export(identifier)
|
|
1922
|
+
return add(f'{team["name"]} copy', filename, data, team['format'])
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
def export(identifier: str) -> tuple[str, bytes]:
|
|
1926
|
+
"""Returns a team as a zip of its submission folder, with its `cuju-team` folder inside.
|
|
1927
|
+
|
|
1928
|
+
The archive holds `main.py` and the submission's files at its top, as Kaggle reads them, and
|
|
1929
|
+
`cuju-team` with the team's format, style, images and model, which the page reads back when
|
|
1930
|
+
the folder is uploaded again. The built-in AI exports only its `cuju-team` folder.
|
|
1931
|
+
|
|
1932
|
+
Returns:
|
|
1933
|
+
A file name for the archive and its bytes.
|
|
1934
|
+
"""
|
|
1935
|
+
team = entry(identifier)
|
|
1936
|
+
root = _meta_path(identifier).parent.parent
|
|
1937
|
+
meta = _meta_path(identifier)
|
|
1938
|
+
stored = {key: value for key, value in team.items() if key not in _IMPLIED}
|
|
1939
|
+
from .._packages import presentation as _presentation
|
|
1940
|
+
|
|
1941
|
+
# A package's files go out only as they pass their check again.
|
|
1942
|
+
failing = _presentation.left_out(root) if root.is_dir() else set()
|
|
1943
|
+
buffer = io.BytesIO()
|
|
1944
|
+
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
|
1945
|
+
archive.writestr(f'{TEAM_FOLDER}/team.json', json.dumps(stored))
|
|
1946
|
+
for path in sorted(root.rglob('*')) if root.is_dir() else []:
|
|
1947
|
+
if (
|
|
1948
|
+
path.is_file()
|
|
1949
|
+
and path != meta
|
|
1950
|
+
and path not in failing
|
|
1951
|
+
and not path.name.endswith('.part')
|
|
1952
|
+
and '__pycache__' not in path.parts
|
|
1953
|
+
):
|
|
1954
|
+
archive.write(path, path.relative_to(root).as_posix())
|
|
1955
|
+
safe = ''.join(character if character.isalnum() else '-' for character in team['name'])
|
|
1956
|
+
return f'{safe.strip("-") or "team"}.zip', buffer.getvalue()
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def remove(identifier: str) -> bool:
|
|
1960
|
+
"""Deletes a team's folder and returns whether it existed.
|
|
1961
|
+
|
|
1962
|
+
The built-in AI stays, and removing it only resets its name, style, model and images.
|
|
1963
|
+
"""
|
|
1964
|
+
if identifier == BUILTIN:
|
|
1965
|
+
shutil.rmtree(_meta_path(BUILTIN).parent.parent, ignore_errors=True)
|
|
1966
|
+
return True
|
|
1967
|
+
if not _home.valid_identifier(identifier):
|
|
1968
|
+
return False
|
|
1969
|
+
folder = _home.folder('teams') / identifier
|
|
1970
|
+
if not folder.is_dir():
|
|
1971
|
+
return False
|
|
1972
|
+
shutil.rmtree(folder)
|
|
1973
|
+
return True
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
def entry(identifier: str) -> dict[str, Any]:
|
|
1977
|
+
"""Returns the entry of a team, or raises `ArgumentError` for an unknown one."""
|
|
1978
|
+
for team in teams():
|
|
1979
|
+
if team['id'] == identifier:
|
|
1980
|
+
return team
|
|
1981
|
+
raise MessageError('server.teams.unknown', identifier=repr(identifier))
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
def side_entry(identifier: str) -> dict[str, Any]:
|
|
1985
|
+
"""Returns the entry of a side of a match, a team of the registry or the random entrant.
|
|
1986
|
+
|
|
1987
|
+
The random entrant is no team of the registry, so the Teams page leaves it out, and it plays
|
|
1988
|
+
one player of either size with actions drawn at random, its teammates the built-in AI's.
|
|
1989
|
+
"""
|
|
1990
|
+
if identifier == RANDOM_ACTIONS:
|
|
1991
|
+
return _random()
|
|
1992
|
+
return entry(identifier)
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
_RANDOM_KIT = {
|
|
1996
|
+
'pattern': 'stripes',
|
|
1997
|
+
'primary': [120, 120, 128],
|
|
1998
|
+
'secondary': [255, 214, 0],
|
|
1999
|
+
'shorts': [60, 60, 66],
|
|
2000
|
+
'socks': [255, 214, 0],
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
def _random() -> dict[str, Any]:
|
|
2005
|
+
"""Returns the entry of the random entrant, with a kit of its own and a name as a label."""
|
|
2006
|
+
entry = {'id': RANDOM_ACTIONS, 'kind': 'random', 'created': None, 'format': '11v11'}
|
|
2007
|
+
entry.update(_messages.named('name', _messages.label('server.names.random')))
|
|
2008
|
+
return _complete(entry, _RANDOM_KIT)
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def load_agent(identifier: str) -> Any:
|
|
2012
|
+
"""Loads a submission's agent function as Kaggle does, the last callable its `main.py` defines.
|
|
2013
|
+
|
|
2014
|
+
The agent runs from its folder, which comes first on `sys.path`, as on Kaggle, so its own
|
|
2015
|
+
modules import and its working files resolve. The modules it imports from its folder leave
|
|
2016
|
+
`sys.modules` afterwards, so a second submission with modules of the same names gets its own.
|
|
2017
|
+
"""
|
|
2018
|
+
folder = _home.folder('teams') / identifier
|
|
2019
|
+
main = folder / _MAIN
|
|
2020
|
+
if not _home.valid_identifier(identifier) or not main.is_file():
|
|
2021
|
+
raise MessageError('server.teams.unknown_kaggle', identifier=repr(identifier))
|
|
2022
|
+
module = types.ModuleType(f'cuju_team_{identifier.replace("-", "_")}')
|
|
2023
|
+
module.__file__ = str(main)
|
|
2024
|
+
with _from_folder(folder):
|
|
2025
|
+
before = set(sys.modules)
|
|
2026
|
+
# Kaggle: `get_last_callable` runs the file and takes the last callable it defined. The
|
|
2027
|
+
# bytes go to `compile`, so the file's own encoding line or UTF-8 decides how it reads.
|
|
2028
|
+
exec(compile(main.read_bytes(), str(main), 'exec'), module.__dict__)
|
|
2029
|
+
resolved = folder.resolve()
|
|
2030
|
+
for name in set(sys.modules) - before:
|
|
2031
|
+
origin = getattr(sys.modules[name], '__file__', None)
|
|
2032
|
+
if origin and Path(origin).resolve().is_relative_to(resolved):
|
|
2033
|
+
del sys.modules[name]
|
|
2034
|
+
callables = [value for value in module.__dict__.values() if callable(value)]
|
|
2035
|
+
if not callables:
|
|
2036
|
+
raise MessageError('server.teams.agent_function', identifier=repr(identifier))
|
|
2037
|
+
agent = callables[-1]
|
|
2038
|
+
|
|
2039
|
+
@functools.wraps(agent)
|
|
2040
|
+
def run(*arguments: Any) -> Any:
|
|
2041
|
+
with _from_folder(folder):
|
|
2042
|
+
return agent(*arguments)
|
|
2043
|
+
|
|
2044
|
+
return run
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
@contextlib.contextmanager
|
|
2048
|
+
def _from_folder(folder: Path) -> Any:
|
|
2049
|
+
"""Runs a block from a submission's folder, first on `sys.path`, as Kaggle runs an agent."""
|
|
2050
|
+
previous = os.getcwd()
|
|
2051
|
+
sys.path.insert(0, str(folder))
|
|
2052
|
+
os.chdir(folder)
|
|
2053
|
+
try:
|
|
2054
|
+
yield
|
|
2055
|
+
finally:
|
|
2056
|
+
os.chdir(previous)
|
|
2057
|
+
with contextlib.suppress(ValueError):
|
|
2058
|
+
sys.path.remove(str(folder))
|