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