cuju-render 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. cuju/render/__init__.py +759 -0
  2. cuju/render/__main__.py +1065 -0
  3. cuju/render/_export/__init__.py +3 -0
  4. cuju/render/_export/feed.py +542 -0
  5. cuju/render/_export/sizes.py +157 -0
  6. cuju/render/_export/video.py +1618 -0
  7. cuju/render/_export/video_jobs.py +408 -0
  8. cuju/render/_format/__init__.py +3 -0
  9. cuju/render/_format/annotations.py +235 -0
  10. cuju/render/_format/codec.py +307 -0
  11. cuju/render/_format/keep.py +121 -0
  12. cuju/render/_format/recording.py +860 -0
  13. cuju/render/_format/writer.py +54 -0
  14. cuju/render/_guide/__init__.py +3 -0
  15. cuju/render/_guide/doctor.py +523 -0
  16. cuju/render/_guide/help.py +62 -0
  17. cuju/render/_guide/tutorial.py +254 -0
  18. cuju/render/_http/__init__.py +3 -0
  19. cuju/render/_http/commands.py +57 -0
  20. cuju/render/_http/headers.py +103 -0
  21. cuju/render/_http/launch.py +500 -0
  22. cuju/render/_http/launchers.py +408 -0
  23. cuju/render/_http/pwa.py +128 -0
  24. cuju/render/_http/server.py +2105 -0
  25. cuju/render/_http/window.py +360 -0
  26. cuju/render/_moments/__init__.py +3 -0
  27. cuju/render/_moments/captions.py +284 -0
  28. cuju/render/_moments/events.py +1123 -0
  29. cuju/render/_moments/search.py +694 -0
  30. cuju/render/_packages/__init__.py +3 -0
  31. cuju/render/_packages/glsl.py +746 -0
  32. cuju/render/_packages/loudness.py +334 -0
  33. cuju/render/_packages/pack.py +1959 -0
  34. cuju/render/_packages/presentation.py +1376 -0
  35. cuju/render/_packages/vocabulary.py +609 -0
  36. cuju/render/_playing/__init__.py +3 -0
  37. cuju/render/_playing/achievements.py +463 -0
  38. cuju/render/_playing/bench.py +609 -0
  39. cuju/render/_playing/challenges.py +1813 -0
  40. cuju/render/_playing/demo.py +323 -0
  41. cuju/render/_playing/draw.py +211 -0
  42. cuju/render/_playing/errors.py +20 -0
  43. cuju/render/_playing/game.py +645 -0
  44. cuju/render/_playing/limits.py +339 -0
  45. cuju/render/_playing/play.py +926 -0
  46. cuju/render/_playing/policy_worker.py +250 -0
  47. cuju/render/_playing/strict.py +810 -0
  48. cuju/render/_registry/__init__.py +3 -0
  49. cuju/render/_registry/clips.py +394 -0
  50. cuju/render/_registry/joints.py +108 -0
  51. cuju/render/_registry/teams.py +2058 -0
  52. cuju/render/_store/__init__.py +3 -0
  53. cuju/render/_store/home.py +70 -0
  54. cuju/render/_store/library.py +604 -0
  55. cuju/render/_store/samples.py +163 -0
  56. cuju/render/_text/__init__.py +3 -0
  57. cuju/render/_text/languages.py +637 -0
  58. cuju/render/_text/messages.py +1396 -0
  59. cuju/render/_tournaments/__init__.py +3 -0
  60. cuju/render/_tournaments/results.py +547 -0
  61. cuju/render/_tournaments/tournament.py +2742 -0
  62. cuju/render/_tournaments/tournament_cli.py +587 -0
  63. cuju/render/_tournaments/tournament_runner.py +1975 -0
  64. cuju/render/_version.py +6 -0
  65. cuju/render/_watch/__init__.py +3 -0
  66. cuju/render/_watch/grid.py +265 -0
  67. cuju/render/_watch/live.py +664 -0
  68. cuju/render/_watch/notebook.py +79 -0
  69. cuju/render/_watch/show.py +171 -0
  70. cuju/render/samples/sample-11v11.cjr +0 -0
  71. cuju/render/samples/sample-5v5.cjr +0 -0
  72. cuju/render/samples/teams/starter/main.py +33 -0
  73. cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
  74. cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
  75. cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
  76. cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
  77. cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
  78. cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
  79. cuju/render/web/assets/adboard_polygon.webp +0 -0
  80. cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
  81. cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
  82. cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
  83. cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
  84. cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
  85. cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
  86. cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
  87. cuju/render/web/assets/ball.glb +0 -0
  88. cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
  89. cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
  90. cuju/render/web/assets/challenges/corner.mp4 +0 -0
  91. cuju/render/web/assets/challenges/corner.webp +0 -0
  92. cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
  93. cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
  94. cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
  95. cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
  96. cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
  97. cuju/render/web/assets/challenges/empty_goal.webp +0 -0
  98. cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
  99. cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
  100. cuju/render/web/assets/challenges/manifest.json +375 -0
  101. cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
  102. cuju/render/web/assets/challenges/one_on_one.webp +0 -0
  103. cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
  104. cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
  105. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
  106. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
  107. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
  108. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
  109. cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
  110. cuju/render/web/assets/challenges/run_to_score.webp +0 -0
  111. cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
  112. cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
  113. cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
  114. cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
  115. cuju/render/web/assets/crowd.webp +0 -0
  116. cuju/render/web/assets/crowd_normal.webp +0 -0
  117. cuju/render/web/assets/cuju-logo-384.png +0 -0
  118. cuju/render/web/assets/cuju-logo.png +0 -0
  119. cuju/render/web/assets/cuju-logo.svg +46 -0
  120. cuju/render/web/assets/cuju-mark-light.svg +33 -0
  121. cuju/render/web/assets/cuju-mark.svg +33 -0
  122. cuju/render/web/assets/goal.glb +0 -0
  123. cuju/render/web/assets/grass.webp +0 -0
  124. cuju/render/web/assets/hair_black.png +0 -0
  125. cuju/render/web/assets/hair_blonde.png +0 -0
  126. cuju/render/web/assets/hair_brown.png +0 -0
  127. cuju/render/web/assets/hair_darkblonde.png +0 -0
  128. cuju/render/web/assets/hair_long01.glb +0 -0
  129. cuju/render/web/assets/hair_long02.glb +0 -0
  130. cuju/render/web/assets/hair_medium01.glb +0 -0
  131. cuju/render/web/assets/hair_red.png +0 -0
  132. cuju/render/web/assets/hair_short01.glb +0 -0
  133. cuju/render/web/assets/hair_short02.glb +0 -0
  134. cuju/render/web/assets/icons/cuju-192.png +0 -0
  135. cuju/render/web/assets/icons/cuju-512.png +0 -0
  136. cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
  137. cuju/render/web/assets/kit_goalkeeper.webp +0 -0
  138. cuju/render/web/assets/kit_mask.png +0 -0
  139. cuju/render/web/assets/kit_normal.webp +0 -0
  140. cuju/render/web/assets/manifest.json +601 -0
  141. cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
  142. cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
  143. cuju/render/web/assets/models/kaykit-knight.glb +0 -0
  144. cuju/render/web/assets/models/kaykit-knight.json +23 -0
  145. cuju/render/web/assets/models/kaykit-mage.glb +0 -0
  146. cuju/render/web/assets/models/kaykit-mage.json +23 -0
  147. cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
  148. cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
  149. cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
  150. cuju/render/web/assets/models/kaykit-ranger.json +23 -0
  151. cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
  152. cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
  153. cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
  154. cuju/render/web/assets/models/kaykit-rogue.json +23 -0
  155. cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
  156. cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
  157. cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
  158. cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
  159. cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
  160. cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
  161. cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
  162. cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
  163. cuju/render/web/assets/pitch.webp +0 -0
  164. cuju/render/web/assets/pitch_normal.webp +0 -0
  165. cuju/render/web/assets/player.glb +0 -0
  166. cuju/render/web/assets/shoe.webp +0 -0
  167. cuju/render/web/assets/shoe_sole.png +0 -0
  168. cuju/render/web/assets/skin_01.png +0 -0
  169. cuju/render/web/assets/skin_02.png +0 -0
  170. cuju/render/web/assets/skin_03.png +0 -0
  171. cuju/render/web/assets/skin_04.png +0 -0
  172. cuju/render/web/assets/sounds/applause.ogg +0 -0
  173. cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
  174. cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
  175. cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
  176. cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
  177. cuju/render/web/assets/sounds/city-horn.ogg +0 -0
  178. cuju/render/web/assets/sounds/city-loop.ogg +0 -0
  179. cuju/render/web/assets/sounds/city-siren.ogg +0 -0
  180. cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
  181. cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
  182. cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
  183. cuju/render/web/assets/sounds/gulls.ogg +0 -0
  184. cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
  185. cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
  186. cuju/render/web/assets/sounds/sounds.json +46 -0
  187. cuju/render/web/assets/sounds/space-beep.ogg +0 -0
  188. cuju/render/web/assets/sounds/space-hum.ogg +0 -0
  189. cuju/render/web/assets/sounds/space-radio.ogg +0 -0
  190. cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
  191. cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
  192. cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
  193. cuju/render/web/easy.css +357 -0
  194. cuju/render/web/export.html +23 -0
  195. cuju/render/web/feed.css +262 -0
  196. cuju/render/web/feed.html +28 -0
  197. cuju/render/web/flat.css +120 -0
  198. cuju/render/web/home.css +837 -0
  199. cuju/render/web/index.html +373 -0
  200. cuju/render/web/js/boot.js +50 -0
  201. cuju/render/web/js/broadcast/audio.js +2141 -0
  202. cuju/render/web/js/broadcast/celebrate.js +1238 -0
  203. cuju/render/web/js/broadcast/decisions.js +609 -0
  204. cuju/render/web/js/broadcast/hud.js +352 -0
  205. cuju/render/web/js/broadcast/show.js +3614 -0
  206. cuju/render/web/js/broadcast/transition.js +2231 -0
  207. cuju/render/web/js/broadcast/voices.js +153 -0
  208. cuju/render/web/js/capture.js +101 -0
  209. cuju/render/web/js/core/accessibility.js +90 -0
  210. cuju/render/web/js/core/api.js +429 -0
  211. cuju/render/web/js/core/comfort.js +135 -0
  212. cuju/render/web/js/core/i18n.js +705 -0
  213. cuju/render/web/js/core/icons.js +167 -0
  214. cuju/render/web/js/core/input-settings.js +156 -0
  215. cuju/render/web/js/core/keys.js +277 -0
  216. cuju/render/web/js/core/language-ui.js +125 -0
  217. cuju/render/web/js/core/menu.js +853 -0
  218. cuju/render/web/js/core/names.js +113 -0
  219. cuju/render/web/js/core/page-structure.js +110 -0
  220. cuju/render/web/js/core/scenarios.js +109 -0
  221. cuju/render/web/js/core/spoilers.js +236 -0
  222. cuju/render/web/js/core/typeahead.js +38 -0
  223. cuju/render/web/js/export/dom-picture.js +330 -0
  224. cuju/render/web/js/export/export-host.js +97 -0
  225. cuju/render/web/js/export/feed-dialog.js +191 -0
  226. cuju/render/web/js/export/feed-params.js +266 -0
  227. cuju/render/web/js/export/feed.js +363 -0
  228. cuju/render/web/js/export/mp4-mux.js +295 -0
  229. cuju/render/web/js/export/photo-core.js +313 -0
  230. cuju/render/web/js/export/photo.js +1314 -0
  231. cuju/render/web/js/export/safe-frame.js +59 -0
  232. cuju/render/web/js/export/size-fields.js +103 -0
  233. cuju/render/web/js/export/sizes.js +178 -0
  234. cuju/render/web/js/export/video-boot.js +55 -0
  235. cuju/render/web/js/export/video-browser.js +325 -0
  236. cuju/render/web/js/export/video-export.js +417 -0
  237. cuju/render/web/js/export/video-plan.js +207 -0
  238. cuju/render/web/js/export/video-range.js +185 -0
  239. cuju/render/web/js/export/webm-mux.js +198 -0
  240. cuju/render/web/js/feed-boot.js +151 -0
  241. cuju/render/web/js/feed-frame.js +101 -0
  242. cuju/render/web/js/help/doctor.js +488 -0
  243. cuju/render/web/js/help/firstrun.js +379 -0
  244. cuju/render/web/js/help/help-center.js +438 -0
  245. cuju/render/web/js/help/install-offer.js +164 -0
  246. cuju/render/web/js/help/install-state.js +104 -0
  247. cuju/render/web/js/help/lessons-core.js +207 -0
  248. cuju/render/web/js/help/lessons.js +463 -0
  249. cuju/render/web/js/help/palette.js +584 -0
  250. cuju/render/web/js/help/pwa.js +257 -0
  251. cuju/render/web/js/help/tour-core.js +241 -0
  252. cuju/render/web/js/help/tour.js +461 -0
  253. cuju/render/web/js/main.js +2513 -0
  254. cuju/render/web/js/pages/draw.js +188 -0
  255. cuju/render/web/js/pages/fieldpage.js +530 -0
  256. cuju/render/web/js/pages/home.js +521 -0
  257. cuju/render/web/js/pages/matches.js +779 -0
  258. cuju/render/web/js/pages/moments-ui.js +359 -0
  259. cuju/render/web/js/pages/queue-filters.js +63 -0
  260. cuju/render/web/js/pages/recording-groups.js +80 -0
  261. cuju/render/web/js/pages/recordings.js +1069 -0
  262. cuju/render/web/js/play/bench.js +347 -0
  263. cuju/render/web/js/play/challenges.js +1302 -0
  264. cuju/render/web/js/play/medals.js +127 -0
  265. cuju/render/web/js/play/play.js +1953 -0
  266. cuju/render/web/js/play/teasers.js +196 -0
  267. cuju/render/web/js/play/touch.js +350 -0
  268. cuju/render/web/js/play/tournament-layout.js +487 -0
  269. cuju/render/web/js/play/tournament-results.js +441 -0
  270. cuju/render/web/js/play/tournament-strict.js +560 -0
  271. cuju/render/web/js/play/tournament.js +2585 -0
  272. cuju/render/web/js/players/animate.js +972 -0
  273. cuju/render/web/js/players/clips.js +224 -0
  274. cuju/render/web/js/players/kaykit.js +162 -0
  275. cuju/render/web/js/players/kits.js +676 -0
  276. cuju/render/web/js/players/number-patch.js +82 -0
  277. cuju/render/web/js/players/players.js +2694 -0
  278. cuju/render/web/js/players/recolor.js +893 -0
  279. cuju/render/web/js/players/retarget.js +325 -0
  280. cuju/render/web/js/players/rig-numbers.js +174 -0
  281. cuju/render/web/js/presentation/animator.js +231 -0
  282. cuju/render/web/js/presentation/assign.js +85 -0
  283. cuju/render/web/js/presentation/behaviors.js +440 -0
  284. cuju/render/web/js/presentation/budget.js +209 -0
  285. cuju/render/web/js/presentation/catalog.js +1674 -0
  286. cuju/render/web/js/presentation/clocks.js +72 -0
  287. cuju/render/web/js/presentation/commands.js +301 -0
  288. cuju/render/web/js/presentation/contract.js +240 -0
  289. cuju/render/web/js/presentation/effects.js +904 -0
  290. cuju/render/web/js/presentation/flash.js +605 -0
  291. cuju/render/web/js/presentation/freeze-worklet.js +22 -0
  292. cuju/render/web/js/presentation/glsl.js +464 -0
  293. cuju/render/web/js/presentation/host.js +323 -0
  294. cuju/render/web/js/presentation/index.js +2066 -0
  295. cuju/render/web/js/presentation/loudness-worklet.js +235 -0
  296. cuju/render/web/js/presentation/loudness.js +740 -0
  297. cuju/render/web/js/presentation/moments.js +536 -0
  298. cuju/render/web/js/presentation/post.js +327 -0
  299. cuju/render/web/js/presentation/preview.js +180 -0
  300. cuju/render/web/js/presentation/protocol.js +174 -0
  301. cuju/render/web/js/presentation/reach.js +158 -0
  302. cuju/render/web/js/presentation/rigging.js +130 -0
  303. cuju/render/web/js/presentation/sandbox.js +157 -0
  304. cuju/render/web/js/presentation/shaders.js +235 -0
  305. cuju/render/web/js/presentation/stage.js +402 -0
  306. cuju/render/web/js/presentation/teamaudio.js +722 -0
  307. cuju/render/web/js/presentation/tracks.js +197 -0
  308. cuju/render/web/js/presentation/values.js +81 -0
  309. cuju/render/web/js/recording/annotation-data.js +203 -0
  310. cuju/render/web/js/recording/captions.js +491 -0
  311. cuju/render/web/js/recording/codec.js +185 -0
  312. cuju/render/web/js/recording/decode-worker.js +70 -0
  313. cuju/render/web/js/recording/decode.js +94 -0
  314. cuju/render/web/js/recording/episodes.js +46 -0
  315. cuju/render/web/js/recording/format.js +461 -0
  316. cuju/render/web/js/recording/live.js +348 -0
  317. cuju/render/web/js/recording/moments-query.js +59 -0
  318. cuju/render/web/js/recording/moments.js +149 -0
  319. cuju/render/web/js/recording/pose.js +86 -0
  320. cuju/render/web/js/recording/timeline.js +275 -0
  321. cuju/render/web/js/teams/art.js +50 -0
  322. cuju/render/web/js/teams/clip-editor.js +293 -0
  323. cuju/render/web/js/teams/editor.js +1979 -0
  324. cuju/render/web/js/teams/library.js +344 -0
  325. cuju/render/web/js/teams/painter.js +633 -0
  326. cuju/render/web/js/teams/surprise-ui.js +195 -0
  327. cuju/render/web/js/teams/surprise.js +497 -0
  328. cuju/render/web/js/teams/team-check.js +127 -0
  329. cuju/render/web/js/watch/annotations.js +270 -0
  330. cuju/render/web/js/watch/flat.js +366 -0
  331. cuju/render/web/js/watch/grid.js +436 -0
  332. cuju/render/web/js/watch/pitch2d.js +289 -0
  333. cuju/render/web/js/watch/player-tools.js +192 -0
  334. cuju/render/web/js/watch/presets.js +201 -0
  335. cuju/render/web/js/world/arenas.js +997 -0
  336. cuju/render/web/js/world/ball.js +204 -0
  337. cuju/render/web/js/world/cameras.js +982 -0
  338. cuju/render/web/js/world/crowd.js +376 -0
  339. cuju/render/web/js/world/field.js +227 -0
  340. cuju/render/web/js/world/framing.js +145 -0
  341. cuju/render/web/js/world/occlusion.js +48 -0
  342. cuju/render/web/js/world/pitch.js +703 -0
  343. cuju/render/web/js/world/scene.js +395 -0
  344. cuju/render/web/js/world/sky.js +325 -0
  345. cuju/render/web/js/world/stadium.js +1262 -0
  346. cuju/render/web/js/world/vendor-sky.js +367 -0
  347. cuju/render/web/js/world/weather.js +264 -0
  348. cuju/render/web/languages/en.json +4750 -0
  349. cuju/render/web/lessons.json +137 -0
  350. cuju/render/web/manifest.webmanifest +23 -0
  351. cuju/render/web/style.css +8399 -0
  352. cuju/render/web/sw.js +235 -0
  353. cuju/render/web/vendor/fabric/LICENSE +16 -0
  354. cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
  355. cuju/render/web/vendor/lucide/LICENSE +43 -0
  356. cuju/render/web/vendor/lucide/icons.js +141 -0
  357. cuju/render/web/vendor/three/LICENSE +21 -0
  358. cuju/render/web/vendor/three/build/three.core.js +60586 -0
  359. cuju/render/web/vendor/three/build/three.module.js +19719 -0
  360. cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
  361. cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
  362. cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
  363. cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
  364. cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
  365. cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
  366. cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
  367. cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
  368. cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
  369. cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
  370. cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
  371. cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
  372. cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
  373. cuju_render-0.1.0.dist-info/METADATA +116 -0
  374. cuju_render-0.1.0.dist-info/RECORD +382 -0
  375. cuju_render-0.1.0.dist-info/WHEEL +4 -0
  376. cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
  377. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
  378. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
  379. cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
  380. cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
  381. cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
  382. cuju_render-0.1.0.dist-info/licenses/NOTICE +35 -0
