code-battles 1.2.0 → 1.3.0

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.
package/dist/cjs/index.js CHANGED
@@ -16020,6 +16020,7 @@ const Simulation = () => {
16020
16020
  !location.search.includes("background=true") && (React.createElement(React.Fragment, null,
16021
16021
  React.createElement("div", { style: { textAlign: "center", flexGrow: 1 } },
16022
16022
  showcaseMode || (React.createElement(React.Fragment, null,
16023
+ React.createElement("span", { id: "render-status", style: { marginRight: 10 } }),
16023
16024
  React.createElement(core.NumberInput, { id: "breakpoint", label: "Breakpoint", min: 0, leftSection: React.createElement("i", { className: "fa-solid fa-stopwatch" }), display: "inline-block", maw: "35%", mr: "xs" }),
16024
16025
  React.createElement(core.Button, { style: { flex: "none" }, my: "xs", w: 100, leftSection: React.createElement("i", { className: "fa-solid fa-wand-magic" }), color: "grape", id: "step", mr: "xs", radius: "20px" }, "Step"))),
16025
16026
  React.createElement(PlayPauseButton, null)),
@@ -1,9 +1,7 @@
1
1
  import sys
2
2
 
3
- from code_battles.utilities import GameCanvas, Alignment
4
- from code_battles.battles import CodeBattles, is_web
5
- from js import window
6
- from pyscript.ffi import create_proxy
3
+ from code_battles.utilities import GameCanvas, Alignment, is_web, is_worker
4
+ from code_battles.battles import CodeBattles
7
5
 
8
6
 
9
7
  def run_game(battles: CodeBattles):
@@ -11,8 +9,18 @@ def run_game(battles: CodeBattles):
11
9
  Binds the given code battles instance to the React code to enable all simulations.
12
10
  """
13
11
 
14
- if is_web:
12
+ if is_web():
13
+ from js import window
14
+ from pyscript.ffi import create_proxy
15
+
15
16
  window._startSimulation = create_proxy(battles._start_simulation)
17
+ elif is_worker():
18
+ setattr(
19
+ sys.modules["__main__"],
20
+ "_run_webworker_simulation",
21
+ battles._run_webworker_simulation,
22
+ )
23
+ setattr(sys.modules["__main__"], "__export__", ["_run_webworker_simulation"])
16
24
  else:
17
25
  battles._run_local_simulation()
18
26
 
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import base64
2
3
  import json
3
4
  import math
4
5
  import time
@@ -13,25 +14,20 @@ from code_battles.utilities import (
13
14
  download_image,
14
15
  set_results,
15
16
  show_alert,
17
+ web_only,
18
+ is_web,
16
19
  )
17
- from js import Audio, Image, document, window, FontFace
18
- from pyscript.ffi import create_proxy
20
+
21
+ try:
22
+ import js
23
+ except Exception:
24
+ pass
19
25
 
20
26
  GameStateType = TypeVar("GameStateType")
21
27
  APIImplementationType = TypeVar("APIImplementationType")
22
28
  APIType = TypeVar("APIType")
23
29
  PlayerRequestsType = TypeVar("PlayerRequestsType")
24
30
 
25
- is_web = "MicroPython" in sys.version or "pyodide" in sys.executable
26
-
27
-
28
- def web_only(method):
29
- def wrapper(*args, **kwargs):
30
- if is_web:
31
- return method(*args, **kwargs)
32
-
33
- return wrapper
34
-
35
31
 
36
32
  class CodeBattles(
37
33
  Generic[GameStateType, APIImplementationType, APIType, PlayerRequestsType]
@@ -56,7 +52,7 @@ class CodeBattles(
56
52
  """The name of the players. This is populated before any of the overridable methods run."""
57
53
  map: str
58
54
  """The name of the map. This is populated before any of the overridable methods run."""
59
- map_image: Image
55
+ map_image: "js.Image"
60
56
  """The map image. This is populated before any of the overridable methods run."""
61
57
  canvas: GameCanvas
62
58
  """The game's canvas. Useful for the :func:`render` method. This is populated before any of the overridable methods run, but it isn't populated for background simulations, so you should only use it in :func:`render`."""
@@ -79,7 +75,8 @@ class CodeBattles(
79
75
  _player_globals: List[Dict[str, Any]]
80
76
  _initialized: bool
81
77
  _eliminated: List[int]
82
- _sounds: Dict[str, Audio] = {}
78
+ _sounds: Dict[str, "js.Audio"] = {}
79
+ _decisions: List[bytes]
83
80
 
84
81
  def render(self) -> None:
85
82
  """
@@ -227,11 +224,12 @@ class CodeBattles(
227
224
  @web_only
228
225
  def download_images(
229
226
  self, sources: List[Tuple[str, str]]
230
- ) -> asyncio.Future[Dict[str, Image]]:
227
+ ) -> asyncio.Future[Dict[str, "js.Image"]]:
231
228
  """
232
229
  :param sources: A list of ``(image_name, image_url)`` to download.
233
230
  :returns: A future which can be ``await``'d containing a dictionary mapping each ``image_name`` to its loaded image.
234
231
  """
232
+ from js import Image
235
233
 
236
234
  remaining_images: List[str] = []
237
235
  result = asyncio.Future()
@@ -268,6 +266,7 @@ class CodeBattles(
268
266
  @web_only
269
267
  async def load_font(self, name: str, url: str) -> None:
270
268
  """Loads the font from the specified url as the specified name."""
269
+ from js import FontFace, document
271
270
 
272
271
  ff = FontFace.new(name, f"url({url})")
273
272
  await ff.load()
@@ -334,7 +333,7 @@ class CodeBattles(
334
333
 
335
334
  For game-global log entries (not coming from a specific player), don't specify a ``player_index``.
336
335
  """
337
- if is_web:
336
+ if is_web():
338
337
  console_log(-1 if player_index is None else player_index, text, color)
339
338
  else:
340
339
  self._logs.append(
@@ -349,6 +348,7 @@ class CodeBattles(
349
348
  @web_only
350
349
  def play_sound(self, sound: str):
351
350
  """Plays the given sound, from the URL given by :func:`configure_sound_url`."""
351
+ from js import window, Audio
352
352
 
353
353
  if sound not in self._sounds:
354
354
  self._sounds[sound] = Audio.new(self.configure_sound_url(sound))
@@ -370,7 +370,11 @@ class CodeBattles(
370
370
 
371
371
  return len(self.active_players) <= 1
372
372
 
373
+ @web_only
373
374
  def _initialize(self):
375
+ from js import window, document
376
+ from pyscript.ffi import create_proxy
377
+
374
378
  window.addEventListener("resize", create_proxy(lambda _: self._resize_canvas()))
375
379
  document.getElementById("playpause").onclick = create_proxy(
376
380
  lambda _: asyncio.get_event_loop().run_until_complete(self._play_pause())
@@ -383,6 +387,8 @@ class CodeBattles(
383
387
 
384
388
  def _initialize_simulation(self, player_codes: List[str]):
385
389
  self.step = 0
390
+ self._logs = []
391
+ self._decisions = []
386
392
  self.active_players = list(range(len(self.player_names)))
387
393
  self.active_players = list(range(len(self.player_names)))
388
394
  self.state = self.create_initial_state()
@@ -392,6 +398,38 @@ class CodeBattles(
392
398
  ]
393
399
  self._eliminated = []
394
400
  self._player_globals = self._get_initial_player_globals(player_codes)
401
+ self._webworker_frame = 0
402
+
403
+ def _run_webworker_simulation(
404
+ self, map: str, player_names_str: str, player_codes_str: str
405
+ ):
406
+ from pyscript import sync
407
+
408
+ # JS to Python
409
+ player_names = json.loads(player_names_str)
410
+ player_codes = json.loads(player_codes_str)
411
+
412
+ self.map = map
413
+ self.player_names = player_names
414
+ self.background = True
415
+ self.console_visible = False
416
+ self.verbose = False
417
+ self._initialize_simulation(player_codes)
418
+ while not self.over:
419
+ self._logs = []
420
+ decisions = self.make_decisions()
421
+ logs = self._logs
422
+ self._logs = []
423
+ self.apply_decisions(decisions)
424
+
425
+ sync.update_step(
426
+ base64.b64encode(decisions).decode(),
427
+ json.dumps(logs),
428
+ "true" if self.over else "false",
429
+ )
430
+
431
+ if not self.over:
432
+ self.step += 1
395
433
 
396
434
  def _run_local_simulation(self):
397
435
  self.map = sys.argv[1]
@@ -399,7 +437,6 @@ class CodeBattles(
399
437
  self.background = True
400
438
  self.console_visible = False
401
439
  self.verbose = False
402
- self._logs = []
403
440
  player_codes = []
404
441
  for filename in sys.argv[3:]:
405
442
  with open(filename, "r") as f:
@@ -432,6 +469,7 @@ class CodeBattles(
432
469
  loop = asyncio.get_event_loop()
433
470
  loop.run_until_complete(self._start_simulation_async(*args, **kwargs))
434
471
 
472
+ @web_only
435
473
  async def _start_simulation_async(
436
474
  self,
437
475
  map: str,
@@ -441,7 +479,18 @@ class CodeBattles(
441
479
  console_visible: bool,
442
480
  verbose: bool,
443
481
  ):
482
+ from js import document
483
+ from pyscript import workers
484
+
485
+ # JS to Python
486
+ player_names = [str(x) for x in player_names]
487
+ player_codes = [str(x) for x in player_codes]
488
+
444
489
  try:
490
+ render_status = document.getElementById("render-status")
491
+ if render_status is not None:
492
+ render_status.textContent = "Rendering: Initializing..."
493
+
445
494
  self.map = map
446
495
  self.player_names = player_names
447
496
  self.map_image = await download_image(self.configure_map_image_url(map))
@@ -472,11 +521,36 @@ class CodeBattles(
472
521
  document.getElementById("loader").style.display = "none"
473
522
  self.render()
474
523
 
524
+ self._worker = await workers["worker"]
525
+ self._worker.update_step = self._update_step
526
+ self._worker._run_webworker_simulation(
527
+ map, json.dumps(player_names), json.dumps(player_codes)
528
+ )
529
+
475
530
  if self.background:
476
531
  await self._play_pause()
477
532
  except Exception:
478
533
  traceback.print_exc()
479
534
 
535
+ def _update_step(self, decisions_str: str, logs_str: str, is_over_str: str):
536
+ from js import document
537
+
538
+ decisions = base64.b64decode(str(decisions_str))
539
+ logs: list = json.loads(str(logs_str))
540
+ is_over = str(is_over_str) == "true"
541
+
542
+ self._decisions.append(decisions)
543
+ self._logs.append(logs)
544
+ self._webworker_frame += 1
545
+
546
+ render_status = document.getElementById("render-status")
547
+ if render_status is not None:
548
+ render_status.textContent = (
549
+ "Rendering: Complete!"
550
+ if is_over
551
+ else f"Rendering: Frame {self._webworker_frame}"
552
+ )
553
+
480
554
  def _get_initial_player_globals(self, player_codes: List[str]):
481
555
  contexts = [
482
556
  self.create_api_implementation(i) for i in range(len(self.player_names))
@@ -542,7 +616,10 @@ class CodeBattles(
542
616
 
543
617
  return player_globals
544
618
 
619
+ @web_only
545
620
  def _resize_canvas(self):
621
+ from js import document
622
+
546
623
  if not hasattr(self, "canvas"):
547
624
  return
548
625
 
@@ -555,9 +632,25 @@ class CodeBattles(
555
632
  if not self.background:
556
633
  self.render()
557
634
 
635
+ @web_only
558
636
  def _step(self):
637
+ from js import document, setTimeout
638
+ from pyscript.ffi import create_proxy
639
+
559
640
  if not self.over:
560
- self.apply_decisions(self.make_decisions())
641
+ if len(self._decisions) == 0:
642
+ print("Warning: sleeping because decisions were not made yet!")
643
+ setTimeout(create_proxy(self._step), 100)
644
+ return
645
+ else:
646
+ logs = self._logs.pop(0)
647
+ for log in logs:
648
+ console_log(
649
+ -1 if log["player_index"] is None else log["player_index"],
650
+ log["text"],
651
+ log["color"],
652
+ )
653
+ self.apply_decisions(self._decisions.pop(0))
561
654
 
562
655
  if not self.over:
563
656
  self.step += 1
@@ -585,7 +678,10 @@ class CodeBattles(
585
678
  if self.over:
586
679
  document.getElementById("noui-progress").style.display = "none"
587
680
 
681
+ @web_only
588
682
  def _should_play(self):
683
+ from js import document
684
+
589
685
  if self.over:
590
686
  return False
591
687
 
@@ -600,7 +696,10 @@ class CodeBattles(
600
696
 
601
697
  return True
602
698
 
699
+ @web_only
603
700
  def _get_playback_speed(self):
701
+ from js import document
702
+
604
703
  return 2 ** float(
605
704
  document.getElementById("timescale")
606
705
  .getElementsByClassName("mantine-Slider-thumb")
@@ -608,7 +707,10 @@ class CodeBattles(
608
707
  .ariaValueNow
609
708
  )
610
709
 
710
+ @web_only
611
711
  def _get_breakpoint(self):
712
+ from js import document
713
+
612
714
  breakpoint_element = document.getElementById("breakpoint")
613
715
  if breakpoint_element is None or breakpoint_element.value == "":
614
716
  return -1
@@ -15,6 +15,7 @@ class TwoDContext:
15
15
  font: str
16
16
  fillStyle: str
17
17
  strokeStyle: str
18
+ lineWidth: int
18
19
 
19
20
  @staticmethod
20
21
  def beginPath(): ...
@@ -28,6 +29,10 @@ class TwoDContext:
28
29
  counterclockwise=False,
29
30
  ): ...
30
31
  @staticmethod
32
+ def moveTo(x: int, y: int): ...
33
+ @staticmethod
34
+ def lineTo(x: int, y: int): ...
35
+ @staticmethod
31
36
  def stroke(): ...
32
37
  @staticmethod
33
38
  def fill(): ...
@@ -0,0 +1,4 @@
1
+ from typing import Literal
2
+
3
+ class PyWorker:
4
+ def __init__(self, path: str, type: Literal["micropython", "pyodide"]): ...
@@ -2,10 +2,39 @@
2
2
 
3
3
  import asyncio
4
4
  import math
5
+ import sys
5
6
  from typing import Callable, List, Union
6
7
  from enum import Enum
7
8
 
8
- from js import Element, Image, window
9
+ try:
10
+ import js
11
+ except Exception:
12
+ pass
13
+
14
+
15
+ def is_worker():
16
+ try:
17
+ from js import window # noqa: F401
18
+
19
+ return False
20
+ except Exception:
21
+ return True
22
+
23
+
24
+ def is_web():
25
+ return (
26
+ "MicroPython" in sys.version or "pyodide" in sys.executable
27
+ ) and not is_worker()
28
+
29
+
30
+ def web_only(method):
31
+ if is_web():
32
+ return method
33
+
34
+ def wrapper(*args, **kwargs):
35
+ print(f"Warning: {method.__name__} should only be called in a web context.")
36
+
37
+ return wrapper
9
38
 
10
39
 
11
40
  class Alignment(Enum):
@@ -13,7 +42,10 @@ class Alignment(Enum):
13
42
  TOP_LEFT = 1
14
43
 
15
44
 
16
- def download_image(src: str) -> Image:
45
+ @web_only
46
+ def download_image(src: str):
47
+ from js import Image
48
+
17
49
  result = asyncio.Future()
18
50
  image = Image.new()
19
51
  image.onload = lambda _: result.set_result(image)
@@ -24,14 +56,22 @@ def download_image(src: str) -> Image:
24
56
  def show_alert(
25
57
  title: str, alert: str, color: str, icon: str, limit_time: int = 5000, is_code=True
26
58
  ):
27
- if hasattr(window, "showAlert"):
28
- try:
29
- window.showAlert(title, alert, color, icon, limit_time, is_code)
30
- except Exception as e:
31
- print(e)
59
+ if is_web():
60
+ from js import window
61
+
62
+ if hasattr(window, "showAlert"):
63
+ try:
64
+ window.showAlert(title, alert, color, icon, limit_time, is_code)
65
+ except Exception as e:
66
+ print(e)
67
+ else:
68
+ print(f"[ALERT] {title}: {alert}")
32
69
 
33
70
 
71
+ @web_only
34
72
  def set_results(player_names: List[str], places: List[int], map: str, verbose: bool):
73
+ from js import window
74
+
35
75
  if hasattr(window, "setResults"):
36
76
  try:
37
77
  window.setResults(player_names, places, map, verbose)
@@ -39,7 +79,10 @@ def set_results(player_names: List[str], places: List[int], map: str, verbose: b
39
79
  print(e)
40
80
 
41
81
 
82
+ @web_only
42
83
  def download_json(filename: str, contents: str):
84
+ from js import window
85
+
43
86
  if hasattr(window, "downloadJson"):
44
87
  try:
45
88
  window.downloadJson(filename, contents)
@@ -47,7 +90,10 @@ def download_json(filename: str, contents: str):
47
90
  print(e)
48
91
 
49
92
 
93
+ @web_only
50
94
  def console_log(player_index: int, text: str, color: str):
95
+ from js import window
96
+
51
97
  if hasattr(window, "consoleLog"):
52
98
  try:
53
99
  window.consoleLog(player_index, text, color)
@@ -71,9 +117,9 @@ class GameCanvas:
71
117
 
72
118
  def __init__(
73
119
  self,
74
- canvas: Element,
120
+ canvas: "js.Element",
75
121
  player_count: int,
76
- map_image: Image,
122
+ map_image: "js.Image",
77
123
  max_width: int,
78
124
  max_height: int,
79
125
  extra_width: int,
@@ -89,7 +135,7 @@ class GameCanvas:
89
135
 
90
136
  def draw_element(
91
137
  self,
92
- image: Image,
138
+ image: "js.Image",
93
139
  x: int,
94
140
  y: int,
95
141
  width: int,
@@ -143,6 +189,26 @@ class GameCanvas:
143
189
  self.context.fillStyle = color
144
190
  self.context.fillText(text, x, y)
145
191
 
192
+ def draw_line(
193
+ self,
194
+ start_x: int,
195
+ start_y: int,
196
+ end_x: int,
197
+ end_y: int,
198
+ stroke="black",
199
+ stroke_width=10,
200
+ board_index=0,
201
+ ):
202
+ start_x, start_y = self._translate_position(board_index, start_x, start_y)
203
+ end_x, end_y = self._translate_position(board_index, end_x, end_y)
204
+
205
+ self.context.strokeStyle = stroke
206
+ self.context.lineWidth = stroke_width * self._scale
207
+ self.context.beginPath()
208
+ self.context.moveTo(start_x, start_y)
209
+ self.context.lineTo(end_x, end_y)
210
+ self.context.stroke()
211
+
146
212
  def draw_circle(
147
213
  self,
148
214
  x: int,
@@ -150,6 +216,7 @@ class GameCanvas:
150
216
  radius: float,
151
217
  fill="black",
152
218
  stroke="transparent",
219
+ stroke_width=2,
153
220
  board_index=0,
154
221
  ):
155
222
  """
@@ -157,10 +224,12 @@ class GameCanvas:
157
224
  """
158
225
 
159
226
  x, y = self._translate_position(board_index, x, y)
227
+
160
228
  self.context.fillStyle = fill
161
229
  self.context.strokeStyle = stroke
230
+ self.context.lineWidth = stroke_width * self._scale
162
231
  self.context.beginPath()
163
- self.context.arc(x, y, radius, 0, 2 * math.pi)
232
+ self.context.arc(x, y, radius * self._scale, 0, 2 * math.pi)
164
233
  self.context.stroke()
165
234
  self.context.fill()
166
235
 
@@ -187,6 +256,8 @@ class GameCanvas:
187
256
  return self.map_image.width * self.player_count
188
257
 
189
258
  def _fit_into(self, max_width: int, max_height: int):
259
+ from js import window
260
+
190
261
  if self.map_image.width == 0 or self.map_image.height == 0:
191
262
  raise Exception("Map image invalid!")
192
263
  aspect_ratio = (self.map_image.width * self.player_count + self.extra_width) / (
@@ -0,0 +1,6 @@
1
+ def run_game(game):
2
+ # game = pickle.loads(game)
3
+ print("Hi", game, type(game)) # game.player_names)
4
+
5
+
6
+ __export__ = ["run_game"]
package/dist/esm/index.js CHANGED
@@ -16018,6 +16018,7 @@ const Simulation = () => {
16018
16018
  !location.search.includes("background=true") && (React.createElement(React.Fragment, null,
16019
16019
  React.createElement("div", { style: { textAlign: "center", flexGrow: 1 } },
16020
16020
  showcaseMode || (React.createElement(React.Fragment, null,
16021
+ React.createElement("span", { id: "render-status", style: { marginRight: 10 } }),
16021
16022
  React.createElement(NumberInput, { id: "breakpoint", label: "Breakpoint", min: 0, leftSection: React.createElement("i", { className: "fa-solid fa-stopwatch" }), display: "inline-block", maw: "35%", mr: "xs" }),
16022
16023
  React.createElement(Button, { style: { flex: "none" }, my: "xs", w: 100, leftSection: React.createElement("i", { className: "fa-solid fa-wand-magic" }), color: "grape", id: "step", mr: "xs", radius: "20px" }, "Step"))),
16023
16024
  React.createElement(PlayPauseButton, null)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-battles",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A library for building interactive competitive coding battles",
5
5
  "repository": "https://github.com/noamzaks/code-battles",
6
6
  "homepage": "https://code-battles.readthedocs.org",
@@ -52,16 +52,16 @@
52
52
  "@rollup/plugin-commonjs": "^26.0.3",
53
53
  "@rollup/plugin-node-resolve": "^15.3.0",
54
54
  "@rollup/plugin-typescript": "^11.1.6",
55
- "@types/prismjs": "^1.26.4",
56
- "@types/react": "^18.3.11",
55
+ "@types/prismjs": "^1.26.5",
56
+ "@types/react": "^18.3.12",
57
57
  "@types/react-dom": "^18.3.1",
58
58
  "chokidar-cli": "^3.0.0",
59
- "rollup": "^4.24.0",
59
+ "rollup": "^4.24.3",
60
60
  "rollup-plugin-copy": "^3.5.0",
61
61
  "rollup-plugin-dts": "^6.1.1",
62
- "rollup-plugin-import-css": "^3.5.5",
62
+ "rollup-plugin-import-css": "^3.5.6",
63
63
  "rollup-plugin-peer-deps-external": "^2.2.4",
64
- "tslib": "^2.8.0",
64
+ "tslib": "^2.8.1",
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "prettier": {