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