@@ -0,0 +1,1302 @@
1
+ // Copyright 2026 The cuju Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // The Challenges page, short drills with bronze, silver and gold targets that people play in a
5
+ // game on the Play page and registered teams play headless on the server. Every attempt plays a fixed seed, so
6
+ // people and agents face the same episodes, and the server scores each attempt from its recording
7
+ // and keeps one leaderboard per challenge. Achievements, which the server finds in the frames by
8
+ // the same rules for people and teams, show on the end cards and in a list of their own, kept on
9
+ // disk for teams and in the browser for the person. Each challenge lists its medals as items, and
10
+ // the benchmark of bench.js plays registered teams on several challenges as one job. Scripts reach
11
+ // it all through window.cuju.challenges. An attempt plays on the game view of Play, and its end
12
+ // card, Esc and Leave lead back here with the focus on the challenge's card.
13
+
14
+ import { api } from '../core/api.js';
15
+ import { formatDateTime, formatNumber, serverNodes, serverText, t, tn, tOr } from '../core/i18n.js';
16
+ import { el as h } from '../core/menu.js';
17
+ import { icon, reicon, symbolOnly } from '../core/icons.js';
18
+ import { teamName } from '../core/names.js';
19
+ import { scenarioName } from '../core/scenarios.js';
20
+ import { TeaserCards, loadTeasers } from './teasers.js';
21
+ import { medalList, medalMark, medalStyle } from './medals.js';
22
+ import { BenchPanel, benchApi, benchCsv, benchTable, playingText } from './bench.js';
23
+
24
+ const NAME_KEY = 'cuju.challengeName';
25
+ // The default names of a person in every language that the server knows, which count as no name,
26
+ // so a name stored while the page spoke another language gives way to the default of this one.
27
+ let defaultNames = new Set();
28
+ // Whether the attempts of a run stay as recordings, which the browser keeps, true by default.
29
+ const KEEP_KEY = 'cuju.challengeRecordings';
30
+ const EARNED_KEY = 'cuju.achievements';
31
+ // The achievements of a challenge's medals, which a run adds to those of its attempts' games.
32
+ const CHALLENGE_AWARDS = new Set(['podium', 'gold_standard', 'academy_graduate']);
33
+ const MEDAL_NAMES = { bronze: t('challenges.medal.bronze'), silver: t('challenges.medal.silver'), gold: t('challenges.medal.gold') };
34
+ // Dates of the leaderboards and achievements, a calendar day in the numbers of the language.
35
+ const DAY = { year: 'numeric', month: 'numeric', day: 'numeric' };
36
+ // A headless run is followed this often while it waits or plays.
37
+ const POLL_MS = 1000;
38
+ // The kinds of challenge, as CATEGORIES of challenges.py, each with its symbol and its accent in
39
+ // the dark and the light theme. Every accent keeps a contrast of 4.5 to 1 or more as text on the
40
+ // surfaces of its theme, and as the background of a label in the ink of its theme.
41
+ const CATEGORIES = {
42
+ finishing: { symbol: 'finishing', dark: '#f6c344', light: '#8a5a00' },
43
+ dribbling: { symbol: 'dribbling', dark: '#4fd1c5', light: '#00756b' },
44
+ combination: { symbol: 'combination', dark: '#7cb3ff', light: '#1a5fc4' },
45
+ set_piece: { symbol: 'set-piece', dark: '#c9a3ff', light: '#6f3fc0' },
46
+ counterattack: { symbol: 'counterattack', dark: '#ff8a80', light: '#b3261e' },
47
+ breakthrough: { symbol: 'breakthrough', dark: '#8fd694', light: '#2e7d32' },
48
+ duel: { symbol: 'duel', dark: '#f48fc8', light: '#a3216e' },
49
+ };
50
+ const DIFFICULTIES = ['easy', 'medium', 'hard'];
51
+
52
+ // The accent of each kind of challenge in both themes, the light one from the system or the page.
53
+ function categoryStyle() {
54
+ const rules = (theme, ink, scope = '') => Object.entries(CATEGORIES)
55
+ .map(([name, kind]) => `${scope}.challenge-item.cat-${name} { --cat: ${kind[theme]}; --cat-ink: ${ink}; }`).join('\n');
56
+ return `${rules('dark', '#202124')}
57
+ @media (prefers-color-scheme: light) {
58
+ ${rules('light', '#ffffff', ':root:not([data-theme="dark"]) ')}
59
+ }
60
+ ${rules('light', '#ffffff', ':root[data-theme="light"] ')}`;
61
+ }
62
+
63
+ function injectChallengeStyle() {
64
+ if (document.getElementById('cuju-challenges-style')) {
65
+ return;
66
+ }
67
+ const style = document.createElement('style');
68
+ style.id = 'cuju-challenges-style';
69
+ style.textContent = `
70
+ .challenges { max-width: 1200px; margin: 24px auto; padding: 16px 20px 20px; border-top: 3px solid var(--accent);
71
+ background: var(--surface); display: flex; flex-direction: column; gap: 10px; }
72
+ .challenges h1 { margin: 0; font-size: 22px; }
73
+ .challenges > h2, .challenges section > h2 { margin: 8px 0 0; font-size: 18px; }
74
+ @media (max-width: 600px) { .challenges { margin: 0; padding: 12px 16px; } }
75
+ .challenges .setup-row { display: flex; flex-wrap: wrap; gap: 10px 14px; }
76
+ .challenge-banner, .challenge-job { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; }
77
+ .challenge-banner[hidden], .challenge-job[hidden] { display: none; }
78
+ .challenge-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px;
79
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); }
80
+ .challenge-item { --cat: var(--accent); --cat-ink: var(--on-accent); border: 1px solid var(--border);
81
+ border-top: 4px solid var(--cat); border-radius: 8px; padding: 0 12px 10px; display: flex;
82
+ flex-direction: column; gap: 4px; overflow: hidden; background: var(--surface); }
83
+ .challenge-item h2 { margin: 0; font-size: 16px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
84
+ .challenge-item p { margin: 0; }
85
+ .challenge-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
86
+ .challenge-jumps { display: flex; flex-wrap: wrap; gap: 8px; }
87
+ .challenge-model { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; border: 1px solid var(--border);
88
+ border-left: 3px solid var(--cat); border-radius: 6px; }
89
+ .challenge-model[hidden] { display: none; }
90
+ .challenge-model h3 { margin: 0; font-size: 15px; display: flex; align-items: center; gap: 6px; }
91
+ .challenge-model h3 .icon { width: 16px; height: 16px; }
92
+ .challenge-model .setup-row { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: flex-end; }
93
+ .challenge-model select { max-width: 100%; }
94
+ .challenge-media { order: -1; position: relative; margin: 0 -12px 6px; aspect-ratio: 16 / 9; overflow: hidden;
95
+ background: var(--stage-bottom); }
96
+ .challenge-media > img, .challenge-media > video, .challenge-drawn { position: absolute; inset: 0; width: 100%;
97
+ height: 100%; object-fit: cover; display: block; }
98
+ .challenge-media > video { opacity: 0; transition: opacity 0.2s; }
99
+ .challenge-media[data-playing="true"] > video.ready { opacity: 1; }
100
+ .reduced-motion .challenge-media > video { transition: none; }
101
+ .challenge-media[data-picture="still"] .challenge-drawn { display: none; }
102
+ .challenge-drawn { display: grid; place-items: center; color: var(--cat); background-color: var(--surface);
103
+ background-image: var(--pattern); }
104
+ .challenge-drawn .icon { width: 30%; height: 30%; opacity: 0.85; }
105
+ .challenge-chip, .challenge-difficulty, .challenge-media .challenge-medal { position: absolute; display: inline-flex;
106
+ align-items: center; gap: 5px; font-size: 12px; font-weight: 600; line-height: 1; padding: 4px 8px;
107
+ border-radius: 999px; }
108
+ .challenge-chip { top: 8px; left: 8px; background: var(--cat); color: var(--cat-ink); }
109
+ .challenge-chip .icon { width: 14px; height: 14px; }
110
+ .challenge-difficulty { top: 8px; right: 8px; background: var(--surface); color: var(--text); border: 1px solid var(--cat); }
111
+ .challenge-pips { display: inline-flex; gap: 2px; }
112
+ .challenge-pips i { width: 6px; height: 10px; border-radius: 1px; border: 1px solid currentColor; }
113
+ .challenge-pips i.on { background: var(--cat); border-color: var(--cat); }
114
+ .challenge-media .challenge-medal { bottom: 8px; left: 8px; border: none; }
115
+ .challenge-media .challenge-medal .icon { width: 14px; height: 14px; }
116
+ .challenge-teaser { position: absolute; right: 8px; bottom: 8px; border-radius: 999px; background: var(--overlay);
117
+ color: var(--text); border: 1px solid var(--border); }
118
+ .challenge-teaser[hidden] { display: none; }
119
+ .challenge-item:hover, .challenge-item:focus-within { border-color: var(--cat); }
120
+ .challenge-item.difficulty-hard .challenge-media::after { content: ""; position: absolute; inset: auto 0 0 0; height: 3px;
121
+ background: repeating-linear-gradient(90deg, var(--cat) 0 10px, transparent 10px 16px); }
122
+ .challenge-item.cat-finishing { --pattern: repeating-radial-gradient(circle at 50% 50%, transparent 0 18px,
123
+ color-mix(in srgb, var(--cat) 22%, transparent) 18px 21px); }
124
+ .challenge-item.cat-dribbling { --pattern: repeating-linear-gradient(135deg, transparent 0 14px,
125
+ color-mix(in srgb, var(--cat) 20%, transparent) 14px 18px); }
126
+ .challenge-item.cat-combination { --pattern: radial-gradient(color-mix(in srgb, var(--cat) 35%, transparent) 2px,
127
+ transparent 2.5px) 0 0 / 22px 22px; }
128
+ .challenge-item.cat-set_piece { --pattern: repeating-radial-gradient(circle at 0 100%, transparent 0 26px,
129
+ color-mix(in srgb, var(--cat) 22%, transparent) 26px 29px); }
130
+ .challenge-item.cat-counterattack { --pattern: repeating-linear-gradient(90deg, transparent 0 22px,
131
+ color-mix(in srgb, var(--cat) 22%, transparent) 22px 24px, transparent 24px 30px,
132
+ color-mix(in srgb, var(--cat) 12%, transparent) 30px 31px); }
133
+ .challenge-item.cat-breakthrough { --pattern: linear-gradient(color-mix(in srgb, var(--cat) 22%, transparent) 2px,
134
+ transparent 2px) 0 0 / 36px 18px, linear-gradient(90deg, color-mix(in srgb, var(--cat) 22%, transparent) 2px,
135
+ transparent 2px) 0 0 / 36px 36px, linear-gradient(90deg, color-mix(in srgb, var(--cat) 22%, transparent) 2px,
136
+ transparent 2px) 18px 18px / 36px 36px; }
137
+ .challenge-item.cat-duel { --pattern: linear-gradient(90deg, color-mix(in srgb, var(--cat) 16%, transparent) 50%,
138
+ transparent 50%); }
139
+ ${categoryStyle()}
140
+ .challenge-medal { font-size: 12px; padding: 1px 6px; border-radius: 9px; border: 1px solid var(--border);
141
+ display: inline-flex; align-items: center; gap: 4px; }
142
+ .challenge-medal.gold { background: #d4a017; color: #1b1300; border-color: #d4a017; }
143
+ .challenge-medal.silver { background: #b8bec6; color: #15181c; border-color: #b8bec6; }
144
+ .challenge-medal.bronze { background: #b0703c; color: #fff; border-color: #b0703c; }
145
+ .challenge-item:has(.challenge-board:not([hidden])), .challenge-item:has(.challenge-model:not([hidden])) { grid-column: 1 / -1; }
146
+ .challenge-item:has(.challenge-board:not([hidden])) .challenge-media,
147
+ .challenge-item:has(.challenge-model:not([hidden])) .challenge-media { max-height: 220px; }
148
+ .challenge-board { overflow-x: auto; }
149
+ .challenge-board table { width: 100%; border-collapse: collapse; font-size: 13px; }
150
+ .challenge-board th, .challenge-board td { text-align: left; padding: 2px 6px; border-bottom: 1px solid var(--border); }
151
+ .challenge-board td.num, .challenge-board th.num { text-align: right; }
152
+ .challenge-playing { display: block; }
153
+ .challenge-kind { font-size: 11px; padding: 0 5px; border-radius: 8px; border: 1px solid var(--border); }
154
+ .challenge-kind.agent { border-style: dashed; }
155
+ .challenge-result { display: flex; flex-direction: column; gap: 4px; }
156
+ .achievement-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px;
157
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); }
158
+ .achievement-list li { display: flex; gap: 8px; align-items: flex-start; padding: 6px 8px;
159
+ border: 1px solid var(--border); border-radius: 6px; }
160
+ .achievement-list li.missing { opacity: 0.6; border-style: dashed; }
161
+ .achievement-glyph { flex: none; width: 2.4em; text-align: center; font-weight: 700; font-size: 12px;
162
+ padding: 2px 0; border-radius: 4px; border: 1px solid var(--border); }
163
+ .achievement-list p { margin: 0; }
164
+ .achievement-new { display: flex; flex-direction: column; gap: 2px; }
165
+ .challenge-kind .icon { width: 11px; height: 11px; vertical-align: -1px; margin-right: 3px; }
166
+ .challenge-bench { display: flex; flex-direction: column; gap: 8px; }
167
+ .challenge-bench h2 { margin: 0; }
168
+ .bench-choices { display: flex; flex-wrap: wrap; gap: 10px; }
169
+ .bench-choice { border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; margin: 0; display: flex;
170
+ flex-direction: column; gap: 2px; min-width: min(100%, 240px); }
171
+ .bench-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
172
+ .bench-status[hidden], .bench-exports[hidden], .bench-progress[hidden] { display: none; }
173
+ .bench-progress { width: min(100%, 220px); }
174
+ .bench-table { overflow-x: auto; }
175
+ .bench-table table { border-collapse: collapse; font-size: 13px; }
176
+ .bench-table caption { text-align: left; font-weight: 600; padding-bottom: 4px; }
177
+ .bench-table th, .bench-table td { text-align: left; padding: 3px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
178
+ .bench-table td.num, .bench-table th.num { text-align: right; }
179
+ .bench-table td { white-space: nowrap; }
180
+ .bench-state { display: inline-flex; align-items: center; gap: 4px; }
181
+ .bench-state .icon { width: 13px; height: 13px; }
182
+ .bench-reason, .bench-failed { display: block; white-space: normal; max-width: 240px; }
183
+ ${medalStyle()}
184
+ `;
185
+ document.head.appendChild(style);
186
+ }
187
+
188
+ async function call(method, path, json) {
189
+ const init = { method, cache: 'no-store', headers: {} };
190
+ if (json !== undefined) {
191
+ init.body = JSON.stringify(json);
192
+ init.headers['Content-Type'] = 'application/json';
193
+ }
194
+ let res;
195
+ try {
196
+ res = await fetch(path, init);
197
+ } catch {
198
+ throw new Error(t('api.unreachable', { origin: location.origin }));
199
+ }
200
+ const text = await res.text();
201
+ let data = null;
202
+ try {
203
+ data = text ? JSON.parse(text) : null;
204
+ } catch {
205
+ data = null;
206
+ }
207
+ if (!res.ok) {
208
+ const error = new Error((data && data.error && serverText(data, 'error')) || t('challenges.api.status', { status: res.status }));
209
+ error.status = res.status;
210
+ throw error;
211
+ }
212
+ return data;
213
+ }
214
+
215
+ const enc = encodeURIComponent;
216
+
217
+ // The client of the server's challenge routes.
218
+ export const challengeApi = {
219
+ list: (person) => call('GET', `/api/challenges${person ? `?person=${enc(person)}` : ''}`),
220
+ board: (id) => call('GET', `/api/challenges/${enc(id)}`),
221
+ score: (id, match, attempt) => call('GET', `/api/challenges/${enc(id)}/score?match=${enc(match)}&attempt=${attempt}`),
222
+ submit: (id, person, matches, keep = true, unnamed = false) => call('POST', `/api/challenges/${enc(id)}/runs`,
223
+ { person, matches, keep_recordings: keep, ...(unnamed ? { default_name: true } : {}) }),
224
+ leaveRun: (id, person, matches, keep = true, unnamed = false) => call('POST', `/api/challenges/${enc(id)}/leave`,
225
+ { person, matches, keep_recordings: keep, ...(unnamed ? { default_name: true } : {}) }),
226
+ runTeam: (id, team, attempts = null, keep = true) => call('POST', `/api/challenges/${enc(id)}/runs`,
227
+ { team, ...(attempts == null ? {} : { attempts }), ...(keep ? {} : { keep_recordings: false }) }),
228
+ jobs: () => call('GET', '/api/challenges/jobs'),
229
+ names: () => call('GET', '/api/challenges/names'),
230
+ cancel: (job) => call('DELETE', `/api/challenges/jobs/${enc(job)}`),
231
+ achievements: () => call('GET', '/api/achievements'),
232
+ };
233
+
234
+ // The person's achievements, kept in the browser by identifier, each with its first time and
235
+ // count.
236
+ export function readEarned() {
237
+ try {
238
+ const value = JSON.parse(localStorage.getItem(EARNED_KEY) || '{}');
239
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
240
+ } catch {
241
+ return {};
242
+ }
243
+ }
244
+
245
+ // Adds achievements to the person's, and returns those earned for the first time.
246
+ export function addEarned(ids) {
247
+ const earned = readEarned();
248
+ const fresh = [];
249
+ for (const id of ids) {
250
+ const entry = earned[id];
251
+ if (entry && typeof entry === 'object') {
252
+ entry.count = (Number(entry.count) || 0) + 1;
253
+ } else {
254
+ earned[id] = { first: Date.now(), count: 1 };
255
+ fresh.push(id);
256
+ }
257
+ }
258
+ try {
259
+ localStorage.setItem(EARNED_KEY, JSON.stringify(earned));
260
+ } catch {
261
+ // A browser that keeps no site data shows the achievements of this visit alone.
262
+ }
263
+ return fresh;
264
+ }
265
+
266
+ // The name that a person typed, trimmed to 40 characters, or the empty text for none, which a
267
+ // default name of any language known, the page's own among them, counts as.
268
+ export function typedName(value, defaults = defaultNames) {
269
+ const name = typeof value === 'string' ? value.trim().slice(0, 40) : '';
270
+ return name && !defaults.has(name) && name !== t('challenges.name.default') ? name : '';
271
+ }
272
+
273
+ function readName() {
274
+ try {
275
+ return typedName(localStorage.getItem(NAME_KEY) || '');
276
+ } catch {
277
+ return '';
278
+ }
279
+ }
280
+
281
+ // Keeps a name that the person typed, and forgets the stored one for no name or a default one.
282
+ function writeName(name) {
283
+ const typed = typedName(name);
284
+ try {
285
+ if (typed) {
286
+ localStorage.setItem(NAME_KEY, typed);
287
+ } else {
288
+ localStorage.removeItem(NAME_KEY);
289
+ }
290
+ } catch {
291
+ // A browser that keeps no site data asks for the name on every visit.
292
+ }
293
+ }
294
+
295
+ function readKeep() {
296
+ try {
297
+ return localStorage.getItem(KEEP_KEY) !== 'false';
298
+ } catch {
299
+ return true;
300
+ }
301
+ }
302
+
303
+ function writeKeep(keep) {
304
+ try {
305
+ localStorage.setItem(KEEP_KEY, keep ? 'true' : 'false');
306
+ } catch {
307
+ // A browser that keeps no site data keeps the recordings on every visit.
308
+ }
309
+ }
310
+
311
+ // A number of the server in the numbers of the language, such as seconds with a decimal.
312
+ function num(value) {
313
+ return typeof value === 'number' && Number.isFinite(value) ? formatNumber(value, { useGrouping: false, maximumFractionDigits: 10 }) : String(value);
314
+ }
315
+
316
+ // The title and goal of a challenge, and the name and description of an achievement, in the
317
+ // language of the page, else the server's English.
318
+ export function challengeTitle(challenge) {
319
+ return tOr(`challenge.${challenge.id}.title`, challenge.title);
320
+ }
321
+
322
+ export function challengeGoal(challenge) {
323
+ return tOr(`challenge.${challenge.id}.goal`, challenge.goal);
324
+ }
325
+
326
+ // The value of a run in words, by the measure of its challenge.
327
+ export function valueText(challenge, value) {
328
+ if (challenge.measure === 'margin') {
329
+ return t('challenges.value.margin', { count: Math.abs(value), value: `${value > 0 ? '+' : ''}${num(value)}` });
330
+ }
331
+ return t('challenges.value.attempts', { value: num(value), attempts: num(challenge.attempts) });
332
+ }
333
+
334
+ function medalBadge(medal, text = null) {
335
+ return medal ? h('span', { class: `challenge-medal ${medal}` }, text || MEDAL_NAMES[medal]) : null;
336
+ }
337
+
338
+ // The words of a run's value with the seconds its counting attempts took to score in all, or the
339
+ // value alone for a run in which no attempt counted, whose seconds are none. `id` names the
340
+ // message with the seconds, and the same id with `.none` the one without.
341
+ export function runText(challenge, run, id, values = {}) {
342
+ const value = valueText(challenge, run.value);
343
+ if (!(Number(run.seconds) > 0)) {
344
+ return t(`${id}.none`, { ...values, value });
345
+ }
346
+ return t(id, { ...values, value, count: run.seconds, seconds: num(run.seconds) });
347
+ }
348
+
349
+ // One attempt's result in words.
350
+ export function attemptText(challenge, result) {
351
+ const values = { for: num(result.goals[0]), against: num(result.goals[1]), count: result.seconds, seconds: num(result.seconds), limit: num(challenge.limit) };
352
+ if (result.failed) {
353
+ const reason = result.error ? serverText(result, 'error') : t('challenges.job.noResult');
354
+ return t('challenges.attempt.failed', { ...values, reason });
355
+ }
356
+ if (!result.match && !result.finished) {
357
+ return t('challenges.attempt.missed');
358
+ }
359
+ if (!result.finished) {
360
+ return t('challenges.attempt.left', values);
361
+ }
362
+ if (!result.scored) {
363
+ return t('challenges.attempt.noGoal', values);
364
+ }
365
+ if (challenge.measure === 'within') {
366
+ return t(result.counts ? 'challenges.attempt.within' : 'challenges.attempt.late', values);
367
+ }
368
+ return t(challenge.measure === 'margin' ? 'challenges.attempt.scoredGoals' : 'challenges.attempt.scored', values);
369
+ }
370
+
371
+ function abilityText(challenge) {
372
+ const [left, right] = challenge.played_ability || [null, null];
373
+ const two = { minimumFractionDigits: 2, maximumFractionDigits: 2 };
374
+ return t('challenges.ability', { left: formatNumber(Number(left), two), right: formatNumber(Number(right), two) });
375
+ }
376
+
377
+ // A person, a team of the registry, or a Python policy, which a board ranks by its name alone, so a
378
+ // policy that shares a team's name stays apart from the team. A person carries the symbol of a
379
+ // person, and a team or a policy the symbol of a model.
380
+ function competitorKind(entry) {
381
+ if (entry.kind === 'person') {
382
+ return [icon('user'), t('challenges.board.person')];
383
+ }
384
+ return [icon('robot'), entry.team ? t('challenges.board.team') : t('challenges.board.policy')];
385
+ }
386
+
387
+ // The Challenges page, its section `#page-challenges` of index.html, built on its first visit.
388
+ export class ChallengesPage {
389
+ constructor(app) {
390
+ this.app = app;
391
+ this.page = document.getElementById('page-challenges');
392
+ this.catalog = [];
393
+ this.teams = [];
394
+ this.run = null;
395
+ this.jobs = [];
396
+ this.polling = false;
397
+ this.root = null;
398
+ this.boards = new Map();
399
+ // The teasers of the cards, which play under full motion alone unless asked for.
400
+ this.teaserFiles = {};
401
+ this.teasers = new TeaserCards({ reduced: () => this.motionReduced() });
402
+ this.bench = new BenchPanel(this, { title: challengeTitle, value: valueText });
403
+ // Scripts and agents reach the challenges through the page object.
404
+ this.app.challenges = this.script();
405
+ }
406
+
407
+ // The Play page, whose game view plays each attempt, made on the first attempt when no visit
408
+ // of Play made it before.
409
+ get play() {
410
+ return this.app.playPage();
411
+ }
412
+
413
+ get built() {
414
+ return !!this.root;
415
+ }
416
+
417
+ // Builds the page once, into its section of index.html.
418
+ build() {
419
+ if (!this.root) {
420
+ const root = this.element();
421
+ if (this.page) {
422
+ this.page.replaceChildren(root);
423
+ }
424
+ }
425
+ return this.root;
426
+ }
427
+
428
+ // Shows the page, built on its first visit, and reads the catalog, the teams and the boards.
429
+ open() {
430
+ this.build();
431
+ this.shown = this.show();
432
+ return this.shown;
433
+ }
434
+
435
+ // The page leaves the screen, and every teaser goes back to its still.
436
+ hide() {
437
+ this.teasers.stop();
438
+ }
439
+
440
+ // Puts the focus on Play it yourself of a challenge's card once the list holds it, or on the
441
+ // heading of the page without one.
442
+ async focusCard(id = null) {
443
+ await this.shown;
444
+ const selector = id ? `.challenge-item[data-id="${CSS.escape(id)}"] [data-action="start"]` : '#challenges-title';
445
+ for (let i = 0; i < 40 && this.app.page === 'challenges'; i++) {
446
+ const node = this.list && document.querySelector(selector);
447
+ if (node && node.getClientRects().length) {
448
+ if (!id) {
449
+ node.tabIndex = -1;
450
+ }
451
+ node.focus({ preventScroll: true });
452
+ node.scrollIntoView({ block: id ? 'center' : 'start' });
453
+ return true;
454
+ }
455
+ await new Promise((resolve) => setTimeout(resolve, 50));
456
+ }
457
+ return false;
458
+ }
459
+
460
+ // The page, built once.
461
+ element() {
462
+ injectChallengeStyle();
463
+ this.nameField = h('input', { type: 'text', maxlength: '40', value: readName(), placeholder: t('challenges.name.default'), 'aria-label': t('challenges.name.label'), class: 'challenge-name' });
464
+ this.nameField.addEventListener('change', () => writeName(this.nameField.value));
465
+ this.keepBox = h('input', { type: 'checkbox', checked: readKeep(), class: 'challenge-keep', 'aria-describedby': 'challenge-keep-hint' });
466
+ this.keepBox.addEventListener('change', () => writeKeep(this.keepBox.checked));
467
+ // The model and the attempts that a card's Run a model offers first, the last ones chosen
468
+ // on any card.
469
+ this.choice = { team: 'builtin', attempts: 'full' };
470
+ this.models = new Set();
471
+ this.banner = h('div', { class: 'challenge-banner', role: 'status', hidden: true });
472
+ this.list = h('ul', { class: 'challenge-list', 'aria-label': t('challenges.title') });
473
+ this.bindTeasers(this.list);
474
+ this.note = h('p', { class: 'muted small', role: 'status' });
475
+ this.root = h('section', { class: 'challenges', 'aria-labelledby': 'challenges-title' },
476
+ h('h1', { id: 'challenges-title' }, t('challenges.title')),
477
+ h('p', { class: 'muted' }, tn('challenges.text')),
478
+ h('div', { class: 'setup-row' },
479
+ h('label', { class: 'field' }, t('challenges.name'), this.nameField)),
480
+ h('label', { class: 'check' }, this.keepBox, t('challenges.keep')),
481
+ h('p', { class: 'muted small', id: 'challenge-keep-hint' }, tn('challenges.keep.hint')),
482
+ h('div', { class: 'challenge-jumps' },
483
+ h('button', { type: 'button', class: 'btn', 'data-action': 'jump-bench', onclick: () => this.jump('challenge-bench-title') },
484
+ icon('stats'), t('challenges.jump.bench')),
485
+ h('button', { type: 'button', class: 'btn', 'data-action': 'jump-achievements', onclick: () => this.jump('achievements-title') },
486
+ icon('star'), t('challenges.jump.achievements'))),
487
+ this.banner, this.note, this.list, this.bench.element(), this.achievementsPart());
488
+ return this.root;
489
+ }
490
+
491
+ // Moves the focus to the heading of a part of the page, the benchmark or the achievements.
492
+ jump(id) {
493
+ const heading = document.getElementById(id);
494
+ if (heading) {
495
+ heading.tabIndex = -1;
496
+ heading.scrollIntoView({ block: 'start' });
497
+ heading.focus({ preventScroll: true });
498
+ }
499
+ }
500
+
501
+ achievementsPart() {
502
+ this.whoseSelect = h('select', { 'aria-label': t('challenges.achievements.whose'), class: 'achievement-whose' });
503
+ this.whoseSelect.addEventListener('change', () => this.renderAchievements());
504
+ this.achievementList = h('ul', { class: 'achievement-list', 'aria-label': t('challenges.achievements.title') });
505
+ this.achievementNote = h('p', { class: 'muted small', role: 'status' });
506
+ return h('section', { class: 'achievements', 'aria-labelledby': 'achievements-title' },
507
+ h('h2', { id: 'achievements-title' }, t('challenges.achievements.title')),
508
+ h('p', { class: 'muted' }, tn('challenges.achievements.text')),
509
+ h('label', { class: 'field' }, t('challenges.achievements.whose'), this.whoseSelect),
510
+ this.achievementNote, this.achievementList);
511
+ }
512
+
513
+ // The catalog and the teams' achievements from the server, and the person's from the browser.
514
+ async loadAchievements() {
515
+ try {
516
+ this.achievements = await challengeApi.achievements();
517
+ } catch (err) {
518
+ this.achievements = this.achievements || { catalog: [], teams: {} };
519
+ if (this.achievementNote) {
520
+ this.achievementNote.textContent = t('challenges.achievements.unavailable', { reason: err.message });
521
+ }
522
+ }
523
+ return this.achievements;
524
+ }
525
+
526
+ nameOf(id) {
527
+ const entry = this.achievements && this.achievements.catalog.find((a) => a.id === id);
528
+ return entry ? tOr(`achievement.${id}.name`, entry.name) : id;
529
+ }
530
+
531
+ renderAchievements() {
532
+ if (!this.achievementList || !this.achievements) {
533
+ return;
534
+ }
535
+ const before = this.whoseSelect.value || 'you';
536
+ const teams = this.teams.filter((team) => team.kind !== 'builtin');
537
+ this.whoseSelect.replaceChildren(h('option', { value: 'you' }, t('challenges.achievements.you')),
538
+ ...teams.map((team) => h('option', { value: team.id }, teamName(team))));
539
+ this.whoseSelect.value = before === 'you' || teams.some((team) => team.id === before) ? before : 'you';
540
+ const whose = this.whoseSelect.value;
541
+ const earned = whose === 'you' ? readEarned() : (this.achievements.teams[whose] || {});
542
+ const count = this.achievements.catalog.filter((a) => earned[a.id]).length;
543
+ this.achievementNote.textContent = t('challenges.achievements.count', { count, total: this.achievements.catalog.length });
544
+ this.achievementList.replaceChildren(...this.achievements.catalog.map((a) => {
545
+ const got = earned[a.id];
546
+ const first = got ? formatDateTime(whose === 'you' ? got.first : got.first * 1000, DAY) : '';
547
+ const name = h('strong', {}, tOr(`achievement.${a.id}.name`, a.name));
548
+ return h('li', { class: got ? 'earned' : 'missing', 'data-id': a.id },
549
+ h('span', { class: 'achievement-glyph', 'aria-hidden': 'true' }, tOr(`achievement.${a.id}.glyph`, a.glyph)),
550
+ h('div', {}, h('p', {}, ...(got ? tn('challenges.achievements.earned', { name, count: got.count, date: first }) : tn('challenges.achievements.notYet', { name }))),
551
+ h('p', { class: 'muted small' }, tOr(`achievement.${a.id}.description`, a.description))));
552
+ }));
553
+ }
554
+
555
+ // The lines of an end card that name achievements, the new ones first.
556
+ achievementLines(ids, fresh) {
557
+ if (!ids.length) {
558
+ return null;
559
+ }
560
+ const others = ids.filter((id) => !fresh.includes(id));
561
+ return h('div', { class: 'achievement-new', role: 'status' },
562
+ fresh.length ? h('p', {}, t('challenges.achievements.new', { count: fresh.length, names: fresh.map((id) => this.nameOf(id)).join(', ') })) : null,
563
+ others.length ? h('p', { class: 'muted' }, t('challenges.achievements.again', { count: others.length, names: others.map((id) => this.nameOf(id)).join(', ') })) : null);
564
+ }
565
+
566
+ // The person's achievements of a game, from the ended report of the game's process.
567
+ // The attempts of a challenge's run pass the achievements their run has shown, `seen`, so an
568
+ // achievement earned again on every attempt shows on the first card alone.
569
+ gameAchievements(entry, seen = null) {
570
+ const sides = Array.isArray(entry.achievements) ? entry.achievements : [];
571
+ const ids = [...new Set(sides.filter(Array.isArray).flat().filter((id) => typeof id === 'string'))];
572
+ const fresh = addEarned(ids);
573
+ this.renderAchievements();
574
+ const shown = seen ? ids.filter((id) => fresh.includes(id) || !seen.has(id)) : ids;
575
+ if (seen) {
576
+ ids.forEach((id) => seen.add(id));
577
+ }
578
+ return this.achievementLines(shown, fresh);
579
+ }
580
+
581
+ // Whether motion is reduced, by the page's setting or else the system's.
582
+ motionReduced() {
583
+ if (typeof this.app.reducedMotion === 'boolean') {
584
+ return this.app.reducedMotion;
585
+ }
586
+ return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
587
+ }
588
+
589
+ // The person's name on the leaderboards, the one typed or else the default of the language.
590
+ personName() {
591
+ return this.typed() || t('challenges.name.default');
592
+ }
593
+
594
+ typed() {
595
+ return this.nameField ? typedName(this.nameField.value) : readName();
596
+ }
597
+
598
+ // The default names that the server knows, read once, after which a default name in the field
599
+ // gives way to the default of this language.
600
+ async loadNames() {
601
+ if (this.namesRead) {
602
+ return;
603
+ }
604
+ try {
605
+ const answer = await challengeApi.names();
606
+ defaultNames = new Set(Array.isArray(answer && answer.default_names) ? answer.default_names : []);
607
+ this.namesRead = true;
608
+ } catch {
609
+ // The next show asks again, and the page's own default counts meanwhile.
610
+ }
611
+ if (this.nameField && !this.typed()) {
612
+ this.nameField.value = '';
613
+ writeName('');
614
+ }
615
+ }
616
+
617
+ find(id) {
618
+ return this.catalog.find((c) => c.id === id) || null;
619
+ }
620
+
621
+ // Reads the catalog with the person's best runs and the registry, and draws the list.
622
+ async show(teams = null) {
623
+ if (teams) {
624
+ this.teams = teams;
625
+ } else {
626
+ try {
627
+ this.teams = await api.teams();
628
+ } catch {
629
+ // The server is away, and the note below says so.
630
+ }
631
+ }
632
+ await this.loadNames();
633
+ try {
634
+ this.catalog = await challengeApi.list(this.personName());
635
+ this.note.textContent = '';
636
+ } catch (err) {
637
+ this.note.textContent = t('challenges.unavailable', { reason: err.message });
638
+ }
639
+ await this.loadAchievements();
640
+ this.teaserFiles = await loadTeasers();
641
+ this.render();
642
+ this.bench.renderChoices();
643
+ this.renderAchievements();
644
+ this.renderBanner();
645
+ this.followJobs();
646
+ }
647
+
648
+ // The models of the registry that a card offers, the built-in AI first.
649
+ modelOptions() {
650
+ const teams = this.teams.length ? this.teams : [{ id: 'builtin', kind: 'builtin' }];
651
+ return teams.map((team) => h('option', { value: team.id },
652
+ team.kind === 'builtin' ? t('challenges.team.builtin') : teamName(team)));
653
+ }
654
+
655
+ render() {
656
+ if (!this.list) {
657
+ return;
658
+ }
659
+ const focused = document.activeElement && this.list.contains(document.activeElement)
660
+ ? [document.activeElement.closest('[data-id]')?.dataset.id, document.activeElement.dataset.action] : null;
661
+ this.teasers.clear();
662
+ this.list.replaceChildren(...this.catalog.map((c) => this.item(c)));
663
+ if (focused && focused[0]) {
664
+ const again = this.list.querySelector(`[data-id="${focused[0]}"] [data-action="${focused[1]}"]`);
665
+ if (again) {
666
+ again.focus();
667
+ }
668
+ }
669
+ }
670
+
671
+ item(c) {
672
+ const mine = c.board && c.board.mine;
673
+ const best = mine ? runText(c, mine, 'challenges.item.best', { rank: String(mine.rank) }) : t('challenges.item.noRun');
674
+ // Every challenge has the same buttons, so each names its challenge for screen readers.
675
+ const title = challengeTitle(c);
676
+ const named = (id) => t(`${id}.label`, { title });
677
+ const open = this.boards.has(c.id);
678
+ const board = h('div', { class: 'challenge-board', hidden: !open });
679
+ if (open) {
680
+ this.fillBoard(c, board, this.boards.get(c.id));
681
+ }
682
+ const toggle = h('button', { type: 'button', class: 'btn small', 'data-action': 'board', 'aria-expanded': open ? 'true' : 'false', 'aria-label': named('challenges.item.board'), onclick: () => this.toggleBoard(c, board, toggle) },
683
+ icon('podium'), t('challenges.item.board'));
684
+ const model = this.modelPart(c, title);
685
+ const modelOpen = this.models.has(c.id);
686
+ const runModel = h('button', {
687
+ type: 'button', class: 'btn small', 'data-action': 'model', 'aria-expanded': modelOpen ? 'true' : 'false',
688
+ 'aria-controls': model.id, 'aria-label': named('challenges.item.model'),
689
+ onclick: () => this.toggleModel(c.id, model, runModel),
690
+ }, icon('robot'), t('challenges.item.model'));
691
+ const leaders = c.board && c.board.top && c.board.top.length
692
+ ? t('challenges.item.leader', {
693
+ name: teamName(c.board.top[0]), value: valueText(c, c.board.top[0].value),
694
+ people: t('challenges.item.people', { count: c.board.people }), agents: t('challenges.item.agents', { count: c.board.agents }),
695
+ })
696
+ : t('challenges.item.empty');
697
+ const kind = Object.prototype.hasOwnProperty.call(CATEGORIES, c.category) ? c.category : null;
698
+ const difficulty = DIFFICULTIES.includes(c.difficulty) ? c.difficulty : null;
699
+ const heading = `challenge-title-${c.id}`;
700
+ const classes = ['challenge-item', kind ? `cat-${kind}` : '', difficulty ? `difficulty-${difficulty}` : ''].filter(Boolean).join(' ');
701
+ // The card takes its name from its title alone, and its picture comes first on screen.
702
+ return h('li', { class: classes, 'data-id': c.id, 'aria-labelledby': heading },
703
+ h('h2', { id: heading }, title),
704
+ this.media(c, title, kind, difficulty, mine && mine.medal),
705
+ h('p', {}, challengeGoal(c)),
706
+ h('p', { class: 'muted small' }, t('challenges.item.scenario', { scenario: scenarioName(c.scenario), count: c.attempts })),
707
+ medalList(c, mine ? mine.value : null, t('challenges.medals.best'), t('challenges.medals.label', { title })),
708
+ h('p', { class: 'muted small' }, abilityText(c)),
709
+ h('p', { class: 'small' }, best),
710
+ h('p', { class: 'muted small' }, leaders),
711
+ h('div', { class: 'challenge-actions' },
712
+ h('button', { type: 'button', class: 'btn small primary', 'data-action': 'start', 'aria-label': named('challenges.item.start'), onclick: () => this.start(c.id) },
713
+ icon('gamepad'), t('challenges.item.start')),
714
+ runModel, toggle),
715
+ model, this.jobLineOf(c.id), board);
716
+ }
717
+
718
+ // The part of a card that runs a model of the registry on the challenge headless, its model,
719
+ // its attempts and the button that starts the run.
720
+ modelPart(c, title) {
721
+ const id = `challenge-model-${c.id}`;
722
+ const heading = `${id}-title`;
723
+ const team = h('select', { class: 'challenge-team', 'data-role': 'team' }, this.modelOptions());
724
+ team.value = [...team.options].some((option) => option.value === this.choice.team) ? this.choice.team : 'builtin';
725
+ team.addEventListener('change', () => {
726
+ this.choice.team = team.value;
727
+ });
728
+ const attempts = h('select', { class: 'challenge-attempts', 'data-role': 'attempts' },
729
+ h('option', { value: 'full' }, t('challenges.attempts.full.count', { count: c.attempts })),
730
+ h('option', { value: '1' }, t('challenges.attempts.practice')));
731
+ attempts.value = this.choice.attempts;
732
+ attempts.addEventListener('change', () => {
733
+ this.choice.attempts = attempts.value;
734
+ });
735
+ const run = h('button', {
736
+ type: 'button', class: 'btn primary small', 'data-action': 'headless', 'aria-label': t('challenges.model.run.label', { title }),
737
+ onclick: () => this.runHeadless(c.id, team.value, attempts.value === 'full' ? null : Number(attempts.value)).catch(() => {}),
738
+ }, icon('play'), t('challenges.model.run'));
739
+ return h('div', { class: 'challenge-model', id, role: 'group', 'aria-labelledby': heading, hidden: !this.models.has(c.id) },
740
+ h('h3', { id: heading }, icon('robot'), t('challenges.model.title')),
741
+ h('p', { class: 'muted small' }, t('challenges.model.text', { count: c.attempts })),
742
+ h('div', { class: 'setup-row' },
743
+ h('label', { class: 'field' }, t('challenges.model.team'), team),
744
+ h('label', { class: 'field' }, t('challenges.attempts'), attempts),
745
+ run));
746
+ }
747
+
748
+ // Opens or closes the part of a card that runs a model, with the focus on its model.
749
+ toggleModel(id, part, button) {
750
+ const open = !this.models.has(id);
751
+ if (open) {
752
+ this.models.add(id);
753
+ } else {
754
+ this.models.delete(id);
755
+ }
756
+ part.hidden = !open;
757
+ button.setAttribute('aria-expanded', open ? 'true' : 'false');
758
+ if (open) {
759
+ part.querySelector('select').focus();
760
+ }
761
+ }
762
+
763
+ // The line of a card that follows the newest headless run of its challenge.
764
+ jobLineOf(id) {
765
+ const line = h('div', { class: 'challenge-job', 'data-challenge': id, role: 'status', 'aria-live': 'polite', hidden: true });
766
+ this.fillJob(line, this.jobs.find((job) => job.challenge === id) || null);
767
+ return line;
768
+ }
769
+
770
+ // The picture of a card, its still or a drawing of its kind, the teaser that plays over it,
771
+ // the kind, the difficulty and the person's medal. The video is decoration and never sounds.
772
+ media(c, title, kind, difficulty, medal) {
773
+ const files = (this.teaserFiles && this.teaserFiles[c.id]) || {};
774
+ const box = h('div', { class: 'challenge-media', 'data-picture': files.still ? 'still' : 'drawn', 'data-playing': 'false' },
775
+ h('div', { class: 'challenge-drawn', 'aria-hidden': 'true' }, icon(kind ? CATEGORIES[kind].symbol : 'trophy')));
776
+ const card = { id: c.id, still: files.still || null, teaser: files.teaser || null, video: null };
777
+ if (files.still) {
778
+ const still = h('img', { class: 'challenge-still', src: files.still, alt: '', width: '480', height: '270', loading: 'lazy', decoding: 'async' });
779
+ still.addEventListener('error', () => {
780
+ card.still = null;
781
+ still.remove();
782
+ box.dataset.picture = 'drawn';
783
+ });
784
+ box.append(still);
785
+ }
786
+ if (kind) {
787
+ box.append(h('span', { class: 'challenge-chip' }, icon(CATEGORIES[kind].symbol), t(`challenges.category.${kind}`)));
788
+ }
789
+ if (difficulty) {
790
+ const level = DIFFICULTIES.indexOf(difficulty);
791
+ const words = t(`challenges.difficulty.${difficulty}`);
792
+ box.append(h('span', { class: 'challenge-difficulty' },
793
+ h('span', { class: 'challenge-pips', 'aria-hidden': 'true' }, ...DIFFICULTIES.map((_, i) => h('i', { class: i <= level ? 'on' : '' }))),
794
+ h('span', { class: 'sr-only' }, t('challenges.difficulty.label', { level: words })),
795
+ h('span', { 'aria-hidden': 'true' }, words)));
796
+ }
797
+ const badge = medalBadge(medal);
798
+ if (badge) {
799
+ badge.prepend(icon('podium'));
800
+ box.append(badge);
801
+ }
802
+ const play = h('button', {
803
+ type: 'button', class: 'btn small challenge-teaser', 'data-action': 'teaser', 'aria-pressed': 'false',
804
+ hidden: !files.teaser, ...symbolOnly(t('challenges.teaser.play', { title })),
805
+ }, icon('play'));
806
+ box.append(play);
807
+ card.show = (view) => this.showTeaser(card, box, play, view);
808
+ play.addEventListener('click', () => this.teasers.toggle(c.id));
809
+ this.teasers.add(card);
810
+ return box;
811
+ }
812
+
813
+ // Draws what the teasers' controller says of a card, starting or stopping its video.
814
+ showTeaser(card, box, play, view) {
815
+ box.dataset.picture = card.still ? 'still' : 'drawn';
816
+ box.dataset.playing = view.playing ? 'true' : 'false';
817
+ play.hidden = !view.button;
818
+ play.setAttribute('aria-pressed', view.playing ? 'true' : 'false');
819
+ reicon(play, view.playing ? 'pause' : 'play');
820
+ if (view.playing) {
821
+ if (!card.video) {
822
+ const video = h('video', {
823
+ class: 'challenge-video', 'aria-hidden': 'true', tabindex: '-1', muted: true, playsinline: true, loop: true,
824
+ preload: 'auto', disablepictureinpicture: true, disableremoteplayback: true, width: '480', height: '270',
825
+ });
826
+ video.muted = true;
827
+ video.defaultMuted = true;
828
+ video.volume = 0;
829
+ video.addEventListener('playing', () => video.classList.add('ready'));
830
+ video.addEventListener('error', () => this.teasers.fail(card.id));
831
+ video.src = card.teaser;
832
+ card.video = video;
833
+ play.before(video);
834
+ }
835
+ card.video.muted = true;
836
+ const started = card.video.play();
837
+ if (started && typeof started.catch === 'function') {
838
+ started.catch(() => {});
839
+ }
840
+ } else if (card.video) {
841
+ card.video.pause();
842
+ card.video.classList.remove('ready');
843
+ try {
844
+ card.video.currentTime = 0;
845
+ } catch {
846
+ // A video without data has no time to go back to.
847
+ }
848
+ }
849
+ }
850
+
851
+ // The pointer and the keyboard focus on the cards, followed once on the list. Focus that a
852
+ // click gives a button plays nothing, only focus that the keyboard moves does.
853
+ bindTeasers(list) {
854
+ const cardOf = (target) => (target && target.closest ? target.closest('.challenge-item[data-id]') : null);
855
+ list.addEventListener('pointerover', (event) => {
856
+ const card = cardOf(event.target);
857
+ if (card && event.pointerType !== 'touch' && !card.contains(event.relatedTarget)) {
858
+ this.teasers.enter(card.dataset.id, 'hover');
859
+ }
860
+ });
861
+ list.addEventListener('pointerout', (event) => {
862
+ const card = cardOf(event.target);
863
+ if (card && !card.contains(event.relatedTarget)) {
864
+ this.teasers.leave(card.dataset.id, 'hover');
865
+ }
866
+ });
867
+ list.addEventListener('focusin', (event) => {
868
+ const card = cardOf(event.target);
869
+ let keyboard = true;
870
+ try {
871
+ keyboard = event.target.matches(':focus-visible');
872
+ } catch {
873
+ // A browser without :focus-visible counts every focus.
874
+ }
875
+ if (card && keyboard) {
876
+ this.teasers.enter(card.dataset.id, 'focus');
877
+ }
878
+ });
879
+ list.addEventListener('focusout', (event) => {
880
+ const card = cardOf(event.target);
881
+ if (card && !card.contains(event.relatedTarget)) {
882
+ this.teasers.leave(card.dataset.id, 'focus');
883
+ }
884
+ });
885
+ // A change of the motion setting, which marks the page's root, stops or allows the
886
+ // teasers at once.
887
+ if (typeof MutationObserver === 'function') {
888
+ new MutationObserver(() => this.teasers.refresh())
889
+ .observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
890
+ }
891
+ }
892
+
893
+ async toggleBoard(c, board, button) {
894
+ if (this.boards.has(c.id)) {
895
+ this.boards.delete(c.id);
896
+ board.hidden = true;
897
+ button.setAttribute('aria-expanded', 'false');
898
+ return;
899
+ }
900
+ button.setAttribute('aria-expanded', 'true');
901
+ board.hidden = false;
902
+ board.replaceChildren(h('p', { class: 'muted small' }, t('challenges.board.loading')));
903
+ try {
904
+ const data = await challengeApi.board(c.id);
905
+ this.boards.set(c.id, data.entries);
906
+ this.fillBoard(c, board, data.entries);
907
+ } catch (err) {
908
+ board.replaceChildren(h('p', { class: 'error-text' }, err.message));
909
+ }
910
+ }
911
+
912
+ fillBoard(c, board, entries) {
913
+ const seeds = h('p', { class: 'muted small' }, t('challenges.board.seeds', { seeds: c.seeds.join(', ') }));
914
+ if (!entries.length) {
915
+ board.replaceChildren(h('p', { class: 'muted small' }, t('challenges.board.empty')), seeds);
916
+ return;
917
+ }
918
+ const rows = entries.map((e) => h('tr', { class: e.kind },
919
+ h('td', { class: 'num' }, String(e.rank)),
920
+ h('td', {}, teamName(e), ' ', h('span', { class: `challenge-kind ${e.kind}` }, competitorKind(e)),
921
+ Array.isArray(e.playing) ? h('span', { class: 'muted small challenge-playing' }, playingText(e.playing)) : null),
922
+ h('td', { class: 'num' }, valueText(c, e.value)),
923
+ h('td', { class: 'num' }, t('challenges.board.seconds', { seconds: num(e.seconds) })),
924
+ h('td', {}, medalMark(e.medal, t('challenges.board.noMedal'))),
925
+ h('td', {}, formatDateTime(e.time * 1000, DAY))));
926
+ board.replaceChildren(h('table', {}, h('caption', { class: 'sr-only' }, t('challenges.board.label', { title: challengeTitle(c) })),
927
+ h('thead', {}, h('tr', {}, h('th', { class: 'num', scope: 'col' }, t('challenges.board.rank')), h('th', { scope: 'col' }, t('challenges.board.competitor')),
928
+ h('th', { class: 'num', scope: 'col' }, t('challenges.board.result')), h('th', { class: 'num', scope: 'col' }, t('challenges.board.toScore')),
929
+ h('th', { scope: 'col' }, t('challenges.board.medal')), h('th', { scope: 'col' }, t('challenges.board.date')))),
930
+ h('tbody', {}, rows)), seeds);
931
+ }
932
+
933
+ // A person's run.
934
+
935
+ async start(id) {
936
+ this.build();
937
+ if (!this.catalog.length) {
938
+ await this.show();
939
+ }
940
+ const challenge = this.find(id);
941
+ if (!challenge) {
942
+ throw new Error(`No challenge ${id}.`);
943
+ }
944
+ this.abandon(this.run);
945
+ this.run = {
946
+ id, challenge, index: 0, matches: [], results: [], name: this.personName(), unnamed: !this.typed(), outcome: null, keep: this.keepRecordings(),
947
+ };
948
+ await this.attempt();
949
+ return this.state();
950
+ }
951
+
952
+ async attempt() {
953
+ const run = this.run;
954
+ if (!run) {
955
+ return;
956
+ }
957
+ if (this.app.page !== 'play') {
958
+ this.app.showPage('play');
959
+ }
960
+ const c = run.challenge;
961
+ await this.play.start({
962
+ scenario: c.scenario, side: 'left', ability: c.ability.slice(), record: true,
963
+ teams: ['builtin', 'builtin'], seed: c.seeds[run.index], challenge: run,
964
+ });
965
+ }
966
+
967
+ async next() {
968
+ if (this.run && !this.run.outcome && this.run.index < this.run.challenge.attempts - 1 && this.run.matches.length > this.run.index) {
969
+ this.run.index++;
970
+ await this.attempt();
971
+ }
972
+ return this.state();
973
+ }
974
+
975
+ // Leaves a run for this page, with the focus on Play it yourself of the challenge left, so
976
+ // the end card leads back to the challenge just played.
977
+ leave(id = null) {
978
+ const left = id || (this.run && this.run.id);
979
+ this.abandon(this.run);
980
+ this.run = null;
981
+ this.renderBanner();
982
+ return this.back(left);
983
+ }
984
+
985
+ // Leaves the game view of Play for this page with the focus on a challenge's card. A run
986
+ // left this way stays, and the banner of the page offers its next attempt.
987
+ back(id = null) {
988
+ if (this.app.play) {
989
+ this.app.play.showSetup(false);
990
+ }
991
+ this.app.showPage('challenges');
992
+ return this.focusCard(id);
993
+ }
994
+
995
+ // Whether runs keep their attempts as recordings, as the setting of the section says.
996
+ keepRecordings() {
997
+ return this.keepBox ? this.keepBox.checked : readKeep();
998
+ }
999
+
1000
+ // Hands the attempts of a run left before its end to the server, which keeps them as a group
1001
+ // or, for a run without recordings, removes them.
1002
+ abandon(run) {
1003
+ if (!run || run.outcome || !run.matches.some(Boolean)) {
1004
+ return;
1005
+ }
1006
+ challengeApi.leaveRun(run.id, run.name, run.matches.slice(), run.keep !== false, !!run.unnamed)
1007
+ .catch((err) => this.app.hud && this.app.hud.toast(err.message, 'error'));
1008
+ }
1009
+
1010
+ renderBanner() {
1011
+ if (!this.banner) {
1012
+ return;
1013
+ }
1014
+ const run = this.run;
1015
+ const waiting = run && !run.outcome && run.matches.length > run.index;
1016
+ this.banner.hidden = !waiting;
1017
+ if (!waiting) {
1018
+ this.banner.replaceChildren();
1019
+ return;
1020
+ }
1021
+ const last = run.index === run.challenge.attempts - 1;
1022
+ this.banner.replaceChildren(...[
1023
+ h('span', {}, t('challenges.run.banner', { title: challengeTitle(run.challenge), count: run.matches.length, total: run.challenge.attempts })),
1024
+ last ? null : h('button', { type: 'button', class: 'btn small', ...symbolOnly(t('challenges.run.next')), onclick: () => this.next() }, icon('next')),
1025
+ h('button', { type: 'button', class: 'btn small', ...symbolOnly(t('challenges.run.leave')), onclick: () => this.leave(run.id) }, icon('exit'))].filter(Boolean));
1026
+ }
1027
+
1028
+ // The end card of a game. A game of a challenge shows its attempt's result and leads on to the
1029
+ // next attempt, or after the last one to the run's medal, and any other game keeps its card.
1030
+ endCard(entry, game, lines) {
1031
+ const run = this.run;
1032
+ const ofRun = !!(run && game && game.spec && game.spec.challenge === run);
1033
+ const earned = this.gameAchievements(entry, ofRun ? (run.seen ||= new Set()) : null);
1034
+ if (!ofRun) {
1035
+ return earned ? [lines[0], lines[1], earned, ...lines.slice(2)] : lines;
1036
+ }
1037
+ const c = run.challenge;
1038
+ const index = run.index;
1039
+ const match = entry.match || null;
1040
+ run.matches[index] = match;
1041
+ const last = index === c.attempts - 1;
1042
+ const result = h('div', { class: 'challenge-result', role: 'status', 'aria-live': 'polite' },
1043
+ h('p', {}, t('challenges.run.attempt', { title: challengeTitle(c), number: index + 1, count: c.attempts })),
1044
+ h('p', { class: 'muted' }, match ? t('challenges.run.scoring') : t('challenges.run.missed')));
1045
+ const buttons = [];
1046
+ if (!last) {
1047
+ buttons.push(h('button', { type: 'button', class: 'btn primary', onclick: () => this.next() }, icon('next'), t('challenges.run.next')));
1048
+ }
1049
+ if (match) {
1050
+ buttons.push(h('button', { type: 'button', class: 'btn', onclick: () => this.app.openMatchInWatch(match) }, icon('watch'), t('challenges.run.watch')));
1051
+ buttons[buttons.length - 1].dataset.action = 'watch';
1052
+ }
1053
+ buttons.push(h('button', { type: 'button', class: 'btn', 'data-action': 'leave', onclick: () => this.leave(c.id) }, icon(last ? 'challenges' : 'exit'), last ? t('challenges.title') : t('challenges.run.leave')));
1054
+ const shown = [lines[0], lines[1], ...lines.filter((line) => line.classList && line.classList.contains('error-text')), result, earned, ...buttons].filter(Boolean);
1055
+ this.scoreAttempt(run, index, match, result).then(() => (last ? this.finishRun(run, result, buttons) : null));
1056
+ this.renderBanner();
1057
+ return shown;
1058
+ }
1059
+
1060
+ async scoreAttempt(run, index, match, host) {
1061
+ const c = run.challenge;
1062
+ if (!match) {
1063
+ run.results[index] = { index, seed: c.seeds[index], match: null, finished: false, goals: [0, 0], scored: false, seconds: null, counts: false };
1064
+ return;
1065
+ }
1066
+ try {
1067
+ const scored = await challengeApi.score(run.id, match, index);
1068
+ run.results[index] = scored.result;
1069
+ host.children[1].textContent = attemptText(c, scored.result);
1070
+ if (scored.problem) {
1071
+ // The reason shows with its marks, such as a field of the recording as code.
1072
+ host.append(h('p', { class: 'error-text' }, tn('challenges.run.problem', { problem: h('span', {}, serverNodes(scored, 'problem')) })));
1073
+ }
1074
+ } catch (err) {
1075
+ host.children[1].textContent = t('challenges.run.scoreFailed', { reason: err.message });
1076
+ }
1077
+ }
1078
+
1079
+ async finishRun(run, host, buttons) {
1080
+ if (this.run !== run) {
1081
+ return;
1082
+ }
1083
+ const c = run.challenge;
1084
+ host.append(h('p', { class: 'muted' }, t('challenges.run.sending')));
1085
+ try {
1086
+ const outcome = await challengeApi.submit(run.id, run.name, run.matches.slice(0, c.attempts), run.keep !== false, !!run.unnamed);
1087
+ run.outcome = outcome;
1088
+ host.lastChild.remove();
1089
+ host.append(...this.outcomeLines(c, outcome));
1090
+ // The games of the attempts already counted their own achievements.
1091
+ const awards = (outcome.achievements || []).filter((id) => CHALLENGE_AWARDS.has(id));
1092
+ const lines = this.achievementLines(awards, addEarned(awards));
1093
+ if (lines) {
1094
+ host.append(lines);
1095
+ }
1096
+ this.renderAchievements();
1097
+ } catch (err) {
1098
+ host.lastChild.textContent = t('challenges.run.sendFailed', { reason: err.message });
1099
+ }
1100
+ const again = h('button', { type: 'button', class: 'btn primary', onclick: () => this.start(c.id) }, icon('replay'), t('challenges.run.again'));
1101
+ const panel = buttons[0] && buttons[0].parentNode;
1102
+ if (panel) {
1103
+ panel.insertBefore(again, buttons[0]);
1104
+ again.focus();
1105
+ }
1106
+ if (run.outcome && run.keep === false) {
1107
+ // A run without recordings has nothing left to watch.
1108
+ buttons.filter((button) => button.dataset.action === 'watch').forEach((button) => button.remove());
1109
+ }
1110
+ this.renderBanner();
1111
+ this.show();
1112
+ }
1113
+
1114
+ // The lines of a finished run, its value, medal, personal best and rank.
1115
+ outcomeLines(c, outcome) {
1116
+ const lines = [
1117
+ h('p', { class: 'play-score' }, runText(c, outcome, 'challenges.outcome.value')),
1118
+ // The medal shows as a badge that says its name, and the items mark the one it reached.
1119
+ h('p', {}, outcome.medal ? medalBadge(outcome.medal, t('challenges.outcome.medal', { medal: MEDAL_NAMES[outcome.medal] })) : t('challenges.outcome.noMedal')),
1120
+ medalList(c, outcome.practice ? null : outcome.value, t('challenges.medals.run'), t('challenges.medals.label', { title: challengeTitle(c) })),
1121
+ ];
1122
+ if (outcome.practice) {
1123
+ lines.push(h('p', { class: 'muted' }, tn('challenges.outcome.practice')));
1124
+ } else if (outcome.personal_best) {
1125
+ const best = outcome.personal_best;
1126
+ const rank = outcome.rank ?? t('challenges.rank.outside');
1127
+ // A competitor's first run is its best by default, so it says first rather than new best.
1128
+ const id = outcome.first ? 'challenges.outcome.first' : 'challenges.outcome.improved';
1129
+ lines.push(h('p', {}, outcome.first || outcome.improved ? t(id, { rank })
1130
+ : runText(c, best, 'challenges.outcome.best', { rank })));
1131
+ }
1132
+ return lines;
1133
+ }
1134
+
1135
+ // Headless runs of registered teams.
1136
+
1137
+ async runHeadless(id, team = null, attempts = undefined, keep = this.keepRecordings()) {
1138
+ const chosen = team || this.choice.team || 'builtin';
1139
+ const count = attempts !== undefined ? attempts : (this.choice.attempts !== 'full' ? Number(this.choice.attempts) : null);
1140
+ let job;
1141
+ try {
1142
+ job = await challengeApi.runTeam(id, chosen, count, keep);
1143
+ } catch (err) {
1144
+ const line = this.list && this.list.querySelector(`.challenge-job[data-challenge="${CSS.escape(id)}"]`);
1145
+ if (line) {
1146
+ line.hidden = false;
1147
+ line.replaceChildren(h('span', { class: 'error-text' }, err.message));
1148
+ }
1149
+ throw err;
1150
+ }
1151
+ this.jobs = [job, ...this.jobs.filter((j) => j.id !== job.id)];
1152
+ this.renderJob();
1153
+ this.followJobs();
1154
+ return job;
1155
+ }
1156
+
1157
+ followJobs() {
1158
+ if (this.polling) {
1159
+ return;
1160
+ }
1161
+ this.polling = true;
1162
+ const tick = async () => {
1163
+ try {
1164
+ const before = new Map(this.jobs.map((j) => [j.id, j.state]));
1165
+ this.jobs = await challengeApi.jobs();
1166
+ this.renderJob();
1167
+ const ended = this.jobs.some((j) => j.state === 'done' && before.get(j.id) && before.get(j.id) !== 'done');
1168
+ if (ended) {
1169
+ // A board that is open stays open and shows the new result, so the page that
1170
+ // rebuilds itself does not close it under the reader.
1171
+ const open = [...this.boards.keys()];
1172
+ this.boards.clear();
1173
+ await Promise.all(open.map((id) => challengeApi.board(id)
1174
+ .then((data) => this.boards.set(id, data.entries), () => null)));
1175
+ this.show();
1176
+ }
1177
+ } catch {
1178
+ // The next refresh of the page tries again.
1179
+ }
1180
+ if (this.jobs.some((j) => j.state === 'queued' || j.state === 'running')) {
1181
+ setTimeout(tick, POLL_MS);
1182
+ } else {
1183
+ this.polling = false;
1184
+ }
1185
+ };
1186
+ tick();
1187
+ }
1188
+
1189
+ renderJob() {
1190
+ if (!this.list) {
1191
+ return;
1192
+ }
1193
+ for (const line of this.list.querySelectorAll('.challenge-job[data-challenge]')) {
1194
+ this.fillJob(line, this.jobs.find((job) => job.challenge === line.dataset.challenge) || null);
1195
+ }
1196
+ }
1197
+
1198
+ // Fills the line of a card with a headless run of its challenge, its progress and Cancel run
1199
+ // while it plays, and its result, medal items and failed attempts once it ends.
1200
+ fillJob(line, job) {
1201
+ if (!job) {
1202
+ line.hidden = true;
1203
+ line.replaceChildren();
1204
+ return;
1205
+ }
1206
+ const c = this.find(job.challenge) || { title: job.challenge, measure: 'scored', attempts: job.attempts };
1207
+ const title = this.find(job.challenge) ? challengeTitle(c) : c.title;
1208
+ let text;
1209
+ if (job.state === 'queued') {
1210
+ text = t('challenges.job.queued', { name: teamName(job), title });
1211
+ } else if (job.state === 'running') {
1212
+ text = t('challenges.job.running', { name: teamName(job), title, count: job.results.length, total: job.attempts });
1213
+ } else if (job.state === 'done' && job.result) {
1214
+ const r = job.result;
1215
+ const rank = r.rank ?? t('challenges.rank.outside');
1216
+ const medal = r.practice ? t('challenges.job.practice') : r.medal ? t('challenges.job.medal', { medal: MEDAL_NAMES[r.medal], rank }) : t('challenges.job.noMedal', { rank });
1217
+ const won = (r.achievements || []).map((id) => this.nameOf(id));
1218
+ const values = { name: teamName(job), title, value: valueText(c, r.value), medal, names: won.join(', ') };
1219
+ text = t(won.length ? 'challenges.job.doneAchievements' : 'challenges.job.done', values);
1220
+ if (Array.isArray(r.playing)) {
1221
+ text = `${text} ${playingText(r.playing)}`;
1222
+ }
1223
+ } else {
1224
+ text = t('challenges.job.stopped', { name: teamName(job), title, reason: job.error ? serverText(job, 'error') : t('challenges.job.noResult') });
1225
+ }
1226
+ const live = job.state === 'queued' || job.state === 'running';
1227
+ const known = this.find(job.challenge);
1228
+ const failed = job.state === 'done' && job.result && job.result.failed_attempts
1229
+ ? h('span', { class: 'muted' }, t('challenges.job.failedAttempts', { count: job.result.failed_attempts })) : null;
1230
+ const medals = job.state === 'done' && job.result && known
1231
+ ? medalList(known, job.result.practice ? null : job.result.value, t('challenges.medals.reached'), t('challenges.medals.label', { title })) : null;
1232
+ line.hidden = false;
1233
+ line.replaceChildren(...[icon('robot'), h('span', { class: job.state === 'failed' ? 'error-text' : '' }, text),
1234
+ live ? h('progress', { class: 'bench-progress', max: '1', value: String(Number(job.progress) || 0), 'aria-label': t('challenges.job.progress') }) : null,
1235
+ live ? h('button', { type: 'button', class: 'btn small', ...symbolOnly(t('challenges.job.cancel')), onclick: () => this.cancelJob(job.id) }, icon('stop')) : null,
1236
+ failed, medals].filter(Boolean));
1237
+ }
1238
+
1239
+ async cancelJob(id) {
1240
+ try {
1241
+ await challengeApi.cancel(id);
1242
+ } catch (err) {
1243
+ this.app.hud.toast(err.message, 'error');
1244
+ }
1245
+ this.polling = false;
1246
+ this.followJobs();
1247
+ }
1248
+
1249
+ // The state of a person's run, for scripts.
1250
+ state() {
1251
+ const run = this.run;
1252
+ return {
1253
+ run: run ? { challenge: run.id, attempt: run.index, attempts: run.challenge.attempts, matches: run.matches.slice(), results: run.results.slice(), outcome: run.outcome } : null,
1254
+ jobs: this.jobs.slice(),
1255
+ };
1256
+ }
1257
+
1258
+ // The scripting functions of window.cuju.challenges, which need no mouse.
1259
+ script() {
1260
+ const self = this;
1261
+ return {
1262
+ list: async () => (self.catalog = await challengeApi.list(self.personName())),
1263
+ board: async (id) => (await challengeApi.board(id)).entries,
1264
+ start: async (id, { name } = {}) => {
1265
+ self.build();
1266
+ if (name) {
1267
+ writeName(String(name));
1268
+ if (self.nameField) {
1269
+ self.nameField.value = typedName(String(name));
1270
+ }
1271
+ }
1272
+ return self.start(id);
1273
+ },
1274
+ next: () => self.next(),
1275
+ leave: (id = null) => self.leave(id),
1276
+ // Shows the page, and with an identifier puts the focus on that challenge's card.
1277
+ show: async (id = null) => {
1278
+ self.app.showPage('challenges');
1279
+ return self.focusCard(id);
1280
+ },
1281
+ run: (id, { team = 'builtin', attempts = null, keepRecordings } = {}) => self.runHeadless(id, team, attempts, keepRecordings ?? self.keepRecordings()),
1282
+ jobs: async () => (self.jobs = await challengeApi.jobs()),
1283
+ cancel: (job) => self.cancelJob(job),
1284
+ score: (id, match, attempt) => challengeApi.score(id, match, attempt),
1285
+ achievements: async () => {
1286
+ const data = await self.loadAchievements();
1287
+ self.renderAchievements();
1288
+ return { catalog: data.catalog, person: readEarned(), teams: data.teams };
1289
+ },
1290
+ state: () => self.state(),
1291
+ // The benchmark of models, one job of several teams on several challenges.
1292
+ bench: (teams, { challenges = null, keepRecordings = false, workers = null } = {}) => self.bench.start(teams, challenges, keepRecordings, workers),
1293
+ benches: () => benchApi.list(),
1294
+ benchJob: () => self.bench.job,
1295
+ cancelBench: () => self.bench.cancel(),
1296
+ benchTable: (job = self.bench.job) => (job ? benchTable(job) : null),
1297
+ benchCsv: (job = self.bench.job) => (job ? benchCsv(job) : ''),
1298
+ api: challengeApi,
1299
+ benchApi,
1300
+ };
1301
+ }
1302
+ }