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,2585 @@
|
|
|
1
|
+
// Copyright 2026 The cuju Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// The Tournaments page. A list of tournaments sits beside the open one, which shows its stages as
|
|
5
|
+
// brackets, tables and fixtures with results, the matches of people ready to play, and the trophy
|
|
6
|
+
// ceremony at the end. New tournament opens a wizard of five steps, the format, the entrants, the
|
|
7
|
+
// settings, every setting of every stage in the advanced editor, and a review of the draw.
|
|
8
|
+
|
|
9
|
+
import { api } from '../core/api.js';
|
|
10
|
+
import { formatList, formatNumber, serverText, t, tn, tOr } from '../core/i18n.js';
|
|
11
|
+
import { kitShirt } from '../pages/matches.js';
|
|
12
|
+
import { el as h } from '../core/menu.js';
|
|
13
|
+
import { icon, stateBadge, symbolOnly } from '../core/icons.js';
|
|
14
|
+
import { listedScenarios, scenarioName, scenarioText } from '../core/scenarios.js';
|
|
15
|
+
import {
|
|
16
|
+
DECIDED_NAMES, FORMAT_NAMES, NAME_LONGEST, SECTION_NAMES, SETTLE_NAMES, STATE_NAMES, TIEBREAK_NAMES,
|
|
17
|
+
againStages, bracketLayout, entrantName, fixtureMatches, fixtureState, formatsText, freeName, isBracket, isFinal, jsonErrorPlace,
|
|
18
|
+
madeNames, nameKey, presetDraft, progressShare, sections, sentence, setSetting, shownAbility, sideName, stageNameText, stageText,
|
|
19
|
+
tableCaption, tiebreakText, upNext,
|
|
20
|
+
} from './tournament-layout.js';
|
|
21
|
+
import {
|
|
22
|
+
autoplay, markWatched, noteRecordings, predict, prediction, predictionScore, setAutoplay, setSpoilerFree,
|
|
23
|
+
spoilerFree, watchedKeys,
|
|
24
|
+
} from '../core/spoilers.js';
|
|
25
|
+
import {
|
|
26
|
+
applyPreset, fromConfig, kindGlyph, legsControl, randomAdder, randomName, removedEntrant, resultsSection, strictConfig,
|
|
27
|
+
strictFields, strictHead, strictReview,
|
|
28
|
+
} from './tournament-strict.js';
|
|
29
|
+
import { RANDOM_KEY, forfeitWords } from './tournament-results.js';
|
|
30
|
+
|
|
31
|
+
const FORMATS = Object.keys(FORMAT_NAMES);
|
|
32
|
+
const TIEBREAKERS = Object.keys(TIEBREAK_NAMES);
|
|
33
|
+
const SETTLEMENTS = Object.keys(SETTLE_NAMES);
|
|
34
|
+
const STEPS = [t('tournament.step.format'), t('tournament.step.entrants'), t('tournament.step.settings'), t('tournament.step.stages'), t('tournament.step.review')];
|
|
35
|
+
const STATE_LABELS = { running: t('tournament.state.running'), stopped: t('tournament.state.stopped'), done: t('tournament.state.done'), cancelled: t('tournament.state.cancelled') };
|
|
36
|
+
const KIND_NAMES = { team: t('tournament.kind.team'), builtin: t('tournament.kind.builtin'), person: t('tournament.kind.person'), random: t('tournament.kind.random') };
|
|
37
|
+
// The name of a preset of the user, as the server's rule of identifiers takes it.
|
|
38
|
+
const PRESET_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
39
|
+
// The words between the formats of the stages in a list.
|
|
40
|
+
const COMMA_THEN = t('tournament.formats.commaThen');
|
|
41
|
+
|
|
42
|
+
// The title and description of a preset, those of a shipped one in the language of the page.
|
|
43
|
+
function presetTitle(name, preset) {
|
|
44
|
+
return preset.shipped ? tOr(`tournament.preset.${name}.title`, preset.title) : preset.title;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function presetDescription(name, preset) {
|
|
48
|
+
return preset.shipped ? tOr(`tournament.preset.${name}.description`, preset.description) : preset.description;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// An ability in the numbers of the language, with two decimals.
|
|
52
|
+
function abilityText(value) {
|
|
53
|
+
return formatNumber(Number(value), { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The ability beside an entrant's name where it differs from the standard, or null, with the
|
|
57
|
+
// symbol of the built-in AI before it, since the ability steers the players the built-in AI controls.
|
|
58
|
+
function abilityBadge(entrant, standard) {
|
|
59
|
+
const own = shownAbility(entrant, standard);
|
|
60
|
+
if (own === null) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return h('span', { class: 'cup-ability small muted', title: t('tournament.entrant.shownAbility.title') },
|
|
64
|
+
icon('robot', 'icon text-symbol'), t('tournament.entrant.shownAbility', { ability: abilityText(own) }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The words of an entrant's own ability for a label that a screen reader reads, or an empty text.
|
|
68
|
+
function abilityWords(entrant, standard) {
|
|
69
|
+
const own = shownAbility(entrant, standard);
|
|
70
|
+
return own === null ? '' : t('tournament.entrant.shownAbility', { ability: abilityText(own) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The standard ability of a tournament in words, its value or the scenario's own.
|
|
74
|
+
function standardText(standard) {
|
|
75
|
+
return standard === null || standard === undefined ? t('tournament.view.ability.scenario') : t('tournament.view.ability', { ability: abilityText(standard) });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The size of a team and the players it controls, such as 5v5, 2 controlled players.
|
|
79
|
+
function teamSize(team) {
|
|
80
|
+
const players = (team && team.control && team.control.players) || 1;
|
|
81
|
+
return t('tournament.team.size', { format: team.format, count: players });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A team with its size, for a list or a select.
|
|
85
|
+
function teamOption(team) {
|
|
86
|
+
return t('tournament.entrants.team.option', { name: team.name, size: teamSize(team) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A request to the server, which throws an error with the server's reason.
|
|
90
|
+
async function call(method, path, json) {
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(path, {
|
|
94
|
+
method,
|
|
95
|
+
cache: 'no-store',
|
|
96
|
+
headers: json === undefined ? {} : { 'Content-Type': 'application/json' },
|
|
97
|
+
body: json === undefined ? undefined : JSON.stringify(json),
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
const error = new Error(t('api.unreachable', { origin: location.origin }));
|
|
101
|
+
error.status = 0;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
let body = null;
|
|
106
|
+
try {
|
|
107
|
+
body = text ? JSON.parse(text) : null;
|
|
108
|
+
} catch {
|
|
109
|
+
body = null;
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
// A refusal of the server starts small, as it continues a sentence elsewhere, and the
|
|
113
|
+
// page shows it as a sentence of its own.
|
|
114
|
+
const error = new Error(sentence((body && body.error && serverText(body, 'error')) || t('tournament.api.status', { status: res.status })));
|
|
115
|
+
error.status = res.status;
|
|
116
|
+
// The message of a refusal and its values, so the page can lead to the setting it names.
|
|
117
|
+
error.id = (body && body.error_id) || null;
|
|
118
|
+
error.values = (body && body.error_values) || {};
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
return body;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const tournamentsApi = {
|
|
125
|
+
list: () => call('GET', '/api/tournaments'),
|
|
126
|
+
get: (id) => call('GET', `/api/tournaments/${encodeURIComponent(id)}`),
|
|
127
|
+
presets: () => call('GET', '/api/tournaments/presets'),
|
|
128
|
+
ratings: () => call('GET', '/api/tournaments/ratings'),
|
|
129
|
+
check: (config) => call('POST', '/api/tournaments/check', { config }),
|
|
130
|
+
// A tournament as a person sees it who has watched only the matches of `watched`.
|
|
131
|
+
spoilers: (id, watched) => call('POST', `/api/tournaments/${encodeURIComponent(id)}/spoilers`, { watched }),
|
|
132
|
+
create: (config) => call('POST', '/api/tournaments', { config }),
|
|
133
|
+
action: (id, action, body = {}) => call('POST', `/api/tournaments/${encodeURIComponent(id)}/${action}`, body),
|
|
134
|
+
remove: (id) => call('DELETE', `/api/tournaments/${encodeURIComponent(id)}`),
|
|
135
|
+
savePreset: (name, preset) => call('POST', `/api/tournaments/presets/${encodeURIComponent(name)}`, { preset }),
|
|
136
|
+
removePreset: (name) => call('DELETE', `/api/tournaments/presets/${encodeURIComponent(name)}`),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function ratingKey(entry) {
|
|
140
|
+
if (entry.kind === 'team') {
|
|
141
|
+
return entry.team;
|
|
142
|
+
}
|
|
143
|
+
if (entry.kind === 'builtin') {
|
|
144
|
+
return `builtin:${entry.ability == null ? 'scenario' : Number(entry.ability).toFixed(2)}`;
|
|
145
|
+
}
|
|
146
|
+
if (entry.kind === 'random') {
|
|
147
|
+
return RANDOM_KEY;
|
|
148
|
+
}
|
|
149
|
+
return `person:${entry.name || ''}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function clone(value) {
|
|
153
|
+
return JSON.parse(JSON.stringify(value));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A team in its away kit, its shirt and shorts colors swapped with its trim, as the right side of
|
|
157
|
+
// a match wears it against the same team, so two entrants of one team look apart in a fixture.
|
|
158
|
+
function awayTeam(team) {
|
|
159
|
+
const kit = (team.style && team.style.kit) || {};
|
|
160
|
+
const primary = kit.primary || team.color;
|
|
161
|
+
const secondary = kit.secondary || team.secondary;
|
|
162
|
+
if (!primary || !secondary) {
|
|
163
|
+
return team;
|
|
164
|
+
}
|
|
165
|
+
const swapped = { ...kit, primary: secondary, secondary: primary, shorts: kit.socks || primary, socks: kit.shorts || secondary };
|
|
166
|
+
return { ...team, color: secondary, secondary: primary, style: { ...(team.style || {}), kit: swapped } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Every setting of a stage that the advanced editor shows, with the formats it applies to.
|
|
170
|
+
const ALL = null;
|
|
171
|
+
const KNOCKOUTS = ['knockout', 'double_elimination', 'ladder', 'king_of_the_hill', 'gauntlet'];
|
|
172
|
+
const TABLES = ['league', 'groups', 'swiss'];
|
|
173
|
+
const OFF_SCENARIO = t('tournament.off.scenario');
|
|
174
|
+
const OFF_ENTRANTS = t('tournament.off.entrants');
|
|
175
|
+
const BY_ENTRANTS = t('tournament.scenario.byEntrants');
|
|
176
|
+
const RANDOM_SEEDING = t('tournament.seeding.random');
|
|
177
|
+
// The seeding of a later stage, whose default is the standing of the stage before it.
|
|
178
|
+
const LATER_SEEDING = [['standing', t('tournament.field.seeding.standing')], ['random', t('tournament.field.seeding.random')], ['rating', t('tournament.field.seeding.rating')]];
|
|
179
|
+
const STAGE_FIELDS = [
|
|
180
|
+
{ key: 'name', label: t('tournament.field.name'), type: 'text', formats: ALL },
|
|
181
|
+
{ key: 'format', label: t('tournament.field.format'), type: 'select', options: FORMATS.map((f) => [f, FORMAT_NAMES[f]]), formats: ALL },
|
|
182
|
+
{ key: 'scenario', label: t('tournament.field.scenario'), type: 'scenario', empty: BY_ENTRANTS, formats: ALL },
|
|
183
|
+
{ key: 'length', label: t('tournament.field.length'), type: 'optional', min: 50, max: 5000, step: 50, initial: 600, off: OFF_SCENARIO, formats: ALL },
|
|
184
|
+
{ key: 'ability', label: t('tournament.field.ability'), type: 'optional', min: 0, max: 1, step: 0.05, initial: 0.5, off: OFF_SCENARIO, formats: ALL, help: t('tournament.field.ability.help') },
|
|
185
|
+
{ key: 'legs', label: t('tournament.field.legs'), type: 'number', min: 1, max: 10, formats: ALL },
|
|
186
|
+
{ key: 'final_legs', label: t('tournament.field.final_legs'), type: 'optional', min: 1, max: 10, step: 1, initial: 1, off: t('tournament.off.otherTies'), formats: ['knockout', 'double_elimination'] },
|
|
187
|
+
{ key: 'settle', label: t('tournament.field.settle'), type: 'order', options: SETTLEMENTS, names: SETTLE_NAMES, formats: KNOCKOUTS },
|
|
188
|
+
{ key: 'replays', label: t('tournament.field.replays'), type: 'number', min: 1, max: 5, formats: KNOCKOUTS },
|
|
189
|
+
{ key: 'decider_scenario', label: t('tournament.field.decider_scenario'), type: 'scenario', formats: KNOCKOUTS, help: t('tournament.field.decider_scenario.help') },
|
|
190
|
+
{ key: 'decider_length', label: t('tournament.field.decider_length'), type: 'optional', min: 50, max: 5000, step: 50, initial: 400, off: OFF_SCENARIO, formats: KNOCKOUTS },
|
|
191
|
+
{ key: 'draws', label: t('tournament.field.draws'), type: 'select', options: [['holder', t('tournament.field.draws.holder')], ['settle', t('tournament.field.draws.settle')]], formats: ['ladder', 'king_of_the_hill', 'gauntlet'] },
|
|
192
|
+
{ key: 'points', label: t('tournament.field.points'), type: 'points', formats: [...TABLES, 'ladder', 'king_of_the_hill'] },
|
|
193
|
+
{ key: 'tiebreakers', label: t('tournament.field.tiebreakers'), type: 'order', options: TIEBREAKERS, names: TIEBREAK_NAMES, formats: TABLES },
|
|
194
|
+
{ key: 'bracket', label: t('tournament.field.bracket'), type: 'select', options: [['byes', t('tournament.field.bracket.byes')], ['play_in', t('tournament.field.bracket.play_in')]], formats: ['knockout'] },
|
|
195
|
+
{ key: 'bracket_size', label: t('tournament.field.bracket_size'), type: 'optional', min: 2, max: 512, step: 1, initial: 8, off: OFF_ENTRANTS, formats: ['knockout', 'double_elimination'] },
|
|
196
|
+
{ key: 'until', label: t('tournament.field.until'), type: 'number', min: 1, max: 256, formats: ['knockout'] },
|
|
197
|
+
{ key: 'third_place', label: t('tournament.field.third_place'), type: 'check', formats: ['knockout'] },
|
|
198
|
+
{ key: 'grand_final_reset', label: t('tournament.field.grand_final_reset'), type: 'check', formats: ['double_elimination'] },
|
|
199
|
+
{ key: 'cycles', label: t('tournament.field.cycles'), type: 'number', min: 1, max: 10, formats: ['league', 'groups'] },
|
|
200
|
+
{ key: 'groups', label: t('tournament.field.groups'), type: 'optional', min: 1, max: 128, step: 1, initial: 4, off: t('tournament.off.groupSize'), formats: ['groups'] },
|
|
201
|
+
{ key: 'group_size', label: t('tournament.field.group_size'), type: 'number', min: 2, max: 256, formats: ['groups'] },
|
|
202
|
+
{ key: 'group_sizes', label: t('tournament.field.group_sizes'), type: 'list', placeholder: t('tournament.field.group_sizes.placeholder'), formats: ['groups'] },
|
|
203
|
+
{ key: 'pots', label: t('tournament.field.pots'), type: 'optional', min: 1, max: 256, step: 1, initial: 4, off: t('tournament.off.pots'), formats: ['groups'] },
|
|
204
|
+
{ key: 'qualifiers', label: t('tournament.field.qualifiers'), type: 'list', placeholder: t('tournament.field.qualifiers.placeholder'), single: true, formats: ['groups'] },
|
|
205
|
+
{ key: 'best_position', label: t('tournament.field.best_position'), type: 'optional', min: 1, max: 64, step: 1, initial: 3, off: t('tournament.off.none'), formats: ['groups'] },
|
|
206
|
+
{ key: 'best_count', label: t('tournament.field.best_count'), type: 'fill', formats: ['groups'] },
|
|
207
|
+
{ key: 'rounds', label: t('tournament.field.rounds'), type: 'optional', min: 1, max: 1000, step: 1, initial: 5, off: OFF_ENTRANTS, formats: ['swiss', 'ladder'] },
|
|
208
|
+
{ key: 'bye_points', label: t('tournament.field.bye_points'), type: 'select', options: [['win', t('tournament.field.bye_points.win')], ['draw', t('tournament.field.bye_points.draw')], ['none', t('tournament.field.bye_points.none')]], formats: ['swiss'] },
|
|
209
|
+
{ key: 'laps', label: t('tournament.field.laps'), type: 'number', min: 1, max: 10, formats: ['king_of_the_hill'] },
|
|
210
|
+
{ key: 'seeding', label: t('tournament.field.seeding'), type: 'select', options: LATER_SEEDING, formats: ALL, later: true },
|
|
211
|
+
{ key: 'advance', label: t('tournament.field.advance'), type: 'optional', min: 1, max: 256, step: 1, initial: 4, off: t('tournament.off.format'), formats: ALL, notLast: true },
|
|
212
|
+
];
|
|
213
|
+
const STAGE_DEFAULTS = {
|
|
214
|
+
format: 'knockout', name: '', scenario: null, length: null, ability: null, legs: 1, final_legs: null,
|
|
215
|
+
settle: ['replay', 'lots'], replays: 1, decider_scenario: 'academy_run_to_score_with_keeper', decider_length: null,
|
|
216
|
+
points: { win: 3, draw: 1, loss: 0 }, tiebreakers: ['goal_difference', 'goals_for', 'head_to_head', 'wins', 'fair_play', 'lots'],
|
|
217
|
+
seeding: 'standing', bracket: 'byes', bracket_size: null, until: 1, third_place: false, grand_final_reset: true,
|
|
218
|
+
cycles: 1, groups: null, group_size: 4, group_sizes: null, pots: null, qualifiers: 2, best_position: null, best_count: 0,
|
|
219
|
+
rounds: null, laps: 1, bye_points: 'win', draws: null, advance: null,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function newDraft() {
|
|
223
|
+
return {
|
|
224
|
+
name: '', seed: '', seeding: 'random', concurrency: 1, forfeit_score: 3, ability: null, preset: null,
|
|
225
|
+
defaults: {}, stages: [{ format: 'knockout', name: 'Cup' }], entrants: [],
|
|
226
|
+
// The settings of OWN_SETTINGS that the person set by hand.
|
|
227
|
+
own: new Set(),
|
|
228
|
+
// Strict rules, the pre-flight and the entrants a preset adds, see tournament-strict.js.
|
|
229
|
+
strict: null, preflight: false, include: [],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export class TournamentsPage {
|
|
234
|
+
constructor(app) {
|
|
235
|
+
this.app = app;
|
|
236
|
+
this.root = document.getElementById('page-tournaments');
|
|
237
|
+
this.list = [];
|
|
238
|
+
this.teams = [];
|
|
239
|
+
this.ratings = {};
|
|
240
|
+
this.scenarios = [];
|
|
241
|
+
this.presets = {};
|
|
242
|
+
this.current = null;
|
|
243
|
+
this.openId = null;
|
|
244
|
+
this.mode = 'empty';
|
|
245
|
+
this.draft = null;
|
|
246
|
+
this.step = 0;
|
|
247
|
+
this.stageTab = 0;
|
|
248
|
+
this.error = null;
|
|
249
|
+
this.built = false;
|
|
250
|
+
this.token = 0;
|
|
251
|
+
this.announced = new Set();
|
|
252
|
+
// The award that waits for its confirmation, by where it shows, its match and its side.
|
|
253
|
+
this.awarding = null;
|
|
254
|
+
this.primed = false;
|
|
255
|
+
// The match that plays on Watch for a tournament without spoilers, the matches watched
|
|
256
|
+
// in this visit by tournament, the zoom of the Brackets view and the card between matches.
|
|
257
|
+
this.watching = null;
|
|
258
|
+
this.session = {};
|
|
259
|
+
this.zoom = 1;
|
|
260
|
+
this.intermission = null;
|
|
261
|
+
this.revealing = false;
|
|
262
|
+
document.addEventListener('cuju:recording-ended', (e) => this.recordingEnded(e.detail || {}));
|
|
263
|
+
// A person who comes back to the page from another window may have registered a team
|
|
264
|
+
// there, so the teams are read again.
|
|
265
|
+
window.addEventListener('focus', () => {
|
|
266
|
+
if (this.visible && this.built) {
|
|
267
|
+
this.refreshTeams();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
setInterval(() => this.poll(), 1500);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Says a change in the polite region of the page, which stays in place and empty between
|
|
274
|
+
// messages, so a screen reader hears each one, the same text again too.
|
|
275
|
+
announce(text) {
|
|
276
|
+
if (!this.announcer) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.announcer.textContent = '';
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
this.announcer.textContent = text;
|
|
282
|
+
}, 50);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Reads the registry again, and redraws the part that shows teams when it changed, the
|
|
286
|
+
// entrants step of the wizard or the open tournament.
|
|
287
|
+
async refreshTeams() {
|
|
288
|
+
const teams = await api.teams().catch(() => null);
|
|
289
|
+
if (!teams || JSON.stringify(teams) === JSON.stringify(this.teams)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.teams = teams;
|
|
293
|
+
if (this.mode === 'wizard' && this.step === 1) {
|
|
294
|
+
this.renderWizard();
|
|
295
|
+
} else if ((this.mode === 'view' || this.mode === 'brackets') && this.current) {
|
|
296
|
+
this.renderView();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
get visible() {
|
|
301
|
+
return !!this.root && !this.root.hidden;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async refresh() {
|
|
305
|
+
const token = ++this.token;
|
|
306
|
+
try {
|
|
307
|
+
const [list, teams, scenarios, presets] = await Promise.all([
|
|
308
|
+
tournamentsApi.list(), api.teams().catch(() => []), api.scenarios().catch(() => []), tournamentsApi.presets().catch(() => ({})),
|
|
309
|
+
]);
|
|
310
|
+
if (token !== this.token) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
this.list = list;
|
|
314
|
+
this.teams = teams;
|
|
315
|
+
this.scenarios = listedScenarios(scenarios);
|
|
316
|
+
this.presets = presets;
|
|
317
|
+
this.error = null;
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (token !== this.token) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.error = err;
|
|
323
|
+
}
|
|
324
|
+
this.render();
|
|
325
|
+
if ((this.mode === 'view' || this.mode === 'brackets') && this.openId) {
|
|
326
|
+
await this.load(this.openId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
build() {
|
|
331
|
+
this.built = true;
|
|
332
|
+
this.listHost = h('ul', { class: 'cup-list', 'aria-label': t('tournament.list.label') });
|
|
333
|
+
// The main part redraws as a tournament plays, so it stays silent, and the announcer
|
|
334
|
+
// below says the changes worth hearing.
|
|
335
|
+
this.mainHost = h('section', { class: 'cup-main', 'aria-busy': 'false' });
|
|
336
|
+
const side = h('aside', { class: 'cup-side', 'aria-labelledby': 'cup-list-title' },
|
|
337
|
+
h('header', { class: 'cup-side-head' },
|
|
338
|
+
h('h1', { id: 'cup-list-title' }, t('tournament.list.title')),
|
|
339
|
+
h('button', { type: 'button', class: 'btn primary', onclick: () => this.startWizard() }, icon('plus'), t('tournament.new'))),
|
|
340
|
+
this.listHost);
|
|
341
|
+
this.announcer = h('p', { class: 'sr-only', role: 'status', 'aria-live': 'polite' });
|
|
342
|
+
this.root.replaceChildren(h('div', { class: 'cup-layout' }, side, this.mainHost), this.announcer);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
render() {
|
|
346
|
+
if (this.error) {
|
|
347
|
+
this.built = false;
|
|
348
|
+
this.root.replaceChildren(h('div', { class: 'page-pad' }, h('div', { class: 'notice error' },
|
|
349
|
+
h('p', {}, this.error.message),
|
|
350
|
+
h('p', { class: 'muted' }, tn('tournament.offline.hint')),
|
|
351
|
+
h('button', { class: 'btn', type: 'button', ...symbolOnly(t('tournament.offline.retry')), onclick: () => this.refresh() }, icon('refresh')))));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!this.built) {
|
|
355
|
+
this.build();
|
|
356
|
+
}
|
|
357
|
+
this.renderList();
|
|
358
|
+
if (this.mode === 'empty') {
|
|
359
|
+
this.renderEmpty();
|
|
360
|
+
} else if (this.mode === 'wizard' && !this.mainHost.querySelector('.cup-wizard')) {
|
|
361
|
+
this.renderWizard();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
renderList() {
|
|
366
|
+
if (!this.list.length) {
|
|
367
|
+
this.listHost.replaceChildren(h('li', { class: 'muted pad' }, t('tournament.list.empty')));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
this.listHost.replaceChildren(...this.list.map((cup) => {
|
|
371
|
+
const share = progressShare(cup);
|
|
372
|
+
const shown = cup.id === this.openId && (this.mode === 'view' || this.mode === 'brackets');
|
|
373
|
+
const button = h('button', {
|
|
374
|
+
type: 'button', class: `cup-item${shown ? ' active' : ''}`,
|
|
375
|
+
'aria-current': shown ? 'true' : 'false',
|
|
376
|
+
onclick: () => this.open(cup.id),
|
|
377
|
+
},
|
|
378
|
+
h('span', { class: 'cup-item-name' }, serverText(cup, 'name')),
|
|
379
|
+
h('span', { class: 'cup-item-meta' },
|
|
380
|
+
stateBadge(cup.state, STATE_LABELS[cup.state] || cup.state, `state-pill ${cup.state === 'running' ? 'running' : cup.state === 'done' ? 'done' : 'failed'}`),
|
|
381
|
+
t('tournament.list.meta', { count: cup.entrants, formats: formatsText(cup.stages) })),
|
|
382
|
+
cup.champion && !spoilerFree(cup.id) ? h('span', { class: 'cup-item-champion' }, t('tournament.list.champion', { name: serverText(cup, 'champion') })) : null,
|
|
383
|
+
spoilerFree(cup.id) ? h('span', { class: 'cup-item-spoilers small muted' }, icon('eye', 'icon text-symbol'), t('tournament.spoilers.listed')) : null,
|
|
384
|
+
// People wait only for a tournament that runs or stopped, never one that ended.
|
|
385
|
+
cup.people_waiting && ['running', 'stopped'].includes(cup.state) ? h('span', { class: 'cup-item-people' }, t('tournament.list.people', { count: cup.people_waiting })) : null,
|
|
386
|
+
// A stopped tournament says why, the failed matches that wait for an award or Resume.
|
|
387
|
+
cup.state === 'stopped' && cup.failed ? h('span', { class: 'cup-item-failed small' }, icon('warning', 'icon text-symbol'), t('tournament.list.failed', { count: cup.failed })) : null,
|
|
388
|
+
cup.state === 'running' ? h('span', { class: 'progress', 'aria-hidden': 'true' }, h('span', { class: 'bar', style: `width:${Math.round(share * 100)}%` })) : null);
|
|
389
|
+
return h('li', {}, button);
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
renderEmpty() {
|
|
394
|
+
this.mainHost.replaceChildren(h('div', { class: 'empty-state' },
|
|
395
|
+
h('p', { class: 'empty-title' }, t('tournament.empty.title')),
|
|
396
|
+
h('p', { class: 'muted' }, tn('tournament.empty.text')),
|
|
397
|
+
h('button', { type: 'button', class: 'btn primary', onclick: () => this.startWizard() }, icon('plus'), t('tournament.new'))));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// The wizard and the advanced editor.
|
|
401
|
+
|
|
402
|
+
startWizard(config = null) {
|
|
403
|
+
this.mode = 'wizard';
|
|
404
|
+
this.root.querySelector('.cup-layout')?.classList.remove('brackets');
|
|
405
|
+
this.openId = null;
|
|
406
|
+
this.step = config ? 1 : 0;
|
|
407
|
+
this.draft = config ? this.draftFrom(config) : newDraft();
|
|
408
|
+
this.replaced = null;
|
|
409
|
+
this.renderList();
|
|
410
|
+
this.renderWizard();
|
|
411
|
+
// The focus goes to the tab of the step that opens, so a keyboard starts in the wizard.
|
|
412
|
+
document.getElementById(`cup-step-${this.step}`)?.focus();
|
|
413
|
+
this.refreshEntrants();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Reads the registry and the ratings again, and redraws the entrants step when they changed.
|
|
417
|
+
async refreshEntrants() {
|
|
418
|
+
const draft = this.draft;
|
|
419
|
+
const [teams, ratings] = await Promise.all([
|
|
420
|
+
api.teams().catch(() => null), tournamentsApi.ratings().catch(() => null),
|
|
421
|
+
]);
|
|
422
|
+
if (this.mode !== 'wizard' || this.draft !== draft) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const changed = (teams && JSON.stringify(teams) !== JSON.stringify(this.teams))
|
|
426
|
+
|| (ratings && JSON.stringify(ratings) !== JSON.stringify(this.ratings));
|
|
427
|
+
this.teams = teams || this.teams;
|
|
428
|
+
this.ratings = ratings || this.ratings;
|
|
429
|
+
if (changed && this.step === 1) {
|
|
430
|
+
this.renderWizard();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
draftFrom(config) {
|
|
435
|
+
const draft = newDraft();
|
|
436
|
+
for (const key of ['name', 'seeding', 'concurrency', 'forfeit_score', 'ability', 'preset']) {
|
|
437
|
+
if (config[key] !== undefined) {
|
|
438
|
+
draft[key] = config[key];
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// A name that the server made, such as Tournament, stays the server's, so the new
|
|
442
|
+
// tournament takes it in any language, while a name a person gave carries over.
|
|
443
|
+
if (config.name_id) {
|
|
444
|
+
draft.name = '';
|
|
445
|
+
}
|
|
446
|
+
draft.stages = againStages(config, Object.keys(STAGE_DEFAULTS), draft.stages);
|
|
447
|
+
draft.defaults = clone((config.given && config.given.defaults) || {});
|
|
448
|
+
draft.entrants = (config.entrants || []).map((e) => {
|
|
449
|
+
const entry = { kind: e.kind, name: e.name_id ? undefined : e.name, ability: e.ability ?? null };
|
|
450
|
+
if (e.kind === 'team') {
|
|
451
|
+
entry.team = e.team;
|
|
452
|
+
// A team that entered under its own name enters again under it, as a team just
|
|
453
|
+
// added does, so its row shows the name as the one it takes, not as one typed.
|
|
454
|
+
const team = this.teams.find((x) => x.id === e.team);
|
|
455
|
+
if (team && e.name === team.name) {
|
|
456
|
+
entry.name = undefined;
|
|
457
|
+
}
|
|
458
|
+
} else if (e.kind === 'person') {
|
|
459
|
+
entry.teammates = e.team || 'builtin';
|
|
460
|
+
}
|
|
461
|
+
if (e.rated) {
|
|
462
|
+
entry.rating = e.rating;
|
|
463
|
+
}
|
|
464
|
+
return entry;
|
|
465
|
+
});
|
|
466
|
+
fromConfig(draft, config);
|
|
467
|
+
return draft;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
renderWizard() {
|
|
471
|
+
const tabs = h('div', { class: 'cup-steps', role: 'tablist', 'aria-label': t('tournament.wizard.steps') },
|
|
472
|
+
STEPS.map((name, index) => h('button', {
|
|
473
|
+
type: 'button', role: 'tab', id: `cup-step-${index}`, class: `cup-step${index === this.step ? ' active' : ''}`,
|
|
474
|
+
'aria-selected': index === this.step ? 'true' : 'false', tabindex: index === this.step ? '0' : '-1',
|
|
475
|
+
'aria-controls': 'cup-step-panel',
|
|
476
|
+
onclick: () => this.goStep(index),
|
|
477
|
+
onkeydown: (e) => this.stepKeys(e, index),
|
|
478
|
+
}, h('span', { class: 'cup-step-number' }, String(index + 1)), name)));
|
|
479
|
+
const panel = h('div', { class: 'cup-step-panel', id: 'cup-step-panel', role: 'tabpanel', 'aria-labelledby': `cup-step-${this.step}` });
|
|
480
|
+
const body = [this.formatStep, this.entrantsStep, this.settingsStep, this.stagesStep, this.reviewStep][this.step].call(this);
|
|
481
|
+
panel.append(...[body].flat().filter(Boolean));
|
|
482
|
+
const nav = h('div', { class: 'cup-nav' },
|
|
483
|
+
h('button', { type: 'button', class: 'btn ghost', ...symbolOnly(t('tournament.wizard.cancel')), onclick: () => this.cancelWizard() }, icon('close')),
|
|
484
|
+
h('span', { class: 'cup-nav-gap' }),
|
|
485
|
+
this.step > 0 ? h('button', { type: 'button', class: 'btn', ...symbolOnly(t('tournament.wizard.back')), onclick: () => this.goStep(this.step - 1, true) }, icon('back')) : null,
|
|
486
|
+
this.step < STEPS.length - 1
|
|
487
|
+
? h('button', { type: 'button', class: 'btn primary', ...symbolOnly(t('tournament.wizard.next')), onclick: () => this.goStep(this.step + 1, true) }, icon('next'))
|
|
488
|
+
: h('button', { type: 'button', class: 'btn primary cup-start', onclick: (e) => this.create(e.currentTarget) }, icon('play'), t('tournament.wizard.start')));
|
|
489
|
+
const memo = this.focusMemo();
|
|
490
|
+
this.mainHost.replaceChildren(h('div', { class: 'cup-wizard' },
|
|
491
|
+
h('header', { class: 'cup-head' }, h('h2', {}, t('tournament.new')),
|
|
492
|
+
h('p', { class: 'muted' }, tn('tournament.wizard.text'))),
|
|
493
|
+
tabs, panel, nav));
|
|
494
|
+
this.restoreFocus(memo);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// What the focused control of the main part is, by its id, its focus key or its accessible
|
|
498
|
+
// name, so the same control takes the focus again once the part is drawn anew.
|
|
499
|
+
focusMemo() {
|
|
500
|
+
const node = document.activeElement;
|
|
501
|
+
if (!node || node === this.mainHost || !this.mainHost.contains(node)) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
return { id: node.id || null, focus: node.dataset.focus || null, label: node.getAttribute('aria-label') };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Focuses the control that `memo` names, or the tab of the current step when it is gone or
|
|
508
|
+
// can no longer be used, so the focus never falls back to the start of the page.
|
|
509
|
+
restoreFocus(memo) {
|
|
510
|
+
if (!memo) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const usable = (node) => node && !node.disabled && node.isConnected;
|
|
514
|
+
const all = [...this.mainHost.querySelectorAll('button, input, select, textarea, [tabindex]')];
|
|
515
|
+
const found = (memo.id && document.getElementById(memo.id))
|
|
516
|
+
|| (memo.focus && all.find((node) => node.dataset.focus === memo.focus))
|
|
517
|
+
|| (memo.label && all.find((node) => node.getAttribute('aria-label') === memo.label));
|
|
518
|
+
const target = usable(found) ? found : document.getElementById(`cup-step-${this.step}`);
|
|
519
|
+
target?.focus();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
stepKeys(e, index) {
|
|
523
|
+
const moves = { ArrowRight: 1, ArrowLeft: -1, Home: -index, End: STEPS.length - 1 - index };
|
|
524
|
+
if (e.key in moves) {
|
|
525
|
+
e.preventDefault();
|
|
526
|
+
const next = Math.max(0, Math.min(STEPS.length - 1, index + moves[e.key]));
|
|
527
|
+
this.goStep(next);
|
|
528
|
+
document.getElementById(`cup-step-${next}`)?.focus();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Opens the Stages step at the scenario of a stage, for a person to choose another.
|
|
533
|
+
focusStageScenario(index, decider = false) {
|
|
534
|
+
this.goStep(3);
|
|
535
|
+
const field = t(decider ? 'tournament.field.decider_scenario' : 'tournament.field.scenario');
|
|
536
|
+
const label = t('tournament.stage.fieldLabel', { label: field, number: index + 1 });
|
|
537
|
+
const stage = this.mainHost.querySelectorAll('.cup-stage')[index];
|
|
538
|
+
const control = stage && [...stage.querySelectorAll('select, button, input')]
|
|
539
|
+
.find((node) => (node.getAttribute('aria-label') || '').startsWith(label));
|
|
540
|
+
stage?.scrollIntoView({ block: 'center' });
|
|
541
|
+
control?.focus();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Opens a step, and with `focusTab` or from a tab focuses the tab of the step, as Next and Back
|
|
545
|
+
// do, so a screen reader names the step that opened.
|
|
546
|
+
goStep(index, focusTab = false) {
|
|
547
|
+
this.step = index;
|
|
548
|
+
const fromTab = document.activeElement && document.activeElement.classList.contains('cup-step');
|
|
549
|
+
this.renderWizard();
|
|
550
|
+
if (focusTab || fromTab) {
|
|
551
|
+
document.getElementById(`cup-step-${index}`)?.focus();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
cancelWizard() {
|
|
556
|
+
this.mode = this.openId ? 'view' : 'empty';
|
|
557
|
+
this.draft = null;
|
|
558
|
+
this.render();
|
|
559
|
+
if (this.mode === 'view') {
|
|
560
|
+
this.renderView();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
formatStep() {
|
|
565
|
+
const entries = Object.entries(this.presets);
|
|
566
|
+
// A preset gives its format and stages, and the settings that the person set by hand stay
|
|
567
|
+
// where it leaves them free. Where it sets its own, a notice names them and offers the
|
|
568
|
+
// person's back.
|
|
569
|
+
const choose = (name) => {
|
|
570
|
+
const preset = this.presets[name];
|
|
571
|
+
const draft = this.draft;
|
|
572
|
+
const { values, replaced } = presetDraft(draft, preset, draft.own);
|
|
573
|
+
Object.assign(draft, values);
|
|
574
|
+
draft.preset = name;
|
|
575
|
+
this.replaced = replaced.length ? { name, items: replaced } : null;
|
|
576
|
+
applyPreset(draft, preset);
|
|
577
|
+
if (!draft.name || Object.entries(this.presets).some(([key, p]) => presetTitle(key, p) === draft.name)) {
|
|
578
|
+
draft.name = presetTitle(name, preset);
|
|
579
|
+
}
|
|
580
|
+
this.renderWizard();
|
|
581
|
+
this.mainHost.querySelector(`[data-preset="${name}"]`)?.focus();
|
|
582
|
+
};
|
|
583
|
+
// The presets are a group of radio buttons with one stop of the Tab key, the chosen one or
|
|
584
|
+
// else the first, and the arrow keys choose the next or the previous, Home and End the ends.
|
|
585
|
+
const names = entries.map(([name]) => name);
|
|
586
|
+
const stop = names.includes(this.draft.preset) ? this.draft.preset : names[0];
|
|
587
|
+
const keys = (e, index) => {
|
|
588
|
+
const moves = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1, Home: -index, End: names.length - 1 - index };
|
|
589
|
+
if (e.key in moves && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
const next = e.key === 'Home' || e.key === 'End' ? index + moves[e.key] : (index + moves[e.key] + names.length) % names.length;
|
|
592
|
+
choose(names[next]);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
const cards = entries.map(([name, preset], index) => {
|
|
596
|
+
const on = this.draft.preset === name;
|
|
597
|
+
return h('div', { class: `cup-preset${on ? ' active' : ''}` },
|
|
598
|
+
h('button', {
|
|
599
|
+
type: 'button', class: 'cup-preset-pick', role: 'radio', 'aria-checked': on ? 'true' : 'false', 'data-preset': name,
|
|
600
|
+
tabindex: name === stop ? '0' : '-1',
|
|
601
|
+
onclick: () => choose(name),
|
|
602
|
+
onkeydown: (e) => keys(e, index),
|
|
603
|
+
},
|
|
604
|
+
h('span', { class: 'cup-preset-title' }, presetTitle(name, preset)),
|
|
605
|
+
h('span', { class: 'cup-preset-text muted small' }, presetDescription(name, preset) || preset.stages.map(stageText).join(COMMA_THEN)),
|
|
606
|
+
h('span', { class: 'cup-preset-stages small' }, formatsText(preset.stages))),
|
|
607
|
+
preset.shipped ? null : this.deletePresetButton(name, preset));
|
|
608
|
+
});
|
|
609
|
+
return [
|
|
610
|
+
h('p', { class: 'muted' }, tn('tournament.formatStep.intro')),
|
|
611
|
+
this.draft.own.size ? h('p', { class: 'muted small' }, t('tournament.formatStep.kept.note')) : null,
|
|
612
|
+
this.replacedNotice(),
|
|
613
|
+
h('div', { class: 'cup-presets', role: 'radiogroup', 'aria-label': t('tournament.formatStep.label') }, cards),
|
|
614
|
+
];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// The notice of the settings of the person that the chosen preset set in their place, with
|
|
618
|
+
// Keep mine, which gives them back, and the preset's own, which closes the notice.
|
|
619
|
+
replacedNotice() {
|
|
620
|
+
const replaced = this.replaced;
|
|
621
|
+
if (!replaced || replaced.name !== this.draft.preset || !this.presets[replaced.name]) {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
const title = presetTitle(replaced.name, this.presets[replaced.name]);
|
|
625
|
+
const settings = formatList(replaced.items.map((item) => t(`tournament.setting.${item.key}`)));
|
|
626
|
+
const pick = () => this.mainHost.querySelector('.cup-preset-pick[tabindex="0"]')?.focus();
|
|
627
|
+
return h('div', { class: 'notice cup-replaced', role: 'status' },
|
|
628
|
+
h('p', {}, icon('info', 'icon text-symbol'), t('tournament.formatStep.replaced', { title, settings })),
|
|
629
|
+
h('div', { class: 'row' },
|
|
630
|
+
h('button', { type: 'button', class: 'btn small primary', 'data-focus': 'keep-mine', onclick: () => {
|
|
631
|
+
for (const item of replaced.items) {
|
|
632
|
+
setSetting(this.draft, item.key, item.mine);
|
|
633
|
+
}
|
|
634
|
+
this.replaced = null;
|
|
635
|
+
this.renderWizard();
|
|
636
|
+
pick();
|
|
637
|
+
this.announce(t('tournament.formatStep.kept'));
|
|
638
|
+
} }, icon('undo'), t('tournament.formatStep.keep')),
|
|
639
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': 'use-preset', onclick: () => {
|
|
640
|
+
this.replaced = null;
|
|
641
|
+
this.renderWizard();
|
|
642
|
+
pick();
|
|
643
|
+
} }, icon('check'), t('tournament.formatStep.usePreset'))));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// The button that deletes a preset of the person, which asks first in place of the button, as
|
|
647
|
+
// Award and Delete of a tournament do. Escape or Keep it leaves the preset, and the focus
|
|
648
|
+
// returns to the button.
|
|
649
|
+
deletePresetButton(name, preset) {
|
|
650
|
+
const title = preset.title;
|
|
651
|
+
const button = h('button', {
|
|
652
|
+
type: 'button', class: 'btn small ghost danger', 'data-focus': `delete-preset-${name}`,
|
|
653
|
+
'aria-label': t('tournament.formatStep.delete.label', { title }),
|
|
654
|
+
}, icon('trash'), t('tournament.formatStep.delete'));
|
|
655
|
+
button.addEventListener('click', () => {
|
|
656
|
+
const back = () => {
|
|
657
|
+
ask.replaceWith(button);
|
|
658
|
+
button.focus();
|
|
659
|
+
};
|
|
660
|
+
const ask = this.confirmGroup(t('tournament.formatStep.delete.confirm', { title }),
|
|
661
|
+
[icon('trash'), t('tournament.formatStep.delete')], t('tournament.formatStep.delete.no'), async () => {
|
|
662
|
+
try {
|
|
663
|
+
await tournamentsApi.removePreset(name);
|
|
664
|
+
delete this.presets[name];
|
|
665
|
+
// A draft made from the deleted preset no longer names it.
|
|
666
|
+
if (this.draft.preset === name) {
|
|
667
|
+
this.draft.preset = null;
|
|
668
|
+
}
|
|
669
|
+
this.renderWizard();
|
|
670
|
+
this.mainHost.querySelector('.cup-preset-pick[tabindex="0"]')?.focus();
|
|
671
|
+
this.announce(t('tournament.formatStep.deleted', { title }));
|
|
672
|
+
} catch (err) {
|
|
673
|
+
back();
|
|
674
|
+
this.app.hud.toast(err.message, 'error');
|
|
675
|
+
}
|
|
676
|
+
}, back, true);
|
|
677
|
+
button.replaceWith(ask);
|
|
678
|
+
ask.querySelector('[data-answer="no"]').focus();
|
|
679
|
+
});
|
|
680
|
+
return button;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// A question in place of a button, with its yes and no, as the questions of a tournament
|
|
684
|
+
// show it. Escape answers no.
|
|
685
|
+
confirmGroup(question, yes, no, onYes, onNo, danger = false) {
|
|
686
|
+
return h('span', {
|
|
687
|
+
class: 'cup-confirm', role: 'group', 'aria-label': question,
|
|
688
|
+
onkeydown: (e) => {
|
|
689
|
+
if (e.key === 'Escape') {
|
|
690
|
+
e.preventDefault();
|
|
691
|
+
e.stopPropagation();
|
|
692
|
+
onNo();
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
h('span', { class: 'small' }, question),
|
|
697
|
+
h('button', { type: 'button', class: `btn small primary${danger ? ' danger' : ''}`, 'data-answer': 'yes', onclick: (e) => {
|
|
698
|
+
e.currentTarget.disabled = true;
|
|
699
|
+
onYes();
|
|
700
|
+
} }, ...[yes].flat()),
|
|
701
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-answer': 'no', onclick: onNo }, icon('close'), no));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
entrantsStep() {
|
|
705
|
+
const d = this.draft;
|
|
706
|
+
const teams = this.teams.filter((x) => x.kind !== 'builtin');
|
|
707
|
+
const teamSelect = h('select', { 'aria-label': t('tournament.entrants.team.label') },
|
|
708
|
+
teams.length ? teams.map((x) => h('option', { value: x.id }, teamOption(x))) : h('option', { value: '' }, t('tournament.entrants.team.none')));
|
|
709
|
+
// A team registered elsewhere since the step opened joins the list once the person comes
|
|
710
|
+
// to choose a team.
|
|
711
|
+
teamSelect.addEventListener('focus', () => this.refreshEntrants());
|
|
712
|
+
const ability = h('input', { type: 'number', min: '0', max: '1', step: '0.05', value: '0.5', 'aria-label': t('tournament.entrants.builtin.label') });
|
|
713
|
+
const person = h('input', { type: 'text', maxlength: '40', placeholder: t('tournament.entrants.name'), 'aria-label': t('tournament.entrants.person.label') });
|
|
714
|
+
const add = (entry) => {
|
|
715
|
+
if (d.entrants.length >= 256) {
|
|
716
|
+
this.app.hud.toast(t('tournament.entrants.full'), 'error');
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
d.entrants.push(entry);
|
|
720
|
+
this.renderWizard();
|
|
721
|
+
return true;
|
|
722
|
+
};
|
|
723
|
+
// A team whose name an entrant already shows, as when it enters again, enters under the
|
|
724
|
+
// first free name, such as Rex 2, which its row then lets a person change.
|
|
725
|
+
// The select keeps the team added last, so the next one is a step away.
|
|
726
|
+
if (teams.some((x) => x.id === this.lastTeam)) {
|
|
727
|
+
teamSelect.value = this.lastTeam;
|
|
728
|
+
}
|
|
729
|
+
teamSelect.addEventListener('change', () => {
|
|
730
|
+
this.lastTeam = teamSelect.value;
|
|
731
|
+
});
|
|
732
|
+
const addTeam = (id) => {
|
|
733
|
+
this.lastTeam = id;
|
|
734
|
+
const team = teams.find((x) => x.id === id);
|
|
735
|
+
const entry = { kind: 'team', team: id };
|
|
736
|
+
const taken = this.takenNames();
|
|
737
|
+
if (team && taken.has(nameKey(team.name))) {
|
|
738
|
+
entry.name = freeName(team.name, taken);
|
|
739
|
+
}
|
|
740
|
+
if (add(entry) && entry.name) {
|
|
741
|
+
this.app.hud.toast(t('tournament.entrants.renamed', { team: team.name, name: entry.name }), 'info', 6000, 'edit');
|
|
742
|
+
this.mainHost.querySelector('.cup-entrants > li:last-child .cup-entrant-name input')?.focus();
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
const adders = h('div', { class: 'cup-adders' },
|
|
746
|
+
h('div', { class: 'cup-adder' }, h('label', { class: 'field' }, t('tournament.entrants.team'), teamSelect),
|
|
747
|
+
h('button', { type: 'button', class: 'btn', disabled: !teams.length, ...symbolOnly(t('tournament.entrants.team.add')), onclick: () => addTeam(teamSelect.value) }, icon('plus')),
|
|
748
|
+
h('button', { type: 'button', class: 'btn ghost', 'data-focus': 'add-all', disabled: !teams.length, onclick: () => {
|
|
749
|
+
const have = new Set(d.entrants.filter((e) => e.kind === 'team').map((e) => e.team));
|
|
750
|
+
const missing = teams.filter((team) => !have.has(team.id));
|
|
751
|
+
if (!missing.length) {
|
|
752
|
+
this.app.hud.toast(t('tournament.entrants.team.allIn'), 'info');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// The list stops at the most entrants a tournament takes, and says so.
|
|
756
|
+
const room = 256 - d.entrants.length;
|
|
757
|
+
for (const x of missing.slice(0, Math.max(0, room))) {
|
|
758
|
+
const taken = this.takenNames();
|
|
759
|
+
d.entrants.push(taken.has(nameKey(x.name)) ? { kind: 'team', team: x.id, name: freeName(x.name, taken) } : { kind: 'team', team: x.id });
|
|
760
|
+
}
|
|
761
|
+
if (missing.length > room) {
|
|
762
|
+
this.app.hud.toast(t('tournament.entrants.full'), 'error');
|
|
763
|
+
}
|
|
764
|
+
this.renderWizard();
|
|
765
|
+
} }, icon('plus'), t('tournament.entrants.team.addAll'))),
|
|
766
|
+
h('div', { class: 'cup-adder' }, h('label', { class: 'field' }, t('tournament.entrants.builtin'), ability),
|
|
767
|
+
h('button', { type: 'button', class: 'btn', ...symbolOnly(t('tournament.entrants.builtin.add')), onclick: () => {
|
|
768
|
+
// An empty box reads as no ability at all, never as 0.
|
|
769
|
+
const value = ability.value.trim() === '' ? NaN : Number(ability.value);
|
|
770
|
+
if (!(value >= 0 && value <= 1)) {
|
|
771
|
+
this.app.hud.toast(t('tournament.entrants.builtin.range'), 'error');
|
|
772
|
+
ability.focus();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const entry = { kind: 'builtin', ability: Math.round(value * 10000) / 10000 };
|
|
776
|
+
const base = this.shownName(entry);
|
|
777
|
+
if (add(entry)) {
|
|
778
|
+
// The built-in AI at an ability that an entrant already shows enters under
|
|
779
|
+
// the next number, which the server gives it, as a team entered again does.
|
|
780
|
+
const made = this.madeNames().at(-1);
|
|
781
|
+
if (made !== base) {
|
|
782
|
+
this.app.hud.toast(t('tournament.entrants.renamed', { team: base, name: made }), 'info', 6000, 'edit');
|
|
783
|
+
this.mainHost.querySelector('.cup-entrants > li:last-child .cup-entrant-name input')?.focus();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
} }, icon('plus'))),
|
|
787
|
+
h('div', { class: 'cup-adder' }, h('label', { class: 'field' }, t('tournament.entrants.person'), person),
|
|
788
|
+
h('button', { type: 'button', class: 'btn cup-add-person', ...symbolOnly(t('tournament.entrants.person.add')), onclick: () => {
|
|
789
|
+
const name = person.value.trim();
|
|
790
|
+
if (!name) {
|
|
791
|
+
this.app.hud.toast(t('tournament.entrants.person.noName'), 'error');
|
|
792
|
+
person.focus();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (this.takenNames().has(nameKey(name))) {
|
|
796
|
+
this.app.hud.toast(t('tournament.entrants.person.taken', { name }), 'error', 6000, 'warning');
|
|
797
|
+
person.focus();
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
add({ kind: 'person', name, teammates: 'builtin' });
|
|
801
|
+
} }, icon('plus'))),
|
|
802
|
+
randomAdder(d, add, (text) => this.app.hud.toast(text, 'info')));
|
|
803
|
+
// Enter in the name of a person adds the person, as the button beside it does.
|
|
804
|
+
person.addEventListener('keydown', (e) => {
|
|
805
|
+
if (e.key === 'Enter' && !e.isComposing) {
|
|
806
|
+
e.preventDefault();
|
|
807
|
+
adders.querySelector('.cup-add-person')?.click();
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
const rows = d.entrants.map((entry, index) => this.entrantRow(entry, index));
|
|
811
|
+
queueMicrotask(() => this.markNames());
|
|
812
|
+
// The draw is random from the tournament seed, unless the person sets the bracket by hand,
|
|
813
|
+
// which seeds the entrants in the order of this list, the first as seed 1.
|
|
814
|
+
const manual = h('input', { type: 'checkbox', class: 'cup-manual', checked: d.seeding === 'manual' });
|
|
815
|
+
const drawNote = h('span', { class: 'muted small cup-draw-note', id: 'cup-draw-note' });
|
|
816
|
+
const showDraw = () => {
|
|
817
|
+
drawNote.textContent = t(d.seeding === 'manual' ? 'tournament.entrants.draw.manual' : d.seeding === 'rating' ? 'tournament.entrants.draw.rating' : 'tournament.entrants.draw.random');
|
|
818
|
+
};
|
|
819
|
+
manual.setAttribute('aria-describedby', 'cup-draw-note');
|
|
820
|
+
manual.addEventListener('change', () => {
|
|
821
|
+
d.seeding = manual.checked ? 'manual' : 'random';
|
|
822
|
+
d.own.add('seeding');
|
|
823
|
+
showDraw();
|
|
824
|
+
});
|
|
825
|
+
showDraw();
|
|
826
|
+
return [
|
|
827
|
+
h('p', { class: 'muted' }, tn('tournament.entrants.intro', { count: d.entrants.length })),
|
|
828
|
+
h('div', { class: 'cup-draw' }, h('label', { class: 'check' }, manual, t('tournament.entrants.draw.byHand')), drawNote),
|
|
829
|
+
adders,
|
|
830
|
+
d.entrants.length ? h('ol', { class: 'cup-entrants' }, rows) : h('p', { class: 'muted pad' }, t('tournament.entrants.empty')),
|
|
831
|
+
];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
entrantRow(entry, index) {
|
|
835
|
+
const d = this.draft;
|
|
836
|
+
const team = entry.kind === 'team' ? this.teams.find((x) => x.id === entry.team) : null;
|
|
837
|
+
const fallback = entry.kind === 'team' ? (team ? team.name : entry.team)
|
|
838
|
+
: entry.kind === 'builtin' ? (entry.ability == null ? t('tournament.entrant.builtin') : t('tournament.entrant.builtinAt', { ability: abilityText(entry.ability) }))
|
|
839
|
+
: entry.kind === 'random' ? randomName() : '';
|
|
840
|
+
const number = index + 1;
|
|
841
|
+
const noteId = `cup-name-note-${index}`;
|
|
842
|
+
const name = h('input', { type: 'text', maxlength: String(NAME_LONGEST), value: entry.name || '', placeholder: fallback || t('tournament.entrants.name'), 'aria-label': t('tournament.entrant.name.label', { number }), 'aria-describedby': noteId });
|
|
843
|
+
name.addEventListener('input', () => {
|
|
844
|
+
entry.name = name.value.trim() || undefined;
|
|
845
|
+
this.markNames();
|
|
846
|
+
});
|
|
847
|
+
const note = h('span', { class: 'cup-name-note small', id: noteId, role: 'status' });
|
|
848
|
+
const held = this.ratings[ratingKey(entry)];
|
|
849
|
+
const rating = h('input', {
|
|
850
|
+
type: 'number', min: '0', max: '10000', step: '1', value: entry.rating ?? '', placeholder: String(Math.round(held ? held.rating : 1500)),
|
|
851
|
+
title: t('tournament.entrant.rating.title'), 'aria-label': t('tournament.entrant.rating.label', { number }),
|
|
852
|
+
});
|
|
853
|
+
rating.addEventListener('input', () => {
|
|
854
|
+
entry.rating = rating.value === '' ? undefined : Number(rating.value);
|
|
855
|
+
});
|
|
856
|
+
const extra = [];
|
|
857
|
+
// Every entrant may carry an ability, which the built-in AI players of its side play with.
|
|
858
|
+
// Left empty, it follows the stage's or the standard, which the placeholder names.
|
|
859
|
+
const followed = d.ability === null || d.ability === undefined ? t('tournament.entrant.ability.placeholder')
|
|
860
|
+
: t('tournament.entrant.ability.standard', { ability: abilityText(d.ability) });
|
|
861
|
+
const ability = h('input', {
|
|
862
|
+
type: 'number', class: 'cup-entrant-ability', min: '0', max: '1', step: '0.05', value: entry.ability ?? '', placeholder: followed,
|
|
863
|
+
title: t('tournament.entrant.ability.title'), 'aria-label': t('tournament.entrant.ability.label', { number }),
|
|
864
|
+
});
|
|
865
|
+
ability.addEventListener('input', () => {
|
|
866
|
+
entry.ability = ability.value === '' ? null : Number(ability.value);
|
|
867
|
+
// The made name of the built-in AI holds its ability, so the names follow it.
|
|
868
|
+
if (entry.kind === 'builtin') {
|
|
869
|
+
this.markNames();
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
const abilityField = h('label', { class: 'field small-field' }, t('tournament.entrant.ability'), ability);
|
|
873
|
+
if (entry.kind === 'person') {
|
|
874
|
+
const dynamic = this.teams.filter((x) => x.kind !== 'builtin' && x.control && x.control.assignment === 'dynamic');
|
|
875
|
+
const mates = h('select', { 'aria-label': t('tournament.entrant.teammates.label', { number }) },
|
|
876
|
+
h('option', { value: 'builtin' }, t('tournament.entrant.builtin')), dynamic.map((x) => h('option', { value: x.id }, teamOption(x))));
|
|
877
|
+
mates.value = entry.teammates || 'builtin';
|
|
878
|
+
mates.addEventListener('change', () => {
|
|
879
|
+
entry.teammates = mates.value;
|
|
880
|
+
});
|
|
881
|
+
extra.push(h('label', { class: 'field small-field' }, t('tournament.entrant.teammates'), mates));
|
|
882
|
+
} else {
|
|
883
|
+
extra.push(h('span', { class: 'cup-entrant-extra', 'aria-hidden': 'true' }));
|
|
884
|
+
}
|
|
885
|
+
extra.push(abilityField);
|
|
886
|
+
const move = (by) => {
|
|
887
|
+
const to = index + by;
|
|
888
|
+
if (to < 0 || to >= d.entrants.length) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
[d.entrants[index], d.entrants[to]] = [d.entrants[to], d.entrants[index]];
|
|
892
|
+
this.renderWizard();
|
|
893
|
+
// The moved entrant keeps the focus on the button that moved it, or on the other one
|
|
894
|
+
// once it reaches either end of the list, and its new place is said.
|
|
895
|
+
const row = this.mainHost.querySelectorAll('.cup-entrants > li')[to];
|
|
896
|
+
const buttons = row ? [row.querySelector(by < 0 ? '.cup-up' : '.cup-down'), row.querySelector(by < 0 ? '.cup-down' : '.cup-up')] : [];
|
|
897
|
+
buttons.find((button) => button && !button.disabled)?.focus();
|
|
898
|
+
this.announce(t('tournament.moved', { name: this.madeNames()[to] || '', place: to + 1, total: d.entrants.length }));
|
|
899
|
+
};
|
|
900
|
+
return h('li', { class: 'cup-entrant' },
|
|
901
|
+
team ? kitShirt(team, 0) : h('span', { class: `cup-kind ${entry.kind}`, 'aria-hidden': 'true' }, kindGlyph(entry.kind)),
|
|
902
|
+
// The shirt marks a team, so its row names the team, with its kind for a screen
|
|
903
|
+
// reader alone, and its size under the name.
|
|
904
|
+
h('span', { class: 'cup-entrant-kind small muted' }, team ? [h('span', { class: 'sr-only' }, `${KIND_NAMES.team}, `), team.name] : KIND_NAMES[entry.kind],
|
|
905
|
+
team ? h('span', { class: 'cup-entrant-size' }, teamSize(team)) : null),
|
|
906
|
+
h('span', { class: 'cup-entrant-name' }, h('label', { class: 'field' }, h('span', {}, icon('edit', 'icon text-symbol'), t('tournament.entrants.name')), name), note), ...extra,
|
|
907
|
+
h('label', { class: 'field small-field' }, t('tournament.entrant.rating'), rating),
|
|
908
|
+
h('span', { class: 'cup-entrant-actions' },
|
|
909
|
+
h('button', { type: 'button', class: 'btn small ghost cup-up', ...symbolOnly(t('tournament.entrant.up.label', { number })), disabled: index === 0, onclick: () => move(-1) }, icon('up')),
|
|
910
|
+
h('button', { type: 'button', class: 'btn small ghost cup-down', ...symbolOnly(t('tournament.entrant.down.label', { number })), disabled: index === d.entrants.length - 1, onclick: () => move(1) }, icon('down')),
|
|
911
|
+
h('button', { type: 'button', class: 'btn small ghost danger cup-remove', ...symbolOnly(t('tournament.entrant.remove.label', { number })), onclick: () => {
|
|
912
|
+
d.entrants.splice(index, 1);
|
|
913
|
+
removedEntrant(d, entry);
|
|
914
|
+
this.renderWizard();
|
|
915
|
+
// The focus moves to the entrant that takes the place, else the one before it,
|
|
916
|
+
// else to the first way to add an entrant.
|
|
917
|
+
const rows = this.mainHost.querySelectorAll('.cup-entrants > li');
|
|
918
|
+
const row = rows[Math.min(index, rows.length - 1)];
|
|
919
|
+
(row ? row.querySelector('.cup-remove') : this.mainHost.querySelector('.cup-adder select, .cup-adder input'))?.focus();
|
|
920
|
+
} }, icon('close'))));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// The name that an entrant of the draft shows, the one a person gave, else its team's or
|
|
924
|
+
// its kind's.
|
|
925
|
+
shownName(entry) {
|
|
926
|
+
if (entry.name) {
|
|
927
|
+
return entry.name;
|
|
928
|
+
}
|
|
929
|
+
if (entry.kind === 'team') {
|
|
930
|
+
const team = this.teams.find((x) => x.id === entry.team);
|
|
931
|
+
return team ? team.name : entry.team;
|
|
932
|
+
}
|
|
933
|
+
if (entry.kind === 'builtin') {
|
|
934
|
+
return entry.ability == null ? t('tournament.entrant.builtin') : t('tournament.entrant.builtinAt', { ability: abilityText(entry.ability) });
|
|
935
|
+
}
|
|
936
|
+
return entry.kind === 'random' ? randomName() : '';
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// The name that each entrant of the draft takes, the one a person gave, else the one the
|
|
940
|
+
// server makes from its team or kind, numbered from 2 when an entrant already holds it.
|
|
941
|
+
madeNames() {
|
|
942
|
+
return madeNames(this.draft.entrants.map((e) => ({ name: this.shownName(e), own: !!e.name })));
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// The keys of the names that the entrants of the draft show.
|
|
946
|
+
takenNames() {
|
|
947
|
+
return new Set(this.draft.entrants.map((e) => nameKey(this.shownName(e))).filter(Boolean));
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The entrant whose name the given name of entrant `index` repeats, or -1. A given name
|
|
951
|
+
// repeats no other given name, which the server refuses, nor the name an earlier entrant takes
|
|
952
|
+
// from its team or kind, which the server would number in its place.
|
|
953
|
+
nameClash(index) {
|
|
954
|
+
const entries = this.draft.entrants;
|
|
955
|
+
const own = entries[index];
|
|
956
|
+
if (!own || !own.name) {
|
|
957
|
+
return -1;
|
|
958
|
+
}
|
|
959
|
+
const key = nameKey(own.name);
|
|
960
|
+
return entries.findIndex((e, j) => j !== index && (e.name || j < index) && nameKey(this.shownName(e)) === key);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Each entrant whose given name repeats another, with the entrant it repeats.
|
|
964
|
+
nameClashes() {
|
|
965
|
+
return this.draft.entrants.map((entry, index) => ({ index, other: this.nameClash(index), name: entry.name }))
|
|
966
|
+
.filter((clash) => clash.other >= 0);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Marks each name of the entrants step that repeats another, with the reason beside it.
|
|
970
|
+
markNames() {
|
|
971
|
+
if (!this.draft || this.mode !== 'wizard') {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const made = this.madeNames();
|
|
975
|
+
this.mainHost.querySelectorAll('.cup-entrants > li').forEach((row, index) => {
|
|
976
|
+
const input = row.querySelector('.cup-entrant-name input');
|
|
977
|
+
const note = row.querySelector('.cup-name-note');
|
|
978
|
+
if (!input || !note) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
// A field left empty shows the name that the entrant takes, as the server makes it.
|
|
982
|
+
if (made[index] && input.placeholder !== made[index]) {
|
|
983
|
+
input.placeholder = made[index];
|
|
984
|
+
}
|
|
985
|
+
const other = this.nameClash(index);
|
|
986
|
+
const text = other >= 0 ? t('tournament.entrant.name.taken', { name: this.draft.entrants[index].name, other: other + 1 }) : '';
|
|
987
|
+
if (other >= 0) {
|
|
988
|
+
input.setAttribute('aria-invalid', 'true');
|
|
989
|
+
} else {
|
|
990
|
+
input.removeAttribute('aria-invalid');
|
|
991
|
+
}
|
|
992
|
+
// A name that repeats another shows the symbol of a warning before its reason.
|
|
993
|
+
if (note.textContent !== text) {
|
|
994
|
+
note.replaceChildren(...(text ? [icon('warning', 'icon text-symbol'), text] : []));
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
settingsStep() {
|
|
1000
|
+
const d = this.draft;
|
|
1001
|
+
const name = h('input', { type: 'text', maxlength: '80', value: d.name, placeholder: t('server.tournament.label.tournament'), 'aria-label': t('tournament.settings.name.label') });
|
|
1002
|
+
name.addEventListener('input', () => {
|
|
1003
|
+
d.name = name.value;
|
|
1004
|
+
});
|
|
1005
|
+
const seed = h('input', { type: 'text', inputmode: 'numeric', value: d.seed, placeholder: t('tournament.settings.seed.placeholder'), 'aria-label': t('tournament.settings.seed.label') });
|
|
1006
|
+
seed.addEventListener('input', () => {
|
|
1007
|
+
d.seed = seed.value.trim();
|
|
1008
|
+
});
|
|
1009
|
+
// Each setting that the person sets here is theirs, which a preset chosen afterwards
|
|
1010
|
+
// keeps where it leaves the setting free.
|
|
1011
|
+
const seeding = this.select([['random', RANDOM_SEEDING], ['manual', t('tournament.seeding.manual')], ['rating', t('tournament.seeding.rating')]], d.seeding, t('tournament.settings.seeding'), (v) => {
|
|
1012
|
+
d.seeding = v;
|
|
1013
|
+
d.own.add('seeding');
|
|
1014
|
+
});
|
|
1015
|
+
const concurrency = h('input', { type: 'number', min: '1', max: '8', step: '1', value: String(d.concurrency), 'aria-label': t('tournament.settings.concurrency.label') });
|
|
1016
|
+
concurrency.addEventListener('input', () => {
|
|
1017
|
+
d.concurrency = Number(concurrency.value);
|
|
1018
|
+
d.own.add('concurrency');
|
|
1019
|
+
});
|
|
1020
|
+
const forfeit = h('input', { type: 'number', min: '1', max: '10', step: '1', value: String(d.forfeit_score), 'aria-label': t('tournament.settings.forfeit.label') });
|
|
1021
|
+
forfeit.addEventListener('input', () => {
|
|
1022
|
+
d.forfeit_score = Number(forfeit.value);
|
|
1023
|
+
d.own.add('forfeit_score');
|
|
1024
|
+
});
|
|
1025
|
+
const scenario = this.scenarioSelect(d.defaults.scenario ?? null, t('tournament.settings.scenario'), BY_ENTRANTS, (v) => {
|
|
1026
|
+
setSetting(d, 'defaults.scenario', v);
|
|
1027
|
+
d.own.add('defaults.scenario');
|
|
1028
|
+
});
|
|
1029
|
+
const length = this.optionalNumber({ key: 'length', min: 50, max: 5000, step: 50, initial: 600, off: OFF_SCENARIO, label: t('tournament.field.length') }, d.defaults.length ?? null, (v) => {
|
|
1030
|
+
setSetting(d, 'defaults.length', v);
|
|
1031
|
+
d.own.add('defaults.length');
|
|
1032
|
+
});
|
|
1033
|
+
const defaults = d.defaults;
|
|
1034
|
+
return h('div', { class: 'cup-form' },
|
|
1035
|
+
h('label', { class: 'field' }, t('tournament.settings.name'), name),
|
|
1036
|
+
h('label', { class: 'field' }, t('tournament.settings.seed'),
|
|
1037
|
+
h('span', { class: 'row' }, seed, h('button', { type: 'button', class: 'btn ghost', ...symbolOnly(t('tournament.settings.seed.draw')), onclick: () => {
|
|
1038
|
+
seed.value = String(crypto.getRandomValues(new Uint32Array(1))[0]);
|
|
1039
|
+
d.seed = seed.value;
|
|
1040
|
+
} }, icon('dice')))),
|
|
1041
|
+
h('p', { class: 'muted small' }, tn('tournament.settings.seed.note')),
|
|
1042
|
+
h('label', { class: 'field' }, t('tournament.settings.seeding'), seeding),
|
|
1043
|
+
h('div', { class: 'field' }, h('span', {}, t('tournament.settings.scenario')), scenario, h('span', { class: 'muted small' }, scenarioText(defaults.scenario || ''))),
|
|
1044
|
+
h('div', { class: 'field' }, h('span', {}, t('tournament.settings.length')), length),
|
|
1045
|
+
this.standardControl(),
|
|
1046
|
+
h('label', { class: 'field' }, t('tournament.settings.concurrency'), concurrency),
|
|
1047
|
+
h('label', { class: 'field' }, t('tournament.settings.forfeit'), forfeit),
|
|
1048
|
+
strictFields(d),
|
|
1049
|
+
h('p', { class: 'muted small' }, tn('tournament.settings.note')));
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
select(options, value, label, onChange) {
|
|
1053
|
+
const select = h('select', { 'aria-label': label }, options.map(([v, text]) => h('option', { value: v }, text)));
|
|
1054
|
+
select.value = value;
|
|
1055
|
+
select.addEventListener('change', () => onChange(select.value));
|
|
1056
|
+
return select;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
scenarioSelect(value, label, empty, onChange) {
|
|
1060
|
+
const options = [...(empty ? [['', empty]] : []), ...this.scenarios.map((s) => [s, scenarioName(s)])];
|
|
1061
|
+
if (value && !this.scenarios.includes(value)) {
|
|
1062
|
+
options.push([value, scenarioName(value)]);
|
|
1063
|
+
}
|
|
1064
|
+
const select = this.select(options, value ?? '', label, (v) => onChange(v === '' ? null : v));
|
|
1065
|
+
return select;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// The standard ability of the tournament, which every entrant and stage that sets none plays
|
|
1069
|
+
// with, a slider with its value beside it and a box that sets it. Left clear, the scenario's
|
|
1070
|
+
// own ability plays, as the value beside the slider says.
|
|
1071
|
+
standardControl() {
|
|
1072
|
+
const d = this.draft;
|
|
1073
|
+
const label = t('tournament.settings.ability');
|
|
1074
|
+
const on = d.ability !== null && d.ability !== undefined;
|
|
1075
|
+
const box = h('input', { type: 'checkbox', class: 'cup-standard-set', checked: on, 'aria-label': t('tournament.optional.set.label', { label }) });
|
|
1076
|
+
const range = h('input', { type: 'range', class: 'cup-standard-range', min: '0', max: '1', step: '0.05', value: String(on ? d.ability : 0.5), disabled: !on, 'aria-label': label });
|
|
1077
|
+
const shown = h('output', { class: 'ability-value' });
|
|
1078
|
+
const show = () => {
|
|
1079
|
+
shown.textContent = box.checked ? abilityText(d.ability) : OFF_SCENARIO;
|
|
1080
|
+
range.setAttribute('aria-valuetext', shown.textContent);
|
|
1081
|
+
};
|
|
1082
|
+
const sync = () => {
|
|
1083
|
+
range.disabled = !box.checked;
|
|
1084
|
+
d.ability = box.checked ? Math.round(Number(range.value) * 10000) / 10000 : null;
|
|
1085
|
+
d.own.add('ability');
|
|
1086
|
+
show();
|
|
1087
|
+
};
|
|
1088
|
+
box.addEventListener('change', sync);
|
|
1089
|
+
range.addEventListener('input', sync);
|
|
1090
|
+
show();
|
|
1091
|
+
return h('div', { class: 'field cup-standard' }, h('span', {}, label),
|
|
1092
|
+
h('span', { class: 'cup-optional ability' }, h('label', { class: 'check' }, box, t('tournament.optional.set')), range, shown),
|
|
1093
|
+
h('span', { class: 'muted small' }, t('tournament.settings.ability.help')));
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// A number that may be left to its default, a box that turns it on and the number itself.
|
|
1097
|
+
optionalNumber(field, value, onChange) {
|
|
1098
|
+
const on = value !== null && value !== undefined;
|
|
1099
|
+
const box = h('input', { type: 'checkbox', checked: on, 'aria-label': t('tournament.optional.set.label', { label: field.label }) });
|
|
1100
|
+
const input = h('input', {
|
|
1101
|
+
type: 'number', min: String(field.min), max: String(field.max), step: String(field.step || 1),
|
|
1102
|
+
value: String(on ? value : field.initial), disabled: !on, 'aria-label': field.label,
|
|
1103
|
+
});
|
|
1104
|
+
const shown = h('span', { class: 'muted small' }, on ? '' : field.off);
|
|
1105
|
+
const sync = () => {
|
|
1106
|
+
input.disabled = !box.checked;
|
|
1107
|
+
shown.textContent = box.checked ? '' : field.off;
|
|
1108
|
+
onChange(box.checked ? Number(input.value) : null);
|
|
1109
|
+
};
|
|
1110
|
+
box.addEventListener('change', sync);
|
|
1111
|
+
input.addEventListener('input', sync);
|
|
1112
|
+
return h('span', { class: 'cup-optional' }, h('label', { class: 'check' }, box, t('tournament.optional.set')), input, shown);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
stagesStep() {
|
|
1116
|
+
const d = this.draft;
|
|
1117
|
+
const stages = d.stages.map((stage, index) => this.stageCard(stage, index));
|
|
1118
|
+
const json = h('textarea', { class: 'cup-json', rows: '14', spellcheck: 'false', 'aria-label': t('tournament.json.label') },
|
|
1119
|
+
JSON.stringify({ ability: d.ability ?? null, defaults: d.defaults, stages: d.stages }, null, 2));
|
|
1120
|
+
// The note of a mistake stays in place and empty until there is one, so a screen reader
|
|
1121
|
+
// hears it.
|
|
1122
|
+
const applied = h('p', { class: 'small cup-json-note', id: 'cup-json-note', role: 'status' });
|
|
1123
|
+
json.setAttribute('aria-describedby', 'cup-json-note');
|
|
1124
|
+
json.addEventListener('input', () => {
|
|
1125
|
+
json.removeAttribute('aria-invalid');
|
|
1126
|
+
applied.replaceChildren();
|
|
1127
|
+
});
|
|
1128
|
+
const refuse = (text, at = null) => {
|
|
1129
|
+
json.setAttribute('aria-invalid', 'true');
|
|
1130
|
+
applied.replaceChildren(icon('warning', 'icon text-symbol'), text);
|
|
1131
|
+
// The caret goes to the mistake, so the person fixes it where it is.
|
|
1132
|
+
if (at !== null) {
|
|
1133
|
+
json.focus();
|
|
1134
|
+
json.setSelectionRange(at, at);
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
const apply = h('button', { type: 'button', class: 'btn', ...symbolOnly(t('tournament.json.apply')), onclick: () => {
|
|
1138
|
+
let value;
|
|
1139
|
+
try {
|
|
1140
|
+
value = JSON.parse(json.value);
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
const place = jsonErrorPlace(json.value, err);
|
|
1143
|
+
if (!place) {
|
|
1144
|
+
refuse(t('tournament.json.unreadable'));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const lines = json.value.split('\n');
|
|
1148
|
+
const at = lines.slice(0, place.line - 1).reduce((sum, line) => sum + line.length + 1, 0) + place.column - 1;
|
|
1149
|
+
refuse(t('tournament.json.failed', place), Math.min(at, json.value.length));
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (!value || typeof value !== 'object' || !Array.isArray(value.stages)) {
|
|
1153
|
+
refuse(t('tournament.json.expected'));
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
d.stages = value.stages;
|
|
1157
|
+
d.defaults = value.defaults && typeof value.defaults === 'object' ? value.defaults : {};
|
|
1158
|
+
d.ability = value.ability ?? null;
|
|
1159
|
+
this.renderWizard();
|
|
1160
|
+
this.app.hud.toast(t('tournament.json.applied'));
|
|
1161
|
+
} }, icon('check'));
|
|
1162
|
+
// The editor of JSON stays open through the redraws that applying it makes.
|
|
1163
|
+
const jsonBox = h('details', { class: 'cup-json-box', open: !!this.jsonOpen }, h('summary', {}, t('tournament.json.edit')), json, h('div', { class: 'row' }, apply), applied);
|
|
1164
|
+
jsonBox.addEventListener('toggle', () => {
|
|
1165
|
+
this.jsonOpen = jsonBox.open;
|
|
1166
|
+
});
|
|
1167
|
+
return [
|
|
1168
|
+
h('p', { class: 'muted' }, tn('tournament.stages.intro')),
|
|
1169
|
+
h('div', { class: 'cup-form' }, this.standardControl()),
|
|
1170
|
+
h('div', { class: 'cup-stages' }, stages),
|
|
1171
|
+
h('button', { type: 'button', class: 'btn', 'data-focus': 'add-stage', disabled: d.stages.length >= 8, ...symbolOnly(t('tournament.stages.add')), onclick: () => {
|
|
1172
|
+
d.stages.push({ format: 'knockout', name: '' });
|
|
1173
|
+
this.renderWizard();
|
|
1174
|
+
// The new stage opens at its first field, its name.
|
|
1175
|
+
[...this.mainHost.querySelectorAll('.cup-stage')].at(-1)?.querySelector('input, select')?.focus();
|
|
1176
|
+
} }, icon('plus')),
|
|
1177
|
+
jsonBox,
|
|
1178
|
+
];
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
stageCard(stage, index) {
|
|
1182
|
+
const d = this.draft;
|
|
1183
|
+
const last = index === d.stages.length - 1;
|
|
1184
|
+
const format = stage.format || 'knockout';
|
|
1185
|
+
const merged = { ...STAGE_DEFAULTS, ...d.defaults, ...stage };
|
|
1186
|
+
const rows = STAGE_FIELDS.filter((f) => (!f.formats || f.formats.includes(format))
|
|
1187
|
+
&& !(f.later && index === 0) && !(f.notLast && last))
|
|
1188
|
+
.map((field) => this.stageField(stage, merged, field, index));
|
|
1189
|
+
// A stage that moves keeps the focus on the button that moved it, or on the other one at
|
|
1190
|
+
// either end, and a removed stage gives it to the stage that takes its place.
|
|
1191
|
+
const focusStage = (at, ...kinds) => {
|
|
1192
|
+
const card = this.mainHost.querySelectorAll('.cup-stage')[at];
|
|
1193
|
+
kinds.map((kind) => card && card.querySelector(`[data-stage="${kind}"]`))
|
|
1194
|
+
.find((button) => button && !button.disabled)?.focus();
|
|
1195
|
+
};
|
|
1196
|
+
const move = (by) => {
|
|
1197
|
+
const to = index + by;
|
|
1198
|
+
[d.stages[index], d.stages[to]] = [d.stages[to], d.stages[index]];
|
|
1199
|
+
this.renderWizard();
|
|
1200
|
+
focusStage(to, by < 0 ? 'earlier' : 'later', by < 0 ? 'later' : 'earlier');
|
|
1201
|
+
const moved = d.stages[to];
|
|
1202
|
+
const name = stageNameText(moved.name) || FORMAT_NAMES[moved.format] || moved.format;
|
|
1203
|
+
this.announce(t('tournament.moved', { name, place: to + 1, total: d.stages.length }));
|
|
1204
|
+
};
|
|
1205
|
+
const number = index + 1;
|
|
1206
|
+
return h('fieldset', { class: 'cup-stage' },
|
|
1207
|
+
h('legend', {}, t('tournament.stage.legend', { number: index + 1, format: FORMAT_NAMES[format] || format })),
|
|
1208
|
+
h('div', { class: 'cup-stage-fields' }, rows),
|
|
1209
|
+
h('div', { class: 'row' },
|
|
1210
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-stage': 'earlier', disabled: index === 0, ...symbolOnly(t('tournament.stage.earlier', { number })), onclick: () => move(-1) }, icon('up')),
|
|
1211
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-stage': 'later', disabled: last, ...symbolOnly(t('tournament.stage.later', { number })), onclick: () => move(1) }, icon('down')),
|
|
1212
|
+
h('button', { type: 'button', class: 'btn small ghost danger', 'data-stage': 'remove', disabled: d.stages.length === 1, ...symbolOnly(t('tournament.stage.remove', { number })), onclick: () => {
|
|
1213
|
+
d.stages.splice(index, 1);
|
|
1214
|
+
this.renderWizard();
|
|
1215
|
+
const count = this.mainHost.querySelectorAll('.cup-stage').length;
|
|
1216
|
+
focusStage(Math.min(index, count - 1), 'remove', 'earlier', 'later');
|
|
1217
|
+
if (!this.mainHost.contains(document.activeElement)) {
|
|
1218
|
+
this.mainHost.querySelector('[data-focus="add-stage"]')?.focus();
|
|
1219
|
+
}
|
|
1220
|
+
} }, icon('close'))));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
stageField(stage, merged, field, index) {
|
|
1224
|
+
const set = (value) => {
|
|
1225
|
+
stage[field.key] = value;
|
|
1226
|
+
};
|
|
1227
|
+
const label = t('tournament.stage.fieldLabel', { label: field.label, number: index + 1 });
|
|
1228
|
+
let control;
|
|
1229
|
+
const value = merged[field.key];
|
|
1230
|
+
if (field.type === 'text') {
|
|
1231
|
+
// A name that the server or a preset gives shows in the language of the page, and
|
|
1232
|
+
// stays the server's until a person types another.
|
|
1233
|
+
const format = FORMAT_NAMES[merged.format] || merged.format;
|
|
1234
|
+
control = h('input', { type: 'text', maxlength: '60', value: stageNameText(value), placeholder: format, 'aria-label': label });
|
|
1235
|
+
control.addEventListener('input', () => set(control.value));
|
|
1236
|
+
} else if (field.type === 'select') {
|
|
1237
|
+
const options = field.key === 'draws' && value === null
|
|
1238
|
+
? field.options : field.options;
|
|
1239
|
+
const shown = value ?? (field.key === 'draws' ? (merged.format === 'gauntlet' ? 'settle' : 'holder') : '');
|
|
1240
|
+
control = this.select(options, shown, label, (v) => {
|
|
1241
|
+
set(v);
|
|
1242
|
+
if (field.key === 'format') {
|
|
1243
|
+
this.renderWizard();
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
} else if (field.type === 'scenario') {
|
|
1247
|
+
control = this.scenarioSelect(value, label, field.empty || null, set);
|
|
1248
|
+
} else if (field.type === 'number') {
|
|
1249
|
+
control = h('input', { type: 'number', min: String(field.min), max: String(field.max), step: '1', value: String(value), 'aria-label': label });
|
|
1250
|
+
control.addEventListener('input', () => set(Number(control.value)));
|
|
1251
|
+
} else if (field.type === 'optional') {
|
|
1252
|
+
// A stage that sets no ability follows the standard, where the tournament has one.
|
|
1253
|
+
const standard = field.key === 'ability' ? this.draft.ability : null;
|
|
1254
|
+
const off = standard === null || standard === undefined ? field.off : t('tournament.off.standard', { ability: abilityText(standard) });
|
|
1255
|
+
control = this.optionalNumber({ ...field, label, off }, value, set);
|
|
1256
|
+
} else if (field.type === 'check') {
|
|
1257
|
+
control = h('input', { type: 'checkbox', checked: !!value, 'aria-label': label });
|
|
1258
|
+
control.addEventListener('change', () => set(control.checked));
|
|
1259
|
+
control = h('label', { class: 'check' }, control, t('tournament.stage.on'));
|
|
1260
|
+
} else if (field.type === 'points') {
|
|
1261
|
+
const points = { ...value };
|
|
1262
|
+
control = h('span', { class: 'cup-points' }, ['win', 'draw', 'loss'].map((name) => {
|
|
1263
|
+
const input = h('input', { type: 'number', min: '-100', max: '100', step: '1', value: String(points[name]), 'aria-label': t(`tournament.points.${name}.label`, { number: index + 1 }) });
|
|
1264
|
+
input.addEventListener('input', () => {
|
|
1265
|
+
points[name] = Number(input.value);
|
|
1266
|
+
set({ ...points });
|
|
1267
|
+
});
|
|
1268
|
+
return h('label', { class: 'field small-field' }, t(`tournament.points.${name}`), input);
|
|
1269
|
+
}));
|
|
1270
|
+
} else if (field.type === 'list') {
|
|
1271
|
+
const text = Array.isArray(value) ? value.join(', ') : value ?? '';
|
|
1272
|
+
control = h('input', { type: 'text', value: String(text), placeholder: field.placeholder, 'aria-label': label });
|
|
1273
|
+
control.addEventListener('input', () => {
|
|
1274
|
+
const parts = control.value.split(/[\s,]+/).filter(Boolean).map(Number);
|
|
1275
|
+
if (!control.value.trim()) {
|
|
1276
|
+
set(field.single ? 2 : null);
|
|
1277
|
+
} else if (field.single && parts.length === 1) {
|
|
1278
|
+
set(parts[0]);
|
|
1279
|
+
} else {
|
|
1280
|
+
set(parts);
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
} else if (field.type === 'fill') {
|
|
1284
|
+
const fill = value === 'fill';
|
|
1285
|
+
const box = h('input', { type: 'checkbox', checked: fill, 'aria-label': t('tournament.stage.fill.label', { number: index + 1 }) });
|
|
1286
|
+
const input = h('input', { type: 'number', min: '0', max: '128', step: '1', value: fill ? '0' : String(value || 0), disabled: fill, 'aria-label': label });
|
|
1287
|
+
const sync = () => {
|
|
1288
|
+
input.disabled = box.checked;
|
|
1289
|
+
set(box.checked ? 'fill' : Number(input.value));
|
|
1290
|
+
};
|
|
1291
|
+
box.addEventListener('change', sync);
|
|
1292
|
+
input.addEventListener('input', sync);
|
|
1293
|
+
control = h('span', { class: 'cup-optional' }, input, h('label', { class: 'check' }, box, t('tournament.stage.fill')));
|
|
1294
|
+
} else if (field.type === 'order') {
|
|
1295
|
+
control = this.orderControl(field, value, set, label, index + 1);
|
|
1296
|
+
}
|
|
1297
|
+
return h('div', { class: 'field cup-field' }, h('span', { class: 'cup-field-label' }, field.label), control,
|
|
1298
|
+
field.help ? h('span', { class: 'muted small' }, field.help) : null);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// An ordered choice, such as the tiebreakers, each option on or off with buttons to move it.
|
|
1302
|
+
orderControl(field, value, set, label, number) {
|
|
1303
|
+
const chosen = (value || []).filter((v) => field.options.includes(v));
|
|
1304
|
+
// Lots always decide last, so they stay at the end of the list.
|
|
1305
|
+
const order = [...chosen, ...field.options.filter((v) => !chosen.includes(v))].filter((v) => v !== 'lots');
|
|
1306
|
+
if (field.options.includes('lots')) {
|
|
1307
|
+
order.push('lots');
|
|
1308
|
+
}
|
|
1309
|
+
const list = h('ol', { class: 'cup-order', 'aria-label': label });
|
|
1310
|
+
// The names of tiebreakers continue sentences elsewhere, so they start small, and a list
|
|
1311
|
+
// shows each as a name of its own, as the ways to settle a tie read.
|
|
1312
|
+
const shown = (name) => sentence(field.names[name]);
|
|
1313
|
+
const draw = (focus = null) => {
|
|
1314
|
+
list.replaceChildren(...order.map((name, i) => {
|
|
1315
|
+
const on = chosen.includes(name);
|
|
1316
|
+
const box = h('input', { type: 'checkbox', checked: on || name === 'lots', disabled: name === 'lots', 'data-focus': `use-${name}`, 'aria-label': t('tournament.order.use.label', { name: shown(name), number }) });
|
|
1317
|
+
// The items in use stay first, in their order, so the list reads as the order of
|
|
1318
|
+
// play, an item taken into use joining the end of them and one left out leaving.
|
|
1319
|
+
box.addEventListener('change', () => {
|
|
1320
|
+
order.splice(order.indexOf(name), 1);
|
|
1321
|
+
if (box.checked) {
|
|
1322
|
+
chosen.push(name);
|
|
1323
|
+
} else {
|
|
1324
|
+
chosen.splice(chosen.indexOf(name), 1);
|
|
1325
|
+
}
|
|
1326
|
+
const last = order.reduce((end, item, at) => (chosen.includes(item) ? at + 1 : end), 0);
|
|
1327
|
+
order.splice(last, 0, name);
|
|
1328
|
+
commit();
|
|
1329
|
+
draw(`use-${name}`);
|
|
1330
|
+
});
|
|
1331
|
+
// An item out of use takes no place in the order, so it has no button to move it.
|
|
1332
|
+
const up = !on || name === 'lots' ? null : h('button', { type: 'button', class: 'btn small ghost', disabled: i === 0, ...symbolOnly(t('tournament.order.earlier.label', { name: shown(name), number })), onclick: () => {
|
|
1333
|
+
[order[i - 1], order[i]] = [order[i], order[i - 1]];
|
|
1334
|
+
commit();
|
|
1335
|
+
// At the top of the list the button no longer moves it, so its box takes the focus.
|
|
1336
|
+
draw(i - 1 === 0 ? `use-${name}` : `up-${name}`);
|
|
1337
|
+
const now = order.filter((item) => chosen.includes(item) || item === 'lots');
|
|
1338
|
+
this.announce(t('tournament.moved', { name: shown(name), place: now.indexOf(name) + 1, total: now.length }));
|
|
1339
|
+
} }, icon('up'));
|
|
1340
|
+
if (up) {
|
|
1341
|
+
up.dataset.focus = `up-${name}`;
|
|
1342
|
+
}
|
|
1343
|
+
return h('li', { class: on || name === 'lots' ? '' : 'off' }, h('label', { class: 'check' }, box, shown(name)), up);
|
|
1344
|
+
}));
|
|
1345
|
+
if (focus) {
|
|
1346
|
+
list.querySelector(`[data-focus="${focus}"]`)?.focus();
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const commit = () => {
|
|
1350
|
+
const list = order.filter((name) => chosen.includes(name) || name === 'lots');
|
|
1351
|
+
set(list);
|
|
1352
|
+
};
|
|
1353
|
+
draw();
|
|
1354
|
+
return list;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
reviewStep() {
|
|
1358
|
+
// The check runs on the server while the person may type the name of a preset below, and
|
|
1359
|
+
// its place shows that it runs until the answer takes it.
|
|
1360
|
+
const host = h('div', { class: 'cup-review', 'aria-busy': 'true' },
|
|
1361
|
+
h('p', { class: 'muted cup-checking', role: 'status' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }), t('tournament.review.checking')));
|
|
1362
|
+
const saveName = h('input', { type: 'text', class: 'cup-preset-name', maxlength: '64', placeholder: t('tournament.review.presetName.placeholder'), pattern: PRESET_NAME.source, 'aria-label': t('tournament.review.presetName.label') });
|
|
1363
|
+
const saveTitle = h('input', { type: 'text', maxlength: '60', placeholder: t('tournament.review.presetTitle.placeholder'), value: this.draft.name, 'aria-label': t('tournament.review.presetTitle.label') });
|
|
1364
|
+
const saveButton = h('button', { type: 'button', class: 'btn', 'data-focus': 'save-preset', ...symbolOnly(t('tournament.review.save')), onclick: () => {
|
|
1365
|
+
// A name that the server cannot take is refused here, with its rule and the focus on
|
|
1366
|
+
// the name.
|
|
1367
|
+
const name = saveName.value.trim();
|
|
1368
|
+
if (!PRESET_NAME.test(name)) {
|
|
1369
|
+
saveName.setAttribute('aria-invalid', 'true');
|
|
1370
|
+
this.app.hud.toast(t('tournament.review.presetName.rule'), 'error', 7000);
|
|
1371
|
+
saveName.focus();
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
saveName.removeAttribute('aria-invalid');
|
|
1375
|
+
const own = this.presets[name];
|
|
1376
|
+
if (!own || own.shipped) {
|
|
1377
|
+
this.savePreset(name, saveTitle.value.trim());
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
// A preset of the person by that name is replaced only once the person says so, in
|
|
1381
|
+
// place of the button, as a delete asks.
|
|
1382
|
+
const back = () => {
|
|
1383
|
+
ask.replaceWith(saveButton);
|
|
1384
|
+
saveButton.focus();
|
|
1385
|
+
};
|
|
1386
|
+
const ask = this.confirmGroup(t('tournament.review.replace.confirm', { title: own.title }),
|
|
1387
|
+
[icon('save'), t('tournament.review.replace')], t('tournament.review.replace.no'), async () => {
|
|
1388
|
+
await this.savePreset(name, saveTitle.value.trim());
|
|
1389
|
+
back();
|
|
1390
|
+
}, back);
|
|
1391
|
+
saveButton.replaceWith(ask);
|
|
1392
|
+
ask.querySelector('[data-answer="no"]').focus();
|
|
1393
|
+
} }, icon('save'));
|
|
1394
|
+
const save = h('div', { class: 'cup-save' },
|
|
1395
|
+
h('h3', {}, t('tournament.review.save.title')),
|
|
1396
|
+
h('div', { class: 'row' },
|
|
1397
|
+
h('label', { class: 'field' }, t('tournament.review.presetName'), saveName),
|
|
1398
|
+
h('label', { class: 'field' }, t('tournament.review.presetTitle.placeholder'), saveTitle),
|
|
1399
|
+
saveButton));
|
|
1400
|
+
const clashes = this.nameClashes();
|
|
1401
|
+
if (clashes.length) {
|
|
1402
|
+
// Two entrants that share a name go back to the entrants step, before any check.
|
|
1403
|
+
host.setAttribute('aria-busy', 'false');
|
|
1404
|
+
host.replaceChildren(h('div', { class: 'notice error', role: 'alert' },
|
|
1405
|
+
h('ul', { class: 'symbol-list' }, clashes.map((clash) => h('li', {}, icon('warning', 'icon text-symbol'), t('tournament.review.nameTaken', { number: clash.index + 1, name: clash.name, other: clash.other + 1 })))),
|
|
1406
|
+
h('p', { class: 'muted small' }, t('tournament.review.names'))));
|
|
1407
|
+
queueMicrotask(() => this.holdStart(host.firstElementChild));
|
|
1408
|
+
} else {
|
|
1409
|
+
this.check(host);
|
|
1410
|
+
}
|
|
1411
|
+
return [host, strictReview(this), save];
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
config() {
|
|
1415
|
+
const d = this.draft;
|
|
1416
|
+
const config = {
|
|
1417
|
+
seeding: d.seeding, concurrency: d.concurrency,
|
|
1418
|
+
forfeit_score: d.forfeit_score, defaults: d.defaults, stages: d.stages,
|
|
1419
|
+
entrants: d.entrants.map((e) => {
|
|
1420
|
+
const entry = { kind: e.kind };
|
|
1421
|
+
for (const key of ['team', 'name', 'rating', 'teammates', 'ability']) {
|
|
1422
|
+
if (e[key] !== undefined && e[key] !== null && e[key] !== '') {
|
|
1423
|
+
entry[key] = e[key];
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return entry;
|
|
1427
|
+
}),
|
|
1428
|
+
};
|
|
1429
|
+
// A tournament left without a name takes the server's, which reads in any language.
|
|
1430
|
+
if ((d.name || '').trim()) {
|
|
1431
|
+
config.name = d.name.trim();
|
|
1432
|
+
}
|
|
1433
|
+
if (d.seed !== '' && d.seed !== null && d.seed !== undefined) {
|
|
1434
|
+
config.seed = /^\d+$/.test(String(d.seed)) ? Number(d.seed) : d.seed;
|
|
1435
|
+
}
|
|
1436
|
+
if (d.preset) {
|
|
1437
|
+
config.preset = d.preset;
|
|
1438
|
+
}
|
|
1439
|
+
if (d.ability !== null && d.ability !== undefined) {
|
|
1440
|
+
config.ability = d.ability;
|
|
1441
|
+
}
|
|
1442
|
+
return Object.assign(config, strictConfig(d));
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
async check(host) {
|
|
1446
|
+
try {
|
|
1447
|
+
const preview = await tournamentsApi.check(this.config());
|
|
1448
|
+
host.setAttribute('aria-busy', 'false');
|
|
1449
|
+
const entrants = preview.config.entrants;
|
|
1450
|
+
const standard = preview.config.ability ?? null;
|
|
1451
|
+
const parts = [h('p', {}, t('tournament.review.summary', {
|
|
1452
|
+
entrants: t('tournament.count.entrants', { count: entrants.length }), seed: preview.config.seed,
|
|
1453
|
+
matches: t('tournament.review.matches', { count: preview.matches }),
|
|
1454
|
+
})), h('p', { class: 'muted small cup-review-ability' }, standardText(standard))];
|
|
1455
|
+
const own = entrants.filter((e) => shownAbility(e, standard) !== null);
|
|
1456
|
+
if (own.length) {
|
|
1457
|
+
parts.push(h('ul', { class: 'cup-review-abilities small' }, own.map((e) => h('li', {}, entrantName(e), ' ', abilityBadge(e, standard)))));
|
|
1458
|
+
}
|
|
1459
|
+
this.draft.seed = String(preview.config.seed);
|
|
1460
|
+
preview.stages.forEach((stage, index) => {
|
|
1461
|
+
const settings = preview.config.stages[index];
|
|
1462
|
+
parts.push(h('section', { class: 'cup-review-stage' },
|
|
1463
|
+
h('h3', {}, t('tournament.review.stage', { number: index + 1, name: serverText(settings, 'name') })),
|
|
1464
|
+
h('p', { class: 'muted small' }, [
|
|
1465
|
+
stageText(settings), t('tournament.count.entrants', { count: settings.entrants }),
|
|
1466
|
+
index < preview.stages.length - 1 ? t('tournament.review.advance', { count: settings.advance }) : null,
|
|
1467
|
+
scenarioName(settings.scenario), settings.length ? t('tournament.count.steps', { count: settings.length }) : null,
|
|
1468
|
+
settings.ability != null ? t('tournament.stage.ability', { ability: abilityText(settings.ability) }) : null,
|
|
1469
|
+
].filter(Boolean).join(', ')),
|
|
1470
|
+
stage.tables && stage.tables.length && settings.format === 'groups'
|
|
1471
|
+
? h('div', { class: 'cup-review-groups' }, stage.tables.map((table) => h('div', {},
|
|
1472
|
+
h('h4', {}, serverText(table, 'name')), h('ul', {}, table.rows.map((row) => h('li', {}, entrantName(entrants[row.entrant])))))))
|
|
1473
|
+
: null,
|
|
1474
|
+
stage.rounds && stage.rounds.length && settings.format !== 'groups'
|
|
1475
|
+
? h('div', {}, h('h4', {}, serverText(stage.rounds[0], 'name')), h('ul', { class: 'cup-review-draw' },
|
|
1476
|
+
stage.rounds[0].fixtures.slice(0, 32).map((f) => h('li', {}, t('tournament.versus', { home: sideName(f.home, entrants), away: sideName(f.away, entrants) })))))
|
|
1477
|
+
: null));
|
|
1478
|
+
});
|
|
1479
|
+
host.replaceChildren(...parts);
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
host.setAttribute('aria-busy', 'false');
|
|
1482
|
+
// A scenario that an entrant cannot play names its stage, so a button leads straight to
|
|
1483
|
+
// that stage's scenario.
|
|
1484
|
+
// A scenario of the deciding matches leads to that field of the stage instead.
|
|
1485
|
+
const decider = err.id === 'server.tournament.cannot_play_decider';
|
|
1486
|
+
// Teams of both sizes are told a scenario that fits them, which the button leads to as well.
|
|
1487
|
+
const stage = ['server.tournament.cannot_play', 'server.tournament.mixed_sizes'].includes(err.id) || decider ? Number(err.values?.stage) : NaN;
|
|
1488
|
+
const fix = Number.isInteger(stage) && stage >= 1 && stage <= this.draft.stages.length
|
|
1489
|
+
? h('button', { type: 'button', class: 'btn cup-fix-scenario', onclick: () => this.focusStageScenario(stage - 1, decider) },
|
|
1490
|
+
icon('edit'), t(decider ? 'tournament.review.fixDecider' : 'tournament.review.fixScenario', { number: stage }))
|
|
1491
|
+
: null;
|
|
1492
|
+
host.replaceChildren(h('div', { class: 'notice error', role: 'alert' }, h('p', {}, err.message),
|
|
1493
|
+
h('p', { class: 'muted small' }, tn('tournament.review.failed')), fix));
|
|
1494
|
+
if (host.isConnected) {
|
|
1495
|
+
this.holdStart(host.firstElementChild);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Holds Start tournament while the review shows a refusal, the refusal as its description,
|
|
1501
|
+
// so a person who reaches the button hears why it waits, and a press leads to the refusal.
|
|
1502
|
+
holdStart(refusal) {
|
|
1503
|
+
const start = this.mainHost.querySelector('.cup-start');
|
|
1504
|
+
if (!start || !refusal) {
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
refusal.id = 'cup-review-refusal';
|
|
1508
|
+
refusal.tabIndex = -1;
|
|
1509
|
+
start.setAttribute('aria-disabled', 'true');
|
|
1510
|
+
start.setAttribute('aria-describedby', refusal.id);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
async savePreset(name, title) {
|
|
1514
|
+
const d = this.draft;
|
|
1515
|
+
try {
|
|
1516
|
+
const saved = await tournamentsApi.savePreset(name, {
|
|
1517
|
+
title: title || d.name || name, description: '', seeding: d.seeding, concurrency: d.concurrency,
|
|
1518
|
+
forfeit_score: d.forfeit_score, ability: d.ability ?? null, defaults: d.defaults, stages: d.stages, ...strictConfig(d),
|
|
1519
|
+
});
|
|
1520
|
+
this.presets[name] = saved;
|
|
1521
|
+
this.app.hud.toast(t('tournament.review.saved', { title: saved.title }));
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
this.app.hud.toast(err.message, 'error', 7000);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
async create(button) {
|
|
1528
|
+
if (button.getAttribute('aria-disabled') === 'true') {
|
|
1529
|
+
document.getElementById('cup-review-refusal')?.focus();
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (this.nameClashes().length) {
|
|
1533
|
+
this.app.hud.toast(t('tournament.review.names'), 'error', 7000);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
button.disabled = true;
|
|
1537
|
+
try {
|
|
1538
|
+
const entry = await tournamentsApi.create(this.config());
|
|
1539
|
+
this.app.hud.toast(t('tournament.started', { name: serverText(entry, 'name') }));
|
|
1540
|
+
this.draft = null;
|
|
1541
|
+
this.list = await tournamentsApi.list();
|
|
1542
|
+
await this.open(entry.id);
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
this.app.hud.toast(err.message, 'error', 8000);
|
|
1545
|
+
button.disabled = false;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// A tournament.
|
|
1550
|
+
|
|
1551
|
+
async open(id) {
|
|
1552
|
+
this.mode = 'view';
|
|
1553
|
+
this.root.querySelector('.cup-layout')?.classList.remove('brackets');
|
|
1554
|
+
this.openId = id;
|
|
1555
|
+
this.awarding = null;
|
|
1556
|
+
this.asking = null;
|
|
1557
|
+
this.stageTab = null;
|
|
1558
|
+
this.renderList();
|
|
1559
|
+
this.mainHost.setAttribute('aria-busy', 'true');
|
|
1560
|
+
await this.load(id);
|
|
1561
|
+
this.mainHost.setAttribute('aria-busy', 'false');
|
|
1562
|
+
this.mainHost.querySelector('h2')?.focus();
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
async load(id) {
|
|
1566
|
+
try {
|
|
1567
|
+
const entry = await this.fetchCup(id);
|
|
1568
|
+
if (this.openId !== id) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const before = this.current && this.current.id === id ? this.current : null;
|
|
1572
|
+
this.current = entry;
|
|
1573
|
+
this.renderView();
|
|
1574
|
+
this.announceChanges(before, entry);
|
|
1575
|
+
// A team registered since the page read the registry would show as a dot of its
|
|
1576
|
+
// color, so the registry is read again once for the teams it lacks.
|
|
1577
|
+
const missing = [...new Set(entry.config.entrants.map((e) => e.team).filter((team) => team && !this.teams.some((x) => x.id === team)))].join('|');
|
|
1578
|
+
if (missing && missing !== this.teamsAsked) {
|
|
1579
|
+
this.teamsAsked = missing;
|
|
1580
|
+
this.refreshTeams();
|
|
1581
|
+
}
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
if (err.status === 404) {
|
|
1584
|
+
this.mode = 'empty';
|
|
1585
|
+
this.openId = null;
|
|
1586
|
+
this.current = null;
|
|
1587
|
+
this.refresh();
|
|
1588
|
+
} else {
|
|
1589
|
+
this.app.hud.toast(err.message, 'error');
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Says what changed in the open tournament since it was last read, the results of matches
|
|
1595
|
+
// that ended and a stop. A result that waits to be watched without spoilers has none yet,
|
|
1596
|
+
// and the end of a tournament has a toast of its own.
|
|
1597
|
+
announceChanges(before, entry) {
|
|
1598
|
+
if (!before || !this.announcer) {
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const entrants = entry.config.entrants;
|
|
1602
|
+
const old = new Map(before.matches.map((m) => [m.key, m]));
|
|
1603
|
+
const said = entry.matches.filter((m) => m.result && Array.isArray(m.result.score)
|
|
1604
|
+
&& !(old.get(m.key) && old.get(m.key).result)).map((m) => t('tournament.announce.result', {
|
|
1605
|
+
label: serverText(m, 'label'), home: entrantName(entrants[m.home]), away: entrantName(entrants[m.away]),
|
|
1606
|
+
left: m.result.score[0], right: m.result.score[1],
|
|
1607
|
+
}));
|
|
1608
|
+
// Many results at once, as Reveal all shows them, are said as their count.
|
|
1609
|
+
if (said.length > 3) {
|
|
1610
|
+
said.splice(0, said.length, t('tournament.announce.results', { count: said.length }));
|
|
1611
|
+
}
|
|
1612
|
+
if (entry.state === 'stopped' && before.state !== 'stopped') {
|
|
1613
|
+
said.push(t('tournament.announce.stopped', { name: serverText(entry, 'name') }));
|
|
1614
|
+
}
|
|
1615
|
+
if (said.length) {
|
|
1616
|
+
this.announce(said.join(' '));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// A tournament in full, or as the person sees it who watches it without spoilers, whose
|
|
1621
|
+
// unwatched recordings the lists of the page then leave out.
|
|
1622
|
+
async fetchCup(id) {
|
|
1623
|
+
if (!spoilerFree(id)) {
|
|
1624
|
+
return tournamentsApi.get(id);
|
|
1625
|
+
}
|
|
1626
|
+
const entry = await tournamentsApi.spoilers(id, watchedKeys(id));
|
|
1627
|
+
const recordings = {};
|
|
1628
|
+
for (const m of entry.matches || []) {
|
|
1629
|
+
if (m.unseen && m.unseen.match) {
|
|
1630
|
+
recordings[m.key] = m.unseen.match;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
noteRecordings(id, recordings);
|
|
1634
|
+
return entry;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
renderView() {
|
|
1638
|
+
const cup = this.current;
|
|
1639
|
+
if (cup && this.mode === 'brackets') {
|
|
1640
|
+
this.renderBrackets();
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (!cup || this.mode !== 'view') {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
this.root.querySelector('.cup-layout')?.classList.remove('brackets');
|
|
1647
|
+
const entrants = cup.config.entrants;
|
|
1648
|
+
this.matchMap = new Map(cup.matches.map((m) => [m.key, m]));
|
|
1649
|
+
if (this.stageTab === null || this.stageTab >= cup.stages_view.length) {
|
|
1650
|
+
const playing = cup.stages_view.findIndex((s) => s.state !== 'done');
|
|
1651
|
+
this.stageTab = playing >= 0 ? playing : cup.stages_view.length - 1;
|
|
1652
|
+
}
|
|
1653
|
+
const share = progressShare(cup);
|
|
1654
|
+
const actions = [];
|
|
1655
|
+
// Each action shows its words beside its symbol, so the actions of a tournament read
|
|
1656
|
+
// at a glance on any screen.
|
|
1657
|
+
if (cup.state === 'running') {
|
|
1658
|
+
// Canceling cannot be undone, so it asks first in place of the button.
|
|
1659
|
+
actions.push(this.askButton('cancel',
|
|
1660
|
+
h('button', { type: 'button', class: 'btn ghost danger' }, icon('stop'), t('tournament.view.cancel')),
|
|
1661
|
+
t('tournament.view.cancel.confirm', { name: serverText(cup, 'name') }), t('tournament.view.cancel'), t('tournament.view.cancel.no'),
|
|
1662
|
+
() => this.act('cancel', t('tournament.view.cancelled'))));
|
|
1663
|
+
}
|
|
1664
|
+
if (cup.state === 'stopped') {
|
|
1665
|
+
actions.push(h('button', { type: 'button', class: 'btn primary', 'data-focus': 'act-resume', onclick: () => this.act('resume', t('tournament.view.resumed')) }, icon('play'), t('tournament.view.resume')));
|
|
1666
|
+
}
|
|
1667
|
+
// Without spoilers, the ceremony waits until the person has watched the champion win.
|
|
1668
|
+
if (cup.state === 'done' && cup.champion_index !== null && cup.champion_index !== undefined) {
|
|
1669
|
+
actions.push(h('button', { type: 'button', class: 'btn primary', 'data-focus': 'act-ceremony', onclick: () => this.ceremony() }, icon('trophy'), t('tournament.view.ceremony')));
|
|
1670
|
+
}
|
|
1671
|
+
actions.push(h('button', { type: 'button', class: 'btn ghost', 'data-focus': 'act-brackets', onclick: () => this.openBrackets(cup.id) }, icon('grid'), t('tournament.brackets.open')));
|
|
1672
|
+
actions.push(h('button', { type: 'button', class: 'btn ghost', 'data-focus': 'act-again', onclick: () => {
|
|
1673
|
+
const config = clone(cup.config);
|
|
1674
|
+
this.startWizard(config);
|
|
1675
|
+
this.draft.seed = '';
|
|
1676
|
+
} }, icon('replay'), t('tournament.view.again')));
|
|
1677
|
+
actions.push(legsControl(this, cup));
|
|
1678
|
+
if (cup.state !== 'running') {
|
|
1679
|
+
actions.push(this.askButton('delete',
|
|
1680
|
+
h('button', { type: 'button', class: 'btn ghost danger' }, icon('trash'), t('tournament.view.delete')),
|
|
1681
|
+
t('tournament.delete.confirm', { name: serverText(cup, 'name') }), t('tournament.view.delete'), t('tournament.forfeit.no'),
|
|
1682
|
+
() => this.remove()));
|
|
1683
|
+
}
|
|
1684
|
+
const head = h('header', { class: 'cup-head' },
|
|
1685
|
+
h('h2', { tabindex: '-1', 'data-focus': 'cup-title' }, serverText(cup, 'name')),
|
|
1686
|
+
h('p', { class: 'cup-meta' },
|
|
1687
|
+
stateBadge(cup.state, STATE_LABELS[cup.state] || cup.state, `state-pill ${cup.state === 'running' ? 'running' : cup.state === 'done' ? 'done' : 'failed'}`),
|
|
1688
|
+
h('span', {}, t('tournament.count.entrants', { count: entrants.length })),
|
|
1689
|
+
h('span', {}, t('tournament.view.seed', { seed: cup.seed })),
|
|
1690
|
+
h('span', { class: 'cup-view-ability' }, standardText(cup.config.ability ?? null)),
|
|
1691
|
+
// A tournament that ended reads as finished, its schedule played in full.
|
|
1692
|
+
h('span', { class: 'cup-view-played' }, cup.state === 'done' ? t('tournament.view.finished', { count: cup.played })
|
|
1693
|
+
: t('tournament.view.played', { count: cup.played, total: cup.scheduled })),
|
|
1694
|
+
cup.running ? h('span', {}, t('tournament.view.running', { count: cup.running })) : null),
|
|
1695
|
+
strictHead(cup),
|
|
1696
|
+
cup.state === 'running' ? h('div', { class: 'progress', role: 'progressbar', 'aria-label': t('tournament.view.progress.label'), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(Math.round(share * 100)) },
|
|
1697
|
+
h('div', { class: 'bar', style: `width:${Math.round(share * 100)}%` })) : null,
|
|
1698
|
+
cup.champion ? h('p', { class: 'cup-champion' }, h('span', {}, t('tournament.view.champion', { name: serverText(cup, 'champion') }))) : null,
|
|
1699
|
+
cup.state === 'cancelled' ? h('p', { class: 'notice cup-canceled' }, icon('close', 'icon text-symbol'), t('tournament.view.canceledNote')) : null,
|
|
1700
|
+
cup.error ? h('div', { class: 'notice error', role: 'alert' }, h('p', {}, sentence(serverText(cup, 'error'))), this.failedAwards(cup)) : null,
|
|
1701
|
+
cup.persistence_error ? h('div', { class: 'notice error' }, h('p', {}, sentence(serverText(cup, 'persistence_error')))) : null,
|
|
1702
|
+
cup.owner ? h('p', { class: 'muted small' }, t('tournament.view.owner', { owner: cup.owner })) : null,
|
|
1703
|
+
h('div', { class: 'row cup-actions' }, actions),
|
|
1704
|
+
this.spoilerBar(cup));
|
|
1705
|
+
const open = ['running', 'stopped'].includes(cup.state);
|
|
1706
|
+
const people = open ? cup.matches.filter((m) => ['person', 'playing'].includes(m.state)) : [];
|
|
1707
|
+
const peopleBox = people.length ? h('section', { class: 'cup-people', 'aria-labelledby': 'cup-people-title' },
|
|
1708
|
+
h('h3', { id: 'cup-people-title' }, t('tournament.people.title')),
|
|
1709
|
+
cup.state === 'stopped' ? h('p', { class: 'muted small' }, t('tournament.people.resume')) : null,
|
|
1710
|
+
h('ul', {}, people.map((m) => h('li', {},
|
|
1711
|
+
h('span', {}, t('tournament.people.match', { label: serverText(m, 'label'), home: entrantName(entrants[m.home]), away: entrantName(entrants[m.away]), scenario: scenarioName(m.scenario) })),
|
|
1712
|
+
m.note ? h('span', { class: 'muted small' }, serverText(m, 'note')) : null,
|
|
1713
|
+
m.state === 'playing' ? h('span', { class: 'muted small' }, t('tournament.people.playing'))
|
|
1714
|
+
: h('span', { class: 'row' },
|
|
1715
|
+
h('button', { type: 'button', class: 'btn small primary', 'data-focus': `people-${m.key}`, disabled: cup.state !== 'running', onclick: () => this.playMatch(m) }, icon('play'), t('tournament.play')),
|
|
1716
|
+
this.forfeitButton(m, 'home'), this.forfeitButton(m, 'away')))))) : null;
|
|
1717
|
+
const tabs = h('div', { class: 'cup-tabs', role: 'tablist', 'aria-label': t('tournament.view.stages.label') }, cup.stages_view.map((stage, index) => h('button', {
|
|
1718
|
+
type: 'button', role: 'tab', id: `cup-tab-${index}`, 'aria-selected': index === this.stageTab ? 'true' : 'false',
|
|
1719
|
+
tabindex: index === this.stageTab ? '0' : '-1', class: `cup-tab${index === this.stageTab ? ' active' : ''}`, 'aria-controls': 'cup-stage-panel',
|
|
1720
|
+
onclick: () => {
|
|
1721
|
+
this.stageTab = index;
|
|
1722
|
+
this.renderView();
|
|
1723
|
+
document.getElementById(`cup-tab-${index}`)?.focus();
|
|
1724
|
+
},
|
|
1725
|
+
onkeydown: (e) => {
|
|
1726
|
+
const by = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
|
1727
|
+
if (by) {
|
|
1728
|
+
e.preventDefault();
|
|
1729
|
+
this.stageTab = (index + by + cup.stages_view.length) % cup.stages_view.length;
|
|
1730
|
+
this.renderView();
|
|
1731
|
+
document.getElementById(`cup-tab-${this.stageTab}`)?.focus();
|
|
1732
|
+
}
|
|
1733
|
+
},
|
|
1734
|
+
}, serverText(stage, 'name'), h('span', { class: 'muted small' }, ` ${FORMAT_NAMES[stage.format] || stage.format}`))));
|
|
1735
|
+
const stage = cup.stages_view[this.stageTab];
|
|
1736
|
+
const panel = h('div', { class: 'cup-stage-view', id: 'cup-stage-panel', role: 'tabpanel', 'aria-labelledby': `cup-tab-${this.stageTab}` },
|
|
1737
|
+
this.stageView(stage, cup.config.stages[this.stageTab]));
|
|
1738
|
+
const ranking = cup.ranking ? h('section', { class: 'cup-ranking', 'aria-labelledby': 'cup-ranking-title' },
|
|
1739
|
+
h('h3', { id: 'cup-ranking-title' }, t('tournament.view.ranking')),
|
|
1740
|
+
h('ol', {}, cup.ranking.map((e) => h('li', {}, entrantName(entrants[e]))))) : null;
|
|
1741
|
+
const focused = document.activeElement && this.mainHost.contains(document.activeElement) ? document.activeElement : null;
|
|
1742
|
+
// An action that took the focus with it, such as a confirmed award, names where it was.
|
|
1743
|
+
const focusKey = focused ? (focused.id || focused.dataset.focus || null) : this.focusAfter || null;
|
|
1744
|
+
const byId = !!focused && focusKey === focused.id;
|
|
1745
|
+
this.focusAfter = null;
|
|
1746
|
+
const scroll = this.mainHost.scrollTop;
|
|
1747
|
+
this.mainHost.replaceChildren(h('div', { class: 'cup-view' }, head, this.caughtUp(cup), peopleBox, this.upNextBox(cup), tabs, panel, ranking, resultsSection(this, cup)));
|
|
1748
|
+
this.mainHost.scrollTop = scroll;
|
|
1749
|
+
if (focusKey) {
|
|
1750
|
+
this.refocus(focusKey, byId);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// A button of the tournament that asks first, as an award does, the question with its yes and
|
|
1755
|
+
// no in place of the button, through the page's refreshes until either answer. Escape or no
|
|
1756
|
+
// leaves everything as it was, and the focus returns to the button.
|
|
1757
|
+
askButton(name, button, question, yes, no, onYes) {
|
|
1758
|
+
const focus = (key) => this.mainHost.querySelector(`[data-focus="${CSS.escape(key)}"]`)?.focus();
|
|
1759
|
+
if (this.asking !== name) {
|
|
1760
|
+
button.dataset.focus = `act-${name}`;
|
|
1761
|
+
button.addEventListener('click', () => {
|
|
1762
|
+
this.asking = name;
|
|
1763
|
+
this.renderView();
|
|
1764
|
+
focus(`ask-no-${name}`);
|
|
1765
|
+
});
|
|
1766
|
+
return button;
|
|
1767
|
+
}
|
|
1768
|
+
const cancel = () => {
|
|
1769
|
+
this.asking = null;
|
|
1770
|
+
this.renderView();
|
|
1771
|
+
focus(`act-${name}`);
|
|
1772
|
+
};
|
|
1773
|
+
return h('span', {
|
|
1774
|
+
class: 'cup-confirm', role: 'group', 'aria-label': question,
|
|
1775
|
+
onkeydown: (e) => {
|
|
1776
|
+
if (e.key === 'Escape') {
|
|
1777
|
+
e.preventDefault();
|
|
1778
|
+
e.stopPropagation();
|
|
1779
|
+
cancel();
|
|
1780
|
+
}
|
|
1781
|
+
},
|
|
1782
|
+
},
|
|
1783
|
+
h('span', { class: 'small' }, question),
|
|
1784
|
+
h('button', { type: 'button', class: 'btn small primary danger', 'data-focus': `ask-yes-${name}`, onclick: (e) => {
|
|
1785
|
+
e.currentTarget.disabled = true;
|
|
1786
|
+
this.asking = null;
|
|
1787
|
+
this.focusAfter = `ask-yes-${name}`;
|
|
1788
|
+
onYes();
|
|
1789
|
+
} }, yes),
|
|
1790
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': `ask-no-${name}`, onclick: cancel }, icon('close'), no));
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Focuses the control of `key` again once the tournament is drawn anew, or when it is gone or
|
|
1794
|
+
// can no longer be used, as after an award, a reveal or a change of state, a control that
|
|
1795
|
+
// takes its place, else the name of the tournament, so the focus never falls to the page.
|
|
1796
|
+
refocus(key, byId = false) {
|
|
1797
|
+
const find = (name) => {
|
|
1798
|
+
const node = this.mainHost.querySelector(`[data-focus="${CSS.escape(name)}"]`);
|
|
1799
|
+
return node && !node.disabled ? node : null;
|
|
1800
|
+
};
|
|
1801
|
+
const usable = byId ? document.getElementById(key) : find(key);
|
|
1802
|
+
if (usable && !usable.disabled) {
|
|
1803
|
+
usable.focus();
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
const match = /^(?:unseen-watch|unseen-reveal)-(.+)$/.exec(key);
|
|
1807
|
+
const instead = {
|
|
1808
|
+
'act-resume': ['act-cancel', 'act-delete'], 'act-cancel': ['act-delete', 'act-resume'],
|
|
1809
|
+
'ask-yes-cancel': ['act-delete', 'act-resume'], 'ask-no-cancel': ['act-cancel'],
|
|
1810
|
+
'zoom-out': ['zoom-in'], 'zoom-in': ['zoom-out'], 'watch-next': ['reveal-all'],
|
|
1811
|
+
}[key] || (match ? [`watch-${match[1]}`, 'watch-next'] : []);
|
|
1812
|
+
const award = /^award-yes-(fixture|notice)-/.exec(key);
|
|
1813
|
+
const next = award ? [...this.mainHost.querySelectorAll(`[data-focus^="award-${award[1]}-"]`)].find((node) => !node.disabled) : null;
|
|
1814
|
+
const found = next || [...instead, 'watch-next'].map(find).find(Boolean);
|
|
1815
|
+
(found || this.mainHost.querySelector('h2'))?.focus();
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// The awards of the failed matches, for the notice of a stopped tournament.
|
|
1819
|
+
failedAwards(cup) {
|
|
1820
|
+
if (cup.state !== 'stopped') {
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1823
|
+
const failed = cup.matches.filter((m) => m.state === 'failed');
|
|
1824
|
+
if (!failed.length) {
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
const entrants = cup.config.entrants;
|
|
1828
|
+
return h('ul', { class: 'cup-failed' }, failed.map((m) => h('li', {},
|
|
1829
|
+
h('span', {}, t('tournament.failed.match', { label: serverText(m, 'label'), home: entrantName(entrants[m.home]), away: entrantName(entrants[m.away]) })),
|
|
1830
|
+
h('span', { class: 'row' }, this.forfeitButton(m, 'home', 'notice'), this.forfeitButton(m, 'away', 'notice')))));
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// The button that awards a match to one side, which asks first in a confirm step of its own,
|
|
1834
|
+
// the question with Award and Cancel in place of the button. Escape or Cancel leaves the
|
|
1835
|
+
// match as it was, and the step stays through the page's refreshes until either answer.
|
|
1836
|
+
forfeitButton(match, side, where = 'fixture') {
|
|
1837
|
+
const entrants = this.current.config.entrants;
|
|
1838
|
+
const winner = entrantName(entrants[side === 'home' ? match.home : match.away]);
|
|
1839
|
+
const id = `${where}-${match.key}-${side}`;
|
|
1840
|
+
const label = serverText(match, 'label');
|
|
1841
|
+
const focus = (key) => this.mainHost.querySelector(`[data-focus="${CSS.escape(key)}"]`)?.focus();
|
|
1842
|
+
if (this.awarding !== id) {
|
|
1843
|
+
return h('button', { type: 'button', class: 'btn small ghost', 'data-focus': `award-${id}`, onclick: () => {
|
|
1844
|
+
this.awarding = id;
|
|
1845
|
+
this.renderView();
|
|
1846
|
+
focus(`award-no-${id}`);
|
|
1847
|
+
} }, icon('flag'), t('tournament.forfeit', { name: winner }));
|
|
1848
|
+
}
|
|
1849
|
+
const cancel = () => {
|
|
1850
|
+
this.awarding = null;
|
|
1851
|
+
this.renderView();
|
|
1852
|
+
focus(`award-${id}`);
|
|
1853
|
+
};
|
|
1854
|
+
const question = t('tournament.forfeit.confirm', { label, name: winner, goals: this.current.config.forfeit_score });
|
|
1855
|
+
return h('span', {
|
|
1856
|
+
class: 'cup-confirm', role: 'group', 'aria-label': question,
|
|
1857
|
+
onkeydown: (e) => {
|
|
1858
|
+
if (e.key === 'Escape') {
|
|
1859
|
+
e.preventDefault();
|
|
1860
|
+
e.stopPropagation();
|
|
1861
|
+
cancel();
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
},
|
|
1865
|
+
h('span', { class: 'small' }, question),
|
|
1866
|
+
h('button', { type: 'button', class: 'btn small primary', 'data-focus': `award-yes-${id}`, onclick: async (e) => {
|
|
1867
|
+
e.currentTarget.disabled = true;
|
|
1868
|
+
this.focusAfter = `award-yes-${id}`;
|
|
1869
|
+
try {
|
|
1870
|
+
this.current = await tournamentsApi.action(this.current.id, 'forfeit', { key: match.key, winner: side });
|
|
1871
|
+
this.awarding = null;
|
|
1872
|
+
this.app.hud.toast(t('tournament.forfeit.done', { label, name: winner }));
|
|
1873
|
+
this.renderView();
|
|
1874
|
+
} catch (err) {
|
|
1875
|
+
this.awarding = null;
|
|
1876
|
+
this.renderView();
|
|
1877
|
+
this.app.hud.toast(err.message, 'error', 7000);
|
|
1878
|
+
}
|
|
1879
|
+
} }, icon('flag'), t('tournament.forfeit.yes')),
|
|
1880
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': `award-no-${id}`, ...symbolOnly(t('tournament.forfeit.no')), onclick: cancel }, icon('close')));
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
stageView(stage, settings) {
|
|
1884
|
+
const entrants = this.current.config.entrants;
|
|
1885
|
+
const parts = [h('p', { class: 'muted small' }, [
|
|
1886
|
+
stageText(settings), scenarioName(settings.scenario), settings.length ? t('tournament.count.steps', { count: settings.length }) : null,
|
|
1887
|
+
settings.ability != null ? t('tournament.stage.ability', { ability: abilityText(settings.ability) }) : null,
|
|
1888
|
+
stage.state === 'waiting' && this.current.state !== 'cancelled' ? t('tournament.stage.waiting') : null,
|
|
1889
|
+
stage.state !== 'done' && this.current.state === 'cancelled' ? t('tournament.stage.cancelled') : null,
|
|
1890
|
+
].filter(Boolean).join(', '))];
|
|
1891
|
+
if (stage.state === 'waiting') {
|
|
1892
|
+
return parts;
|
|
1893
|
+
}
|
|
1894
|
+
if (isBracket(stage)) {
|
|
1895
|
+
parts.push(...sections(stage).map((section) => this.bracket(section)));
|
|
1896
|
+
}
|
|
1897
|
+
for (const table of stage.tables || []) {
|
|
1898
|
+
parts.push(this.table(table, stage, settings));
|
|
1899
|
+
}
|
|
1900
|
+
if (!isBracket(stage)) {
|
|
1901
|
+
parts.push(h('section', { class: 'cup-fixtures', 'aria-label': t('tournament.fixtures') },
|
|
1902
|
+
h('h3', {}, t('tournament.fixtures')),
|
|
1903
|
+
stage.rounds.map((round) => h('div', { class: 'cup-round' },
|
|
1904
|
+
h('h4', {}, this.roundTitle(round, entrants)),
|
|
1905
|
+
h('ul', {}, round.fixtures.map((f) => h('li', {}, this.fixture(f))))))));
|
|
1906
|
+
}
|
|
1907
|
+
return parts;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// The title of a round, with the entrant who has a bye or holds the hill.
|
|
1911
|
+
roundTitle(round, entrants) {
|
|
1912
|
+
let title = serverText(round, 'name');
|
|
1913
|
+
if (round.bye != null) {
|
|
1914
|
+
title = t('tournament.round.bye', { round: title, name: entrantName(entrants[round.bye]) });
|
|
1915
|
+
}
|
|
1916
|
+
if (round.king != null) {
|
|
1917
|
+
title = t('tournament.round.king', { round: title, name: entrantName(entrants[round.king]) });
|
|
1918
|
+
}
|
|
1919
|
+
return title;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
bracket(section) {
|
|
1923
|
+
const layout = bracketLayout(section.rounds);
|
|
1924
|
+
const byId = new Map(section.rounds.flatMap((r) => r.fixtures).map((f) => [f.id, f]));
|
|
1925
|
+
const roundOf = new Map(section.rounds.flatMap((r) => r.fixtures.map((f) => [f.id, r.name])));
|
|
1926
|
+
const grid = h('div', {
|
|
1927
|
+
class: 'cup-bracket', role: 'list',
|
|
1928
|
+
// The width of a round's column comes from the style, wide in the Brackets view, so its zoom
|
|
1929
|
+
// scales the whole bracket.
|
|
1930
|
+
style: `grid-template-columns: repeat(${layout.columns}, var(--cup-column)); grid-template-rows: auto repeat(${layout.rows}, minmax(32px, auto));`,
|
|
1931
|
+
});
|
|
1932
|
+
const name = SECTION_NAMES[section.name] || section.name;
|
|
1933
|
+
const titled = !(section.rounds.length === 1 && serverText(section.rounds[0], 'name') === name);
|
|
1934
|
+
section.rounds.forEach((round, column) => {
|
|
1935
|
+
if (titled) {
|
|
1936
|
+
grid.append(h('div', { class: 'cup-bracket-round', role: 'presentation', style: `grid-column:${column + 1}; grid-row:1` }, serverText(round, 'name')));
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
layout.cells.flat().forEach((cell) => {
|
|
1940
|
+
const fixture = byId.get(cell.id);
|
|
1941
|
+
const place = `grid-column:${cell.column}; grid-row:${cell.rowStart + 1} / ${cell.rowEnd + 1}`;
|
|
1942
|
+
// A tie of two byes plays nothing and holds its place in the grid empty.
|
|
1943
|
+
if (fixture.home == null && fixture.away == null) {
|
|
1944
|
+
grid.append(h('div', { class: 'cup-bracket-cell empty', 'aria-hidden': 'true', style: place }));
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
grid.append(h('div', { class: 'cup-bracket-cell', role: 'listitem', style: place }, this.fixture(fixture, true, roundOf.get(cell.id))));
|
|
1948
|
+
});
|
|
1949
|
+
return h('section', { class: 'cup-bracket-section', 'aria-label': name },
|
|
1950
|
+
h('h3', {}, name), h('div', { class: 'cup-bracket-scroll', tabindex: '0', 'aria-label': t('tournament.bracket.scroll.label', { name }) }, grid));
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
fixture(f, compact = false, round = null) {
|
|
1954
|
+
const cup = this.current;
|
|
1955
|
+
const entrants = cup.config.entrants;
|
|
1956
|
+
const state = fixtureState(f, this.matchMap, cup.state === 'cancelled');
|
|
1957
|
+
const matches = fixtureMatches(f, this.matchMap);
|
|
1958
|
+
// Two entrants of one team meet with the away side in its away kit, as in the match.
|
|
1959
|
+
const same = typeof f.home === 'number' && typeof f.away === 'number' && entrants[f.home].team === entrants[f.away].team;
|
|
1960
|
+
const standard = cup.config.ability ?? null;
|
|
1961
|
+
// A side's name for a screen reader, with its own ability where it differs from the standard.
|
|
1962
|
+
const spokenSide = (value) => [sideName(value, entrants), typeof value === 'number' ? abilityWords(entrants[value], standard) : ''].filter(Boolean).join(', ');
|
|
1963
|
+
const side = (value, index) => {
|
|
1964
|
+
const known = typeof value === 'number';
|
|
1965
|
+
const won = f.winner !== null && f.winner !== undefined && f.winner === value && known;
|
|
1966
|
+
const team = known ? this.teams.find((x) => x.id === entrants[value].team) : null;
|
|
1967
|
+
const away = same && index === 1;
|
|
1968
|
+
const dot = known ? (away ? entrants[value].secondary : entrants[value].color) : null;
|
|
1969
|
+
return h('span', { class: `cup-side-name${won ? ' won' : ''}${known ? '' : ' unknown'}` },
|
|
1970
|
+
known && team ? kitShirt(away ? awayTeam(team) : team, index) : known ? h('span', { class: 'cup-dot', style: `background: rgb(${dot.join(',')})`, 'aria-hidden': 'true' }) : null,
|
|
1971
|
+
h('span', {}, sideName(value, entrants)),
|
|
1972
|
+
known ? abilityBadge(entrants[value], standard) : null,
|
|
1973
|
+
f.score ? h('b', { class: 'cup-goals' }, String(f.score[index])) : null);
|
|
1974
|
+
};
|
|
1975
|
+
const running = matches.find((m) => m.state === 'running');
|
|
1976
|
+
// How a tie was decided, left out where the score of its replay or deciding matches
|
|
1977
|
+
// already says so, after those scores, in the order things happened.
|
|
1978
|
+
const told = (f.decided_by === 'replay' && f.replay) || (f.decided_by === 'decider' && f.decider);
|
|
1979
|
+
const decided = !told && f.decided_by && DECIDED_NAMES[f.decided_by] ? DECIDED_NAMES[f.decided_by] : '';
|
|
1980
|
+
const watch = matches.filter((m) => m.result && m.result.match);
|
|
1981
|
+
const unseen = matches.filter((m) => m.state === 'unseen');
|
|
1982
|
+
// A failed match waits for an award to either side, or a retry by Resume.
|
|
1983
|
+
const failed = matches.find((m) => m.state === 'failed');
|
|
1984
|
+
const label = f.label ? serverText(f, 'label') : '';
|
|
1985
|
+
// A replay and the deciding matches have scores of their own beside the tie's.
|
|
1986
|
+
const extra = [f.replay ? t('tournament.fixture.replay', { left: f.replay[0], right: f.replay[1] }) : null,
|
|
1987
|
+
f.decider ? t('tournament.fixture.decider', { left: f.decider[0], right: f.decider[1] }) : null];
|
|
1988
|
+
const winner = typeof f.winner === 'number' && f.state === 'done' ? t('tournament.fixture.wonBy', { name: sideName(f.winner, entrants) }) : null;
|
|
1989
|
+
const spoken = [label, t('tournament.fixture.versus', { home: spokenSide(f.home), away: spokenSide(f.away) }),
|
|
1990
|
+
f.score ? t('tournament.fixture.score', { left: f.score[0], right: f.score[1] }) : null, ...extra, decided, winner, forfeitWords(matches), state !== 'done' ? STATE_NAMES[state] || state : null];
|
|
1991
|
+
return h('div', { class: `cup-fixture state-${state}`, 'aria-label': spoken.filter(Boolean).join(', ') },
|
|
1992
|
+
compact && label && f.label !== round ? h('span', { class: 'cup-fixture-label small muted' }, label) : null,
|
|
1993
|
+
side(f.home, 0), side(f.away, 1),
|
|
1994
|
+
h('span', { class: 'cup-fixture-meta small muted' },
|
|
1995
|
+
[...extra, decided, state !== 'done' ? STATE_NAMES[state] : null,
|
|
1996
|
+
running ? t('tournament.fixture.percent', { pct: Math.round((running.progress || 0) * 100) }) : null,
|
|
1997
|
+
f.group ? t('tournament.fixture.group', { group: f.group }) : null,
|
|
1998
|
+
forfeitWords(matches),
|
|
1999
|
+
failed && failed.error ? serverText(failed, 'error') : null].filter(Boolean).join(', ')),
|
|
2000
|
+
watch.length ? h('span', { class: 'cup-watch row' }, watch.map((m, i) => h('button', {
|
|
2001
|
+
type: 'button', class: 'btn small ghost', 'data-focus': `watch-${m.key}`,
|
|
2002
|
+
...symbolOnly(t('tournament.watch.label', { label: serverText(m, 'label'), home: entrantName(entrants[m.home]), away: entrantName(entrants[m.away]) })),
|
|
2003
|
+
onclick: () => this.app.openMatchInWatch(m.result.match),
|
|
2004
|
+
}, icon('watch'), watch.length > 1 ? formatNumber(i + 1) : null))) : null,
|
|
2005
|
+
// Each match gives a pair of buttons, so the pairs flatten into one row.
|
|
2006
|
+
unseen.length ? h('span', { class: 'cup-unseen row' }, unseen.flatMap((m) => this.unseenButtons(m))) : null,
|
|
2007
|
+
failed ? h('span', { class: 'row' }, this.forfeitButton(failed, 'home'), this.forfeitButton(failed, 'away')) : null,
|
|
2008
|
+
matches.some((m) => m.state === 'person') ? h('button', { type: 'button', class: 'btn small primary', 'data-focus': `play-${f.id}`, disabled: cup.state !== 'running', ...symbolOnly(t('tournament.play')), onclick: () => this.playMatch(matches.find((m) => m.state === 'person')) }, icon('play')) : null);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
table(table, stage, settings) {
|
|
2012
|
+
const entrants = this.current.config.entrants;
|
|
2013
|
+
const swiss = stage.format === 'swiss';
|
|
2014
|
+
const king = stage.format === 'king_of_the_hill';
|
|
2015
|
+
const head = ['place', 'entrant', 'played', 'won', 'drawn', 'lost', 'for', 'against', 'difference', 'points'].map((name) => t(`tournament.table.${name}`));
|
|
2016
|
+
if (swiss) {
|
|
2017
|
+
head.push(t('tournament.table.buchholz'), t('tournament.table.byes'));
|
|
2018
|
+
}
|
|
2019
|
+
if (king) {
|
|
2020
|
+
head.push(t('tournament.table.titles'));
|
|
2021
|
+
}
|
|
2022
|
+
const notes = [];
|
|
2023
|
+
const rows = table.rows.map((row, index) => {
|
|
2024
|
+
const next = table.rows[index + 1];
|
|
2025
|
+
const reason = tiebreakText(row, next, entrants);
|
|
2026
|
+
if (reason) {
|
|
2027
|
+
notes.push(h('li', {}, t('tournament.table.note', { name: entrantName(entrants[row.entrant]), reason })));
|
|
2028
|
+
}
|
|
2029
|
+
const qualified = index < table.qualify || row.best;
|
|
2030
|
+
const cells = [row.position, entrantName(entrants[row.entrant]), row.played, row.won, row.drawn, row.lost, row.for, row.against, row.difference, row.points];
|
|
2031
|
+
if (swiss) {
|
|
2032
|
+
cells.push(row.buchholz, row.byes);
|
|
2033
|
+
}
|
|
2034
|
+
if (king) {
|
|
2035
|
+
cells.push(row.titles);
|
|
2036
|
+
}
|
|
2037
|
+
return h('tr', { class: qualified && stage.index < this.current.config.stages.length - 1 ? 'qualified' : '' },
|
|
2038
|
+
cells.map((value, i) => (i === 1 ? h('th', { scope: 'row' }, String(value), abilityBadge(entrants[row.entrant], this.current.config.ability ?? null), row.best ? h('span', { class: 'muted small' }, t('tournament.table.best')) : null) : h('td', {}, String(value)))));
|
|
2039
|
+
});
|
|
2040
|
+
const name = serverText(table, 'name');
|
|
2041
|
+
return h('section', { class: 'cup-table' },
|
|
2042
|
+
h('h3', {}, name),
|
|
2043
|
+
h('div', { class: 'cup-table-scroll', tabindex: '0', 'aria-label': t('tournament.table.scroll.label', { name }) },
|
|
2044
|
+
h('table', {}, h('caption', { class: 'muted small' }, tableCaption(stage.format, settings.tiebreakers)),
|
|
2045
|
+
h('thead', {}, h('tr', {}, head.map((name) => h('th', { scope: 'col' }, name)))),
|
|
2046
|
+
h('tbody', {}, rows))),
|
|
2047
|
+
notes.length ? h('ul', { class: 'cup-tiebreaks small' }, notes) : null);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
async act(action, message) {
|
|
2051
|
+
try {
|
|
2052
|
+
await tournamentsApi.action(this.current.id, action);
|
|
2053
|
+
this.app.hud.toast(message);
|
|
2054
|
+
await this.refresh();
|
|
2055
|
+
} catch (err) {
|
|
2056
|
+
this.app.hud.toast(err.message, 'error', 7000);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// Deletes the open tournament, once its question has been answered, and gives the focus to
|
|
2061
|
+
// the list of tournaments.
|
|
2062
|
+
async remove() {
|
|
2063
|
+
const cup = this.current;
|
|
2064
|
+
try {
|
|
2065
|
+
await tournamentsApi.remove(cup.id);
|
|
2066
|
+
this.mode = 'empty';
|
|
2067
|
+
this.openId = null;
|
|
2068
|
+
this.current = null;
|
|
2069
|
+
await this.refresh();
|
|
2070
|
+
(this.listHost.querySelector('.cup-item') || this.root.querySelector('.cup-side-head button'))?.focus();
|
|
2071
|
+
} catch (err) {
|
|
2072
|
+
this.renderView();
|
|
2073
|
+
this.app.hud.toast(err.message, 'error');
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
async playMatch(match) {
|
|
2078
|
+
const cup = this.current;
|
|
2079
|
+
const app = this.app;
|
|
2080
|
+
try {
|
|
2081
|
+
const entry = await tournamentsApi.action(cup.id, 'play', { key: match.key });
|
|
2082
|
+
const { PlayPage } = await import('./play.js');
|
|
2083
|
+
app.play = app.play || new PlayPage(app);
|
|
2084
|
+
const id = cup.id;
|
|
2085
|
+
app.play.begin(entry, match.persons, {
|
|
2086
|
+
label: t('tournament.play.label', { name: serverText(cup, 'name'), label: serverText(match, 'label') }),
|
|
2087
|
+
back: () => {
|
|
2088
|
+
app.showPage('tournaments');
|
|
2089
|
+
this.open(id);
|
|
2090
|
+
},
|
|
2091
|
+
});
|
|
2092
|
+
app.showPage('play');
|
|
2093
|
+
} catch (err) {
|
|
2094
|
+
app.hud.toast(err.status === 409 ? t('tournament.play.busy') : err.message, 'error', 7000);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// The trophy ceremony, the podium and confetti of the show after a match, in the colors of the
|
|
2099
|
+
// champion, over the last match the champion played that has a recording.
|
|
2100
|
+
async ceremony() {
|
|
2101
|
+
const cup = this.current;
|
|
2102
|
+
const app = this.app;
|
|
2103
|
+
const champion = cup.champion_index;
|
|
2104
|
+
const played = cup.matches.filter((m) => m.result && m.result.match && !m.decider && (m.home === champion || m.away === champion));
|
|
2105
|
+
const last = played[played.length - 1];
|
|
2106
|
+
if (!last) {
|
|
2107
|
+
app.hud.toast(t('tournament.ceremony.none', { champion: serverText(cup, 'champion'), name: serverText(cup, 'name') }), 'info', 7000);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
// A game of the Play page may still be the source, ended, quit or stopped, and its
|
|
2111
|
+
// scoreboard and live state would show while the recording loads, so they hide at once.
|
|
2112
|
+
const ended = app.source && app.source.live ? app.source : null;
|
|
2113
|
+
if (ended) {
|
|
2114
|
+
app.hud.hide();
|
|
2115
|
+
document.getElementById('app')?.classList.add('no-source');
|
|
2116
|
+
}
|
|
2117
|
+
await app.openMatch(last.result.match);
|
|
2118
|
+
if (ended && app.source === ended) {
|
|
2119
|
+
document.getElementById('app')?.classList.remove('no-source');
|
|
2120
|
+
}
|
|
2121
|
+
if (!app.timeline || !app.source) {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (app.director.running) {
|
|
2125
|
+
app.director.stop(false);
|
|
2126
|
+
}
|
|
2127
|
+
app.timeline.seek(app.timeline.last);
|
|
2128
|
+
app.timeline.pause();
|
|
2129
|
+
if (typeof app.afterSeek === 'function') {
|
|
2130
|
+
app.afterSeek();
|
|
2131
|
+
}
|
|
2132
|
+
const runnerUp = cup.ranking && cup.ranking.length > 1 ? entrantName(cup.config.entrants[cup.ranking[1]]) : null;
|
|
2133
|
+
app.podium.show(null, {
|
|
2134
|
+
team: last.home === champion ? 0 : 1,
|
|
2135
|
+
title: serverText(cup, 'champion'),
|
|
2136
|
+
subtitle: runnerUp ? t('tournament.ceremony.subtitle.runnerUp', { name: serverText(cup, 'name'), runnerUp }) : t('tournament.ceremony.subtitle', { name: serverText(cup, 'name') }),
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// Watching without spoilers.
|
|
2141
|
+
|
|
2142
|
+
// The switch of a tournament without spoilers and, while it is on, Watch next, the count of
|
|
2143
|
+
// the matches to watch, Reveal all, the autoplay of the next match and the predictions.
|
|
2144
|
+
spoilerBar(cup) {
|
|
2145
|
+
const on = spoilerFree(cup.id);
|
|
2146
|
+
const box = h('input', { type: 'checkbox', class: 'cup-spoiler-switch', role: 'switch', checked: on, 'aria-describedby': 'cup-spoiler-note' });
|
|
2147
|
+
box.addEventListener('change', async () => {
|
|
2148
|
+
setSpoilerFree(cup.id, box.checked);
|
|
2149
|
+
this.revealing = false;
|
|
2150
|
+
this.renderList();
|
|
2151
|
+
await this.load(cup.id);
|
|
2152
|
+
this.mainHost.querySelector('.cup-spoiler-switch')?.focus();
|
|
2153
|
+
});
|
|
2154
|
+
const parts = [h('label', { class: 'check' }, box, t('tournament.spoilers.switch'))];
|
|
2155
|
+
const spoilers = cup.spoilers || null;
|
|
2156
|
+
if (!on || !spoilers) {
|
|
2157
|
+
parts.push(h('span', { class: 'muted small', id: 'cup-spoiler-note' }, t('tournament.spoilers.note')));
|
|
2158
|
+
return h('div', { class: 'cup-spoilers', role: 'group', 'aria-label': t('tournament.spoilers.label') }, parts);
|
|
2159
|
+
}
|
|
2160
|
+
const next = spoilers.unseen.length ? this.matchMap.get(spoilers.unseen[0]) : null;
|
|
2161
|
+
parts.push(h('span', { class: 'small', id: 'cup-spoiler-note', role: 'status' },
|
|
2162
|
+
spoilers.hidden ? t('tournament.spoilers.hidden', { count: spoilers.hidden }) : t('tournament.spoilers.none')));
|
|
2163
|
+
if (next) {
|
|
2164
|
+
parts.push(h('button', { type: 'button', class: 'btn primary cup-watch-next', 'data-focus': 'watch-next', onclick: () => this.watchMatch(cup.id, next) }, icon('watch'), t('tournament.spoilers.next')));
|
|
2165
|
+
}
|
|
2166
|
+
const auto = h('input', { type: 'checkbox', class: 'cup-autoplay', checked: autoplay(cup.id) });
|
|
2167
|
+
auto.addEventListener('change', () => setAutoplay(cup.id, auto.checked));
|
|
2168
|
+
parts.push(h('label', { class: 'check small' }, auto, t('tournament.spoilers.autoplay')));
|
|
2169
|
+
if (spoilers.hidden) {
|
|
2170
|
+
parts.push(this.revealAllButton(cup));
|
|
2171
|
+
}
|
|
2172
|
+
const score = predictionScore(cup.id, cup.matches);
|
|
2173
|
+
if (score.scored) {
|
|
2174
|
+
parts.push(h('span', { class: 'small cup-prediction-score' }, icon('star', 'icon text-symbol'), t('tournament.predictions.score', { right: score.right, total: score.scored })));
|
|
2175
|
+
}
|
|
2176
|
+
return h('div', { class: 'cup-spoilers on', role: 'group', 'aria-label': t('tournament.spoilers.label') }, parts);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// Reveal all, which asks first in place of the button, as an award does.
|
|
2180
|
+
revealAllButton(cup) {
|
|
2181
|
+
if (!this.revealing) {
|
|
2182
|
+
return h('button', { type: 'button', class: 'btn small ghost', 'data-focus': 'reveal-all', onclick: () => {
|
|
2183
|
+
this.revealing = true;
|
|
2184
|
+
this.renderView();
|
|
2185
|
+
this.mainHost.querySelector('[data-focus="reveal-no"]')?.focus();
|
|
2186
|
+
} }, icon('eye'), t('tournament.spoilers.revealAll'));
|
|
2187
|
+
}
|
|
2188
|
+
const cancel = () => {
|
|
2189
|
+
this.revealing = false;
|
|
2190
|
+
this.renderView();
|
|
2191
|
+
this.mainHost.querySelector('[data-focus="reveal-all"]')?.focus();
|
|
2192
|
+
};
|
|
2193
|
+
const question = t('tournament.spoilers.revealAll.confirm', { count: cup.spoilers.hidden });
|
|
2194
|
+
return h('span', {
|
|
2195
|
+
class: 'cup-confirm', role: 'group', 'aria-label': question,
|
|
2196
|
+
onkeydown: (e) => {
|
|
2197
|
+
if (e.key === 'Escape') {
|
|
2198
|
+
e.preventDefault();
|
|
2199
|
+
e.stopPropagation();
|
|
2200
|
+
cancel();
|
|
2201
|
+
}
|
|
2202
|
+
},
|
|
2203
|
+
}, h('span', { class: 'small' }, question),
|
|
2204
|
+
h('button', { type: 'button', class: 'btn small primary', 'data-focus': 'reveal-yes', onclick: async () => {
|
|
2205
|
+
this.revealing = false;
|
|
2206
|
+
// The matches revealed here join those watched in this visit, so the summary of what
|
|
2207
|
+
// the person missed lists them too.
|
|
2208
|
+
const seen = new Set(watchedKeys(cup.id));
|
|
2209
|
+
const fresh = (cup.spoilers.played || []).filter((key) => !seen.has(key));
|
|
2210
|
+
(this.session[cup.id] = this.session[cup.id] || []).push(...fresh);
|
|
2211
|
+
markWatched(cup.id, ...(cup.spoilers.played || []));
|
|
2212
|
+
await this.load(cup.id);
|
|
2213
|
+
this.app.hud.toast(t('tournament.spoilers.revealed.all'));
|
|
2214
|
+
this.mainHost.querySelector('.cup-spoiler-switch')?.focus();
|
|
2215
|
+
} }, icon('eye'), t('tournament.spoilers.revealAll.yes')),
|
|
2216
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': 'reveal-no', ...symbolOnly(t('tournament.forfeit.no')), onclick: cancel }, icon('close')));
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Watch and Reveal for a played match that the person has not watched yet.
|
|
2220
|
+
unseenButtons(match) {
|
|
2221
|
+
const cup = this.current;
|
|
2222
|
+
const entrants = cup.config.entrants;
|
|
2223
|
+
const names = { label: serverText(match, 'label'), home: entrantName(entrants[match.home]), away: entrantName(entrants[match.away]) };
|
|
2224
|
+
return [
|
|
2225
|
+
match.unseen && match.unseen.match ? h('button', {
|
|
2226
|
+
type: 'button', class: 'btn small primary', 'data-focus': `unseen-watch-${match.key}`,
|
|
2227
|
+
...symbolOnly(t('tournament.spoilers.watch.label', names)),
|
|
2228
|
+
onclick: () => this.watchMatch(cup.id, match),
|
|
2229
|
+
}, icon('watch')) : null,
|
|
2230
|
+
h('button', {
|
|
2231
|
+
type: 'button', class: 'btn small ghost', 'data-focus': `unseen-reveal-${match.key}`,
|
|
2232
|
+
...symbolOnly(t('tournament.spoilers.reveal.label', names)),
|
|
2233
|
+
onclick: () => this.reveal(cup.id, match.key),
|
|
2234
|
+
}, icon('eye')),
|
|
2235
|
+
];
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// Watch next of a tournament from anywhere, as the palette offers it.
|
|
2239
|
+
async watchNextOf(id) {
|
|
2240
|
+
let entry;
|
|
2241
|
+
try {
|
|
2242
|
+
entry = await this.fetchCup(id);
|
|
2243
|
+
} catch (err) {
|
|
2244
|
+
this.app.hud.toast(err.message, 'error');
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const key = entry.spoilers && entry.spoilers.unseen[0];
|
|
2248
|
+
const next = key ? entry.matches.find((m) => m.key === key) : null;
|
|
2249
|
+
if (next) {
|
|
2250
|
+
this.watchMatch(id, next);
|
|
2251
|
+
} else {
|
|
2252
|
+
this.app.hud.toast(t('tournament.spoilers.caughtUp.toast', { name: serverText(entry, 'name') }), 'info', 6000);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// Marks a match watched on purpose and shows its result.
|
|
2257
|
+
async reveal(id, key) {
|
|
2258
|
+
markWatched(id, key);
|
|
2259
|
+
(this.session[id] = this.session[id] || []).push(key);
|
|
2260
|
+
await this.load(id);
|
|
2261
|
+
this.app.hud.toast(t('tournament.spoilers.revealed'));
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Plays a match that waits to be watched on Watch, which marks it watched at its end. A match
|
|
2265
|
+
// awarded without a recording reveals at once.
|
|
2266
|
+
watchMatch(id, match) {
|
|
2267
|
+
this.closeIntermission();
|
|
2268
|
+
if (!match || !match.unseen) {
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (!match.unseen.match) {
|
|
2272
|
+
this.reveal(id, match.key);
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
this.watching = { id, key: match.key, recording: match.unseen.match };
|
|
2276
|
+
this.app.openMatchInWatch(match.unseen.match);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// A recording that played to its end marks its match watched, when it is the one that Watch
|
|
2280
|
+
// next opened or a match of a tournament without spoilers, and shows the card of what follows.
|
|
2281
|
+
async recordingEnded(detail) {
|
|
2282
|
+
const cup = detail.provenance && detail.provenance.tournament;
|
|
2283
|
+
let id = null;
|
|
2284
|
+
let key = null;
|
|
2285
|
+
if (this.watching && detail.id === this.watching.recording) {
|
|
2286
|
+
({ id, key } = this.watching);
|
|
2287
|
+
} else if (cup && typeof cup.id === 'string' && typeof cup.key === 'string' && spoilerFree(cup.id)) {
|
|
2288
|
+
({ id, key } = cup);
|
|
2289
|
+
}
|
|
2290
|
+
this.watching = null;
|
|
2291
|
+
if (!id || !spoilerFree(id) || watchedKeys(id).includes(key)) {
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
markWatched(id, key);
|
|
2295
|
+
(this.session[id] = this.session[id] || []).push(key);
|
|
2296
|
+
let entry;
|
|
2297
|
+
try {
|
|
2298
|
+
entry = await this.fetchCup(id);
|
|
2299
|
+
} catch {
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if (this.openId === id) {
|
|
2303
|
+
this.current = entry;
|
|
2304
|
+
this.renderView();
|
|
2305
|
+
}
|
|
2306
|
+
const next = entry.spoilers && entry.spoilers.unseen.length
|
|
2307
|
+
? entry.matches.find((m) => m.key === entry.spoilers.unseen[0]) : null;
|
|
2308
|
+
if (next) {
|
|
2309
|
+
this.showIntermission(entry, next, autoplay(id));
|
|
2310
|
+
} else if (entry.spoilers && !entry.spoilers.hidden) {
|
|
2311
|
+
this.app.hud.toast(t('tournament.spoilers.caughtUp.toast', { name: serverText(entry, 'name') }), 'info', 6000);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// The card between two matches on Watch, which names only who plays next, the final with a
|
|
2316
|
+
// card of its own, and with autoplay counts down to it. Play now, Escape and Cancel answer it.
|
|
2317
|
+
showIntermission(entry, next, counting) {
|
|
2318
|
+
this.closeIntermission();
|
|
2319
|
+
const host = document.getElementById('page-watch');
|
|
2320
|
+
if (!host || this.app.page !== 'watch') {
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
if (typeof this.app.releasePointer === 'function') {
|
|
2324
|
+
this.app.releasePointer();
|
|
2325
|
+
}
|
|
2326
|
+
const entrants = entry.config.entrants;
|
|
2327
|
+
const final = isFinal(next);
|
|
2328
|
+
const id = entry.id;
|
|
2329
|
+
// A timer reads its count when asked, so a screen reader is not told every second.
|
|
2330
|
+
const left = h('span', { class: 'cup-count small', role: 'timer' });
|
|
2331
|
+
let seconds = 10;
|
|
2332
|
+
const tick = () => {
|
|
2333
|
+
left.textContent = counting ? t('tournament.intermission.count', { count: seconds }) : '';
|
|
2334
|
+
};
|
|
2335
|
+
const play = h('button', { type: 'button', class: 'btn primary', onclick: () => this.watchMatch(id, next) }, icon('play'), t('tournament.intermission.play'));
|
|
2336
|
+
const card = h('section', {
|
|
2337
|
+
class: `cup-intermission${final ? ' final' : ''}`, 'aria-labelledby': 'cup-intermission-title',
|
|
2338
|
+
onkeydown: (e) => {
|
|
2339
|
+
if (e.key === 'Escape') {
|
|
2340
|
+
e.preventDefault();
|
|
2341
|
+
e.stopPropagation();
|
|
2342
|
+
this.closeIntermission();
|
|
2343
|
+
}
|
|
2344
|
+
},
|
|
2345
|
+
},
|
|
2346
|
+
h('h2', { id: 'cup-intermission-title' }, final ? icon('trophy', 'icon text-symbol') : null,
|
|
2347
|
+
final ? t('tournament.intermission.final') : t('tournament.intermission.next')),
|
|
2348
|
+
h('p', { class: 'cup-intermission-stage small muted' }, `${serverText(entry, 'name')}, ${serverText(next, 'label')}`),
|
|
2349
|
+
h('p', { class: 'cup-intermission-sides' },
|
|
2350
|
+
this.kickoffSide(entrants[next.home], 0), h('span', { class: 'cup-versus' }, t('tournament.intermission.versus')), this.kickoffSide(entrants[next.away], 1, entrants[next.home])),
|
|
2351
|
+
left,
|
|
2352
|
+
h('div', { class: 'row' }, play,
|
|
2353
|
+
h('button', { type: 'button', class: 'btn ghost', onclick: () => this.closeIntermission() }, icon('close'), t('tournament.intermission.cancel'))));
|
|
2354
|
+
tick();
|
|
2355
|
+
const before = document.activeElement;
|
|
2356
|
+
host.append(card);
|
|
2357
|
+
this.intermission = { card, timer: null, before };
|
|
2358
|
+
if (counting) {
|
|
2359
|
+
this.intermission.timer = setInterval(() => {
|
|
2360
|
+
// A person who left Watch keeps the next match from starting there.
|
|
2361
|
+
if (this.app.page !== 'watch') {
|
|
2362
|
+
this.closeIntermission();
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
seconds -= 1;
|
|
2366
|
+
tick();
|
|
2367
|
+
if (seconds <= 0) {
|
|
2368
|
+
this.watchMatch(id, next);
|
|
2369
|
+
}
|
|
2370
|
+
}, 1000);
|
|
2371
|
+
}
|
|
2372
|
+
play.focus({ preventScroll: true });
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// Closes the card between matches. A card that held the focus gives it back to where it was
|
|
2376
|
+
// before the card opened, else to the play button of Watch.
|
|
2377
|
+
closeIntermission() {
|
|
2378
|
+
if (!this.intermission) {
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
const { card, timer, before } = this.intermission;
|
|
2382
|
+
const held = card.contains(document.activeElement);
|
|
2383
|
+
clearInterval(timer);
|
|
2384
|
+
card.remove();
|
|
2385
|
+
this.intermission = null;
|
|
2386
|
+
if (held) {
|
|
2387
|
+
const back = before && before.isConnected && before !== document.body ? before : document.getElementById('btn-play');
|
|
2388
|
+
back?.focus({ preventScroll: true });
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// A side of a kickoff card, the kit of its team or a mark of its color, and its name. Two
|
|
2393
|
+
// entrants of one team meet with the away side in its away kit, as in the match.
|
|
2394
|
+
kickoffSide(entrant, index, other = null) {
|
|
2395
|
+
const team = entrant ? this.teams.find((x) => x.id === entrant.team) : null;
|
|
2396
|
+
const away = index === 1 && !!entrant && !!other && entrant.team === other.team;
|
|
2397
|
+
const color = (away ? entrant && entrant.secondary : entrant && entrant.color) || [128, 128, 128];
|
|
2398
|
+
return h('span', { class: `cup-kickoff-side${away ? ' away' : ''}` },
|
|
2399
|
+
team ? kitShirt(away ? awayTeam(team) : team, index) : entrant ? h('span', { class: 'cup-dot', style: `background: rgb(${color.join(',')})`, 'aria-hidden': 'true' }) : null,
|
|
2400
|
+
h('span', {}, entrantName(entrant)));
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// The next matches of the tournament as kickoff cards, those to watch first, each naming who
|
|
2404
|
+
// plays, the final with a card of its own, and the prediction of its result.
|
|
2405
|
+
upNextBox(cup) {
|
|
2406
|
+
const next = upNext(cup, 4);
|
|
2407
|
+
if (!next.length) {
|
|
2408
|
+
return null;
|
|
2409
|
+
}
|
|
2410
|
+
const entrants = cup.config.entrants;
|
|
2411
|
+
const cards = next.map((m) => {
|
|
2412
|
+
const final = isFinal(m);
|
|
2413
|
+
const pick = prediction(cup.id, m.key);
|
|
2414
|
+
const choose = (side, words) => h('button', {
|
|
2415
|
+
type: 'button', class: `btn small${pick === side ? ' primary' : ' ghost'}`, 'aria-pressed': pick === side ? 'true' : 'false',
|
|
2416
|
+
'data-focus': `predict-${m.key}-${side}`,
|
|
2417
|
+
onclick: () => {
|
|
2418
|
+
predict(cup.id, m.key, pick === side ? null : side);
|
|
2419
|
+
this.renderView();
|
|
2420
|
+
},
|
|
2421
|
+
}, words);
|
|
2422
|
+
return h('li', { class: `cup-kickoff${final ? ' final' : ''}` },
|
|
2423
|
+
// The label of a final names it already, beside the symbol of the trophy.
|
|
2424
|
+
h('p', { class: 'cup-kickoff-label small muted' }, final ? icon('trophy', 'icon text-symbol') : null, serverText(m, 'label')),
|
|
2425
|
+
h('p', { class: 'cup-kickoff-sides' }, this.kickoffSide(entrants[m.home], 0), h('span', { class: 'cup-versus' }, t('tournament.intermission.versus')), this.kickoffSide(entrants[m.away], 1, entrants[m.home])),
|
|
2426
|
+
h('p', { class: 'small muted' }, STATE_NAMES[m.state] || ''),
|
|
2427
|
+
h('div', { class: 'cup-predict row', role: 'group', 'aria-label': t('tournament.predictions.label', { label: serverText(m, 'label') }) },
|
|
2428
|
+
choose('home', entrantName(entrants[m.home])), choose('draw', t('tournament.predictions.draw')), choose('away', entrantName(entrants[m.away]))),
|
|
2429
|
+
m.state === 'unseen' ? h('span', { class: 'row' }, this.unseenButtons(m)) : null);
|
|
2430
|
+
});
|
|
2431
|
+
return h('section', { class: 'cup-up-next', 'aria-labelledby': 'cup-up-next-title' },
|
|
2432
|
+
h('h3', { id: 'cup-up-next-title' }, t('tournament.upNext.title')),
|
|
2433
|
+
h('ol', { class: 'cup-kickoffs' }, cards));
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// Once the person has watched every played match, the results of those watched in this visit,
|
|
2437
|
+
// what they missed. The score of the predictions shows once, in the bar of spoilers above.
|
|
2438
|
+
caughtUp(cup) {
|
|
2439
|
+
const watchedNow = this.session[cup.id] || [];
|
|
2440
|
+
if (!spoilerFree(cup.id) || !cup.spoilers || cup.spoilers.hidden || !watchedNow.length) {
|
|
2441
|
+
return null;
|
|
2442
|
+
}
|
|
2443
|
+
const entrants = cup.config.entrants;
|
|
2444
|
+
const results = watchedNow.map((key) => this.matchMap.get(key)).filter((m) => m && m.result && Array.isArray(m.result.score));
|
|
2445
|
+
return h('section', { class: 'cup-caught-up notice', 'aria-labelledby': 'cup-caught-up-title' },
|
|
2446
|
+
h('h3', { id: 'cup-caught-up-title' }, icon('done', 'icon text-symbol'), t('tournament.caughtUp.title')),
|
|
2447
|
+
h('ul', {}, results.map((m) => h('li', {}, t('tournament.caughtUp.result', {
|
|
2448
|
+
label: serverText(m, 'label'), home: entrantName(entrants[m.home]), away: entrantName(entrants[m.away]), left: m.result.score[0], right: m.result.score[1],
|
|
2449
|
+
})))),
|
|
2450
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': 'caught-up-close', onclick: () => {
|
|
2451
|
+
delete this.session[cup.id];
|
|
2452
|
+
this.renderView();
|
|
2453
|
+
} }, icon('close'), t('tournament.caughtUp.close')));
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// The Brackets view.
|
|
2457
|
+
|
|
2458
|
+
// Opens the brackets, groups and tables of a tournament large, in a view of their own.
|
|
2459
|
+
async openBrackets(id) {
|
|
2460
|
+
this.mode = 'brackets';
|
|
2461
|
+
this.openId = id;
|
|
2462
|
+
this.stageTab = null;
|
|
2463
|
+
this.renderList();
|
|
2464
|
+
await this.load(id);
|
|
2465
|
+
this.mainHost.querySelector('h2')?.focus();
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
closeBrackets() {
|
|
2469
|
+
this.mode = 'view';
|
|
2470
|
+
this.root.querySelector('.cup-layout')?.classList.remove('brackets');
|
|
2471
|
+
this.renderList();
|
|
2472
|
+
this.renderView();
|
|
2473
|
+
this.mainHost.querySelector('[data-focus="act-brackets"]')?.focus();
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
renderBrackets() {
|
|
2477
|
+
const cup = this.current;
|
|
2478
|
+
this.matchMap = new Map(cup.matches.map((m) => [m.key, m]));
|
|
2479
|
+
if (this.stageTab === null || this.stageTab >= cup.stages_view.length) {
|
|
2480
|
+
const playing = cup.stages_view.findIndex((s) => s.state !== 'done');
|
|
2481
|
+
this.stageTab = playing >= 0 ? playing : cup.stages_view.length - 1;
|
|
2482
|
+
}
|
|
2483
|
+
this.root.querySelector('.cup-layout')?.classList.add('brackets');
|
|
2484
|
+
const zoomTo = (value) => {
|
|
2485
|
+
this.zoom = Math.max(0.5, Math.min(2, Math.round(value * 10) / 10));
|
|
2486
|
+
this.renderView();
|
|
2487
|
+
};
|
|
2488
|
+
const tabs = h('div', { class: 'cup-tabs', role: 'tablist', 'aria-label': t('tournament.view.stages.label') }, cup.stages_view.map((stage, index) => h('button', {
|
|
2489
|
+
type: 'button', role: 'tab', id: `cup-tab-${index}`, 'aria-selected': index === this.stageTab ? 'true' : 'false',
|
|
2490
|
+
tabindex: index === this.stageTab ? '0' : '-1', class: `cup-tab${index === this.stageTab ? ' active' : ''}`, 'aria-controls': 'cup-stage-panel',
|
|
2491
|
+
onclick: () => {
|
|
2492
|
+
this.stageTab = index;
|
|
2493
|
+
this.renderView();
|
|
2494
|
+
document.getElementById(`cup-tab-${index}`)?.focus();
|
|
2495
|
+
},
|
|
2496
|
+
onkeydown: (e) => {
|
|
2497
|
+
const by = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
|
2498
|
+
if (by) {
|
|
2499
|
+
e.preventDefault();
|
|
2500
|
+
this.stageTab = (index + by + cup.stages_view.length) % cup.stages_view.length;
|
|
2501
|
+
this.renderView();
|
|
2502
|
+
document.getElementById(`cup-tab-${this.stageTab}`)?.focus();
|
|
2503
|
+
}
|
|
2504
|
+
},
|
|
2505
|
+
}, serverText(stage, 'name'))));
|
|
2506
|
+
const stage = cup.stages_view[this.stageTab];
|
|
2507
|
+
const zoom = h('div', { class: 'cup-zoom row', role: 'group', 'aria-label': t('tournament.brackets.zoom.label') },
|
|
2508
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': 'zoom-out', disabled: this.zoom <= 0.5, ...symbolOnly(t('tournament.brackets.zoom.out')), onclick: () => zoomTo(this.zoom - 0.1) }, icon('minus')),
|
|
2509
|
+
h('output', { class: 'small', 'aria-live': 'polite' }, t('tournament.brackets.zoom.value', { percent: Math.round(this.zoom * 100) })),
|
|
2510
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': 'zoom-in', disabled: this.zoom >= 2, ...symbolOnly(t('tournament.brackets.zoom.in')), onclick: () => zoomTo(this.zoom + 0.1) }, icon('plus')),
|
|
2511
|
+
h('button', { type: 'button', class: 'btn small ghost', 'data-focus': 'zoom-reset', ...symbolOnly(t('tournament.brackets.zoom.reset')), onclick: () => zoomTo(1) }, icon('reset')));
|
|
2512
|
+
const head = h('header', { class: 'cup-head cup-brackets-head' },
|
|
2513
|
+
h('h2', { tabindex: '-1', 'data-focus': 'cup-title' }, t('tournament.brackets.title', { name: serverText(cup, 'name') })),
|
|
2514
|
+
h('div', { class: 'row' },
|
|
2515
|
+
h('button', { type: 'button', class: 'btn ghost', 'data-focus': 'brackets-back', onclick: () => this.closeBrackets() }, icon('back'), t('tournament.brackets.back')),
|
|
2516
|
+
zoom,
|
|
2517
|
+
spoilerFree(cup.id) && cup.spoilers && cup.spoilers.unseen.length
|
|
2518
|
+
? h('button', { type: 'button', class: 'btn primary cup-watch-next', 'data-focus': 'watch-next', onclick: () => this.watchMatch(cup.id, this.matchMap.get(cup.spoilers.unseen[0])) }, icon('watch'), t('tournament.spoilers.next'))
|
|
2519
|
+
: null),
|
|
2520
|
+
spoilerFree(cup.id) ? h('p', { class: 'muted small' }, icon('eye', 'icon text-symbol'), t('tournament.spoilers.listed')) : null);
|
|
2521
|
+
const panel = h('div', { class: 'cup-stage-view cup-big', id: 'cup-stage-panel', role: 'tabpanel', 'aria-labelledby': `cup-tab-${this.stageTab}`, style: `--cup-zoom: ${this.zoom}` },
|
|
2522
|
+
this.stageView(stage, cup.config.stages[this.stageTab]));
|
|
2523
|
+
const focused = document.activeElement && this.mainHost.contains(document.activeElement) ? document.activeElement : null;
|
|
2524
|
+
// An action that took the focus with it, such as a confirmed award, names where it was.
|
|
2525
|
+
const focusKey = focused ? (focused.id || focused.dataset.focus || null) : this.focusAfter || null;
|
|
2526
|
+
const byId = !!focused && focusKey === focused.id;
|
|
2527
|
+
this.focusAfter = null;
|
|
2528
|
+
const scroll = this.mainHost.scrollTop;
|
|
2529
|
+
this.mainHost.replaceChildren(h('div', { class: 'cup-view cup-brackets' }, head, tabs, panel));
|
|
2530
|
+
this.mainHost.scrollTop = scroll;
|
|
2531
|
+
if (focusKey) {
|
|
2532
|
+
this.refocus(focusKey, byId);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// Live progress, and a notice when a match of a person is ready.
|
|
2537
|
+
async poll() {
|
|
2538
|
+
if (document.hidden || document.body.classList.contains('offline')) {
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
const busy = this.list.some((cup) => cup.state === 'running');
|
|
2542
|
+
if (!this.visible && (!busy || !this.primed)) {
|
|
2543
|
+
if (!this.primed) {
|
|
2544
|
+
this.primed = true;
|
|
2545
|
+
try {
|
|
2546
|
+
this.list = await tournamentsApi.list();
|
|
2547
|
+
} catch {
|
|
2548
|
+
// The server is away, the next poll tries again.
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
let list;
|
|
2554
|
+
try {
|
|
2555
|
+
list = await tournamentsApi.list();
|
|
2556
|
+
} catch {
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
for (const cup of list) {
|
|
2560
|
+
const key = `${cup.id}|${cup.people_waiting}`;
|
|
2561
|
+
if (cup.people_waiting && cup.state === 'running' && !this.announced.has(key)) {
|
|
2562
|
+
this.announced.add(key);
|
|
2563
|
+
this.app.hud.toast(t(this.visible ? 'tournament.toast.person' : 'tournament.toast.person.away', { name: serverText(cup, 'name') }), 'info', 6000);
|
|
2564
|
+
}
|
|
2565
|
+
const old = this.list.find((x) => x.id === cup.id);
|
|
2566
|
+
if (old && old.state !== 'done' && cup.state === 'done' && !spoilerFree(cup.id)) {
|
|
2567
|
+
this.app.hud.toast(t('tournament.toast.won', { name: serverText(cup, 'name'), champion: serverText(cup, 'champion') }), 'info', 7000);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
const changed = JSON.stringify(list) !== JSON.stringify(this.list);
|
|
2571
|
+
this.list = list;
|
|
2572
|
+
if (!this.visible || !this.built) {
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
if (changed) {
|
|
2576
|
+
this.renderList();
|
|
2577
|
+
}
|
|
2578
|
+
if ((this.mode === 'view' || this.mode === 'brackets') && this.openId) {
|
|
2579
|
+
const summary = list.find((cup) => cup.id === this.openId);
|
|
2580
|
+
if (summary && (changed || summary.running) && !this.mainHost.contains(document.activeElement?.closest('input, select, textarea'))) {
|
|
2581
|
+
await this.load(this.openId);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
}
|