code-battles 1.2.1 → 1.4.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
@@ -3,6 +3,7 @@
3
3
  require('@mantine/core/styles.css');
4
4
  require('@mantine/dates/styles.css');
5
5
  require('@mantine/notifications/styles.css');
6
+ require('@mantine/dropzone/styles.css');
6
7
  var core = require('@mantine/core');
7
8
  var dates = require('@mantine/dates');
8
9
  var hooks = require('@mantine/hooks');
@@ -12,6 +13,7 @@ var auth = require('firebase/auth');
12
13
  var firestore = require('firebase/firestore');
13
14
  var React = require('react');
14
15
  var reactRouterDom = require('react-router-dom');
16
+ var dropzone = require('@mantine/dropzone');
15
17
  var jsxRuntime = require('react/jsx-runtime');
16
18
 
17
19
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
@@ -411,19 +413,28 @@ const toPlacing = (n) => {
411
413
  };
412
414
  const runNoUI = (map, apis, playerBots, verbose) => {
413
415
  const players = playerBots.map((api) => (api === "None" ? "" : apis[api]));
414
- tryUntilFailure(() =>
416
+ tryUntilSuccess(() =>
415
417
  // @ts-ignore
416
418
  window._startSimulation(map, players, playerBots, true, false, verbose));
417
419
  };
418
- const tryUntilFailure = (f, timeout = 500) => {
420
+ const tryUntilSuccess = (f, timeout = 500) => {
419
421
  try {
420
422
  f();
421
423
  }
422
424
  catch (error) {
423
425
  console.log("Failed, waiting for timeout...", error === null || error === void 0 ? void 0 : error.message);
424
- setTimeout(() => tryUntilFailure(f, timeout), timeout);
426
+ setTimeout(() => tryUntilSuccess(f, timeout), timeout);
425
427
  }
426
428
  };
429
+ const downloadFile = (filename, mimeType, contents) => {
430
+ const a = document.createElement("a");
431
+ a.style.display = "none";
432
+ document.body.appendChild(a);
433
+ a.href = `data:${mimeType};charset=utf-8,${encodeURIComponent(contents)}`;
434
+ a.download = filename;
435
+ a.click();
436
+ document.body.removeChild(a);
437
+ };
427
438
 
428
439
  const useLocalStorage = ({ key, defaultValue, }) => {
429
440
  const [value, setValue] = React.useState(getLocalStorage(key, defaultValue !== null && defaultValue !== void 0 ? defaultValue : null));
@@ -2501,6 +2512,8 @@ const RunSimulationBlock = () => {
2501
2512
  remaining = Math.max(...Object.values(runningNoUIN));
2502
2513
  }
2503
2514
  const run = () => {
2515
+ // @ts-ignore
2516
+ window._isSimulationFromFile = false;
2504
2517
  navigate(`/simulation/${map.replaceAll(" ", "-")}/${playerBots.join("-")}`);
2505
2518
  };
2506
2519
  const startRunNoUI = () => {
@@ -2583,7 +2596,31 @@ const RunSimulationBlock = () => {
2583
2596
  remaining !== 0 && (React.createElement("p", { style: { textAlign: "center", marginTop: 10 } },
2584
2597
  "Remaining Simulations: ",
2585
2598
  remaining)),
2586
- React.createElement("p", { style: { textAlign: "center", display: "none", marginTop: 10 }, id: "noui-progress" })));
2599
+ React.createElement("p", { style: { textAlign: "center", display: "none", marginTop: 10 }, id: "noui-progress" }),
2600
+ React.createElement(dropzone.Dropzone, { mt: "xs", multiple: false, onDrop: (files) => __awaiter(void 0, void 0, void 0, function* () {
2601
+ if (files.length !== 1) {
2602
+ return;
2603
+ }
2604
+ const file = files[0];
2605
+ const text = yield file.text();
2606
+ // @ts-ignore
2607
+ window._navigate = navigate;
2608
+ // @ts-ignore
2609
+ window._isSimulationFromFile = true;
2610
+ tryUntilSuccess(() => {
2611
+ // @ts-ignore
2612
+ window._startSimulationFromFile(text);
2613
+ });
2614
+ }), style: {
2615
+ textAlign: "center",
2616
+ paddingTop: 20,
2617
+ paddingBottom: 20,
2618
+ paddingLeft: 10,
2619
+ paddingRight: 10,
2620
+ } },
2621
+ React.createElement("span", null,
2622
+ React.createElement("i", { className: "fa-solid fa-file-code", style: { marginRight: 10 } }),
2623
+ "Drag a simulation file here or click to select a file to run a simulation from a file"))));
2587
2624
  };
2588
2625
 
2589
2626
  const TournamentBlock = ({ pointModifier, inline, title }) => {
@@ -15902,12 +15939,17 @@ const Simulation = () => {
15902
15939
  let { map, playerapis } = reactRouterDom.useParams();
15903
15940
  const location = reactRouterDom.useLocation();
15904
15941
  const [winner, setWinner] = React.useState();
15942
+ const [downloadBytes, setDownloadBytes] = React.useState(false);
15905
15943
  const navigate = reactRouterDom.useNavigate();
15906
15944
  const colorScheme = hooks.useColorScheme();
15907
15945
  const showcaseMode = location.search.includes("showcase=true");
15908
15946
  React.useEffect(() => {
15909
15947
  n((engine) => __awaiter(void 0, void 0, void 0, function* () { return yield loadFull(engine); }));
15910
15948
  // @ts-ignore
15949
+ window.showDownload = () => {
15950
+ setDownloadBytes(true);
15951
+ };
15952
+ // @ts-ignore
15911
15953
  window.showWinner = (winner, verbose) => {
15912
15954
  if (admin) {
15913
15955
  setWinner(winner);
@@ -15937,8 +15979,12 @@ const Simulation = () => {
15937
15979
  const playerNames = (_a = playerapis === null || playerapis === void 0 ? void 0 : playerapis.split("-")) !== null && _a !== void 0 ? _a : [];
15938
15980
  const players = playerNames.map((api) => (api === "None" ? "" : apis[api]));
15939
15981
  React.useEffect(() => {
15940
- if (!loading && players && playerapis) {
15941
- tryUntilFailure(() =>
15982
+ if (!loading &&
15983
+ players &&
15984
+ playerapis &&
15985
+ // @ts-ignore
15986
+ window._isSimulationFromFile !== true) {
15987
+ tryUntilSuccess(() =>
15942
15988
  // @ts-ignore
15943
15989
  window._startSimulation(map, players, playerNames, false, !showcaseMode, true));
15944
15990
  }
@@ -16020,9 +16066,14 @@ const Simulation = () => {
16020
16066
  !location.search.includes("background=true") && (React.createElement(React.Fragment, null,
16021
16067
  React.createElement("div", { style: { textAlign: "center", flexGrow: 1 } },
16022
16068
  showcaseMode || (React.createElement(React.Fragment, null,
16069
+ React.createElement("span", { id: "render-status", style: { marginRight: 10 } }),
16023
16070
  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
16071
  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
- React.createElement(PlayPauseButton, null)),
16072
+ React.createElement(PlayPauseButton, null),
16073
+ downloadBytes && (React.createElement(core.Button, { ml: "xs", radius: "20px", leftSection: React.createElement("i", { className: "fa-solid fa-download" }), color: "blue", onClick: () => downloadFile(`${playerNames.join("-")}.btl`, "text/plain",
16074
+ // @ts-ignore
16075
+ window.simulationToDownload) }, "Download Simulation"))),
16076
+ showcaseMode && React.createElement("span", { id: "render-status" }),
16026
16077
  React.createElement("p", { style: { margin: 0 } }, "Playback Speed"),
16027
16078
  React.createElement(core.Slider, { style: { flex: "none" }, mb: 30, w: 500, maw: "85%", min: -2, defaultValue: 0, marks: [
16028
16079
  { value: -2, label: "1/4" },
@@ -2,6 +2,7 @@ import "@fortawesome/fontawesome-free/css/all.css";
2
2
  import "@mantine/core/styles.css";
3
3
  import "@mantine/dates/styles.css";
4
4
  import "@mantine/notifications/styles.css";
5
+ import "@mantine/dropzone/styles.css";
5
6
  import "prismjs/plugins/line-numbers/prism-line-numbers.css";
6
7
  import "prismjs/themes/prism.css";
7
8
  import "./index.css";
@@ -11,4 +11,5 @@ export declare const updatePointModifier: () => void;
11
11
  export declare const toPlacing: (n: number) => string;
12
12
  export declare const zeroPad: (s: string, l: number) => string;
13
13
  export declare const runNoUI: (map: string, apis: Record<string, any>, playerBots: string[], verbose: boolean) => void;
14
- export declare const tryUntilFailure: (f: () => void, timeout?: number) => void;
14
+ export declare const tryUntilSuccess: (f: () => void, timeout?: number) => void;
15
+ export declare const downloadFile: (filename: string, mimeType: string, contents: string) => void;
@@ -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,21 @@ 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
+ window._startSimulationFromFile = create_proxy(
18
+ battles._start_simulation_from_file
19
+ )
20
+ elif is_worker():
21
+ setattr(
22
+ sys.modules["__main__"],
23
+ "_run_webworker_simulation",
24
+ battles._run_webworker_simulation,
25
+ )
26
+ setattr(sys.modules["__main__"], "__export__", ["_run_webworker_simulation"])
16
27
  else:
17
28
  battles._run_local_simulation()
18
29
 
@@ -1,36 +1,81 @@
1
1
  import asyncio
2
+ import base64
3
+ from dataclasses import dataclass
4
+ import datetime
2
5
  import json
3
6
  import math
4
7
  import time
5
8
  import random
6
9
  import sys
7
10
  import traceback
11
+ import gzip
8
12
 
9
13
  from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar
10
14
  from code_battles.utilities import (
11
15
  GameCanvas,
12
16
  console_log,
13
17
  download_image,
18
+ navigate,
14
19
  set_results,
15
20
  show_alert,
21
+ show_download,
22
+ web_only,
23
+ is_web,
16
24
  )
17
- from js import Audio, Image, document, window, FontFace
18
- from pyscript.ffi import create_proxy
25
+
26
+ try:
27
+ import js
28
+ except Exception:
29
+ pass
19
30
 
20
31
  GameStateType = TypeVar("GameStateType")
21
32
  APIImplementationType = TypeVar("APIImplementationType")
22
33
  APIType = TypeVar("APIType")
23
34
  PlayerRequestsType = TypeVar("PlayerRequestsType")
24
35
 
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
36
 
33
- return wrapper
37
+ @dataclass
38
+ class Simulation:
39
+ map: str
40
+ player_names: str
41
+ game: str
42
+ version: str
43
+ timestamp: datetime.datetime
44
+ logs: list
45
+ decisions: List[bytes]
46
+
47
+ def dump(self):
48
+ return base64.b64encode(
49
+ gzip.compress(
50
+ json.dumps(
51
+ {
52
+ "map": self.map,
53
+ "playerNames": self.player_names,
54
+ "game": self.game,
55
+ "version": self.version,
56
+ "timestamp": self.timestamp.isoformat(),
57
+ "logs": self.logs,
58
+ "decisions": [
59
+ base64.b64encode(decision).decode()
60
+ for decision in self.decisions
61
+ ],
62
+ }
63
+ ).encode()
64
+ )
65
+ ).decode()
66
+
67
+ @staticmethod
68
+ def load(file: str):
69
+ contents = json.loads(gzip.decompress(base64.b64decode(file)))
70
+ return Simulation(
71
+ contents["map"],
72
+ contents["playerNames"],
73
+ contents["game"],
74
+ contents["version"],
75
+ datetime.datetime.fromisoformat(contents["timestamp"]),
76
+ contents["logs"],
77
+ [base64.b64decode(decision) for decision in contents["decisions"]],
78
+ )
34
79
 
35
80
 
36
81
  class CodeBattles(
@@ -56,7 +101,7 @@ class CodeBattles(
56
101
  """The name of the players. This is populated before any of the overridable methods run."""
57
102
  map: str
58
103
  """The name of the map. This is populated before any of the overridable methods run."""
59
- map_image: Image
104
+ map_image: "js.Image"
60
105
  """The map image. This is populated before any of the overridable methods run."""
61
106
  canvas: GameCanvas
62
107
  """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 +124,9 @@ class CodeBattles(
79
124
  _player_globals: List[Dict[str, Any]]
80
125
  _initialized: bool
81
126
  _eliminated: List[int]
82
- _sounds: Dict[str, Audio] = {}
127
+ _sounds: Dict[str, "js.Audio"] = {}
128
+ _decisions: List[bytes]
129
+ _since_last_render: int
83
130
 
84
131
  def render(self) -> None:
85
132
  """
@@ -191,12 +238,12 @@ class CodeBattles(
191
238
 
192
239
  return 1
193
240
 
194
- def configure_map_image_url(self, map: str):
241
+ def configure_map_image_url(self, map: str) -> str:
195
242
  """The URL containing the map image for the given map. By default, this takes the lowercase, replaces spaces with _ and loads from `/images/maps` which is stored in `public/images/maps` in a project."""
196
243
 
197
244
  return "/images/maps/" + map.lower().replace(" ", "_") + ".png"
198
245
 
199
- def configure_sound_url(self, name: str):
246
+ def configure_sound_url(self, name: str) -> str:
200
247
  """The URL containing the sound for the given name. By default, this takes the lowercase, replaces spaces with _ and loads from `/sounds` which is stored in `public/sounds` in a project."""
201
248
 
202
249
  return "/sounds/" + name.lower().replace(" ", "_") + ".mp3"
@@ -206,6 +253,14 @@ class CodeBattles(
206
253
 
207
254
  return "CodeBattlesBot"
208
255
 
256
+ def configure_render_rate(self, playback_speed: float) -> int:
257
+ """
258
+ The amount of frames to simulate before each render.
259
+
260
+ For games with an intensive `render` method, this is useful for higher playback speeds.
261
+ """
262
+ return 1
263
+
209
264
  def configure_bot_globals(self) -> Dict[str, Any]:
210
265
  """
211
266
  Configure additional available global items, such as libraries from the Python standard library, bots can use.
@@ -224,14 +279,19 @@ class CodeBattles(
224
279
  "random": random,
225
280
  }
226
281
 
282
+ def configure_version(self) -> str:
283
+ """Configure the version of the game, which is stored in the simulation files."""
284
+ return "1.0.0"
285
+
227
286
  @web_only
228
287
  def download_images(
229
288
  self, sources: List[Tuple[str, str]]
230
- ) -> asyncio.Future[Dict[str, Image]]:
289
+ ) -> asyncio.Future[Dict[str, "js.Image"]]:
231
290
  """
232
291
  :param sources: A list of ``(image_name, image_url)`` to download.
233
292
  :returns: A future which can be ``await``'d containing a dictionary mapping each ``image_name`` to its loaded image.
234
293
  """
294
+ from js import Image
235
295
 
236
296
  remaining_images: List[str] = []
237
297
  result = asyncio.Future()
@@ -268,6 +328,7 @@ class CodeBattles(
268
328
  @web_only
269
329
  async def load_font(self, name: str, url: str) -> None:
270
330
  """Loads the font from the specified url as the specified name."""
331
+ from js import FontFace, document
271
332
 
272
333
  ff = FontFace.new(name, f"url({url})")
273
334
  await ff.load()
@@ -334,7 +395,7 @@ class CodeBattles(
334
395
 
335
396
  For game-global log entries (not coming from a specific player), don't specify a ``player_index``.
336
397
  """
337
- if is_web:
398
+ if is_web():
338
399
  console_log(-1 if player_index is None else player_index, text, color)
339
400
  else:
340
401
  self._logs.append(
@@ -349,6 +410,7 @@ class CodeBattles(
349
410
  @web_only
350
411
  def play_sound(self, sound: str):
351
412
  """Plays the given sound, from the URL given by :func:`configure_sound_url`."""
413
+ from js import window, Audio
352
414
 
353
415
  if sound not in self._sounds:
354
416
  self._sounds[sound] = Audio.new(self.configure_sound_url(sound))
@@ -356,7 +418,16 @@ class CodeBattles(
356
418
  volume = window.localStorage.getItem("Volume") or 0
357
419
  s = self._sounds[sound].cloneNode(True)
358
420
  s.volume = volume
359
- s.play()
421
+
422
+ async def p():
423
+ try:
424
+ await s.play()
425
+ except Exception:
426
+ print(
427
+ f"Warning: couldn't play sound '{sound}'. Make sure the `sound` and `configure_sound_url` are correct."
428
+ )
429
+
430
+ asyncio.get_event_loop().run_until_complete(p())
360
431
 
361
432
  @property
362
433
  def time(self) -> str:
@@ -370,7 +441,11 @@ class CodeBattles(
370
441
 
371
442
  return len(self.active_players) <= 1
372
443
 
444
+ @web_only
373
445
  def _initialize(self):
446
+ from js import window, document
447
+ from pyscript.ffi import create_proxy
448
+
374
449
  window.addEventListener("resize", create_proxy(lambda _: self._resize_canvas()))
375
450
  document.getElementById("playpause").onclick = create_proxy(
376
451
  lambda _: asyncio.get_event_loop().run_until_complete(self._play_pause())
@@ -383,6 +458,9 @@ class CodeBattles(
383
458
 
384
459
  def _initialize_simulation(self, player_codes: List[str]):
385
460
  self.step = 0
461
+ self._logs = []
462
+ self._decisions = []
463
+ self._decision_index = 0
386
464
  self.active_players = list(range(len(self.player_names)))
387
465
  self.active_players = list(range(len(self.player_names)))
388
466
  self.state = self.create_initial_state()
@@ -392,6 +470,39 @@ class CodeBattles(
392
470
  ]
393
471
  self._eliminated = []
394
472
  self._player_globals = self._get_initial_player_globals(player_codes)
473
+ self._since_last_render = 1
474
+ self._start_time = time.time()
475
+
476
+ def _run_webworker_simulation(
477
+ self, map: str, player_names_str: str, player_codes_str: str
478
+ ):
479
+ from pyscript import sync
480
+
481
+ # JS to Python
482
+ player_names = json.loads(player_names_str)
483
+ player_codes = json.loads(player_codes_str)
484
+
485
+ self.map = map
486
+ self.player_names = player_names
487
+ self.background = True
488
+ self.console_visible = False
489
+ self.verbose = False
490
+ self._initialize_simulation(player_codes)
491
+ while not self.over:
492
+ self._logs = []
493
+ decisions = self.make_decisions()
494
+ logs = self._logs
495
+ self._logs = []
496
+ self.apply_decisions(decisions)
497
+
498
+ sync.update_step(
499
+ base64.b64encode(decisions).decode(),
500
+ json.dumps(logs),
501
+ "true" if self.over else "false",
502
+ )
503
+
504
+ if not self.over:
505
+ self.step += 1
395
506
 
396
507
  def _run_local_simulation(self):
397
508
  self.map = sys.argv[1]
@@ -399,7 +510,6 @@ class CodeBattles(
399
510
  self.background = True
400
511
  self.console_visible = False
401
512
  self.verbose = False
402
- self._logs = []
403
513
  player_codes = []
404
514
  for filename in sys.argv[3:]:
405
515
  with open(filename, "r") as f:
@@ -432,6 +542,72 @@ class CodeBattles(
432
542
  loop = asyncio.get_event_loop()
433
543
  loop.run_until_complete(self._start_simulation_async(*args, **kwargs))
434
544
 
545
+ def _start_simulation_from_file(self, contents: str):
546
+ loop = asyncio.get_event_loop()
547
+ loop.run_until_complete(self._start_simulation_from_file_async(contents))
548
+
549
+ async def _start_simulation_from_file_async(self, contents: str):
550
+ from js import document
551
+
552
+ try:
553
+ simulation = Simulation.load(str(contents))
554
+ navigate(
555
+ f"/simulation/{simulation.map}/{'-'.join(simulation.player_names)}"
556
+ )
557
+ show_alert(
558
+ "Loaded simulation file!",
559
+ f"{', '.join(simulation.player_names)} competed in {simulation.map} at {simulation.timestamp}",
560
+ "blue",
561
+ "fa-solid fa-file-code",
562
+ 0,
563
+ )
564
+ if simulation.game != self.__class__.__name__:
565
+ show_alert(
566
+ "Warning: game mismatch!",
567
+ f"Simulation file is for game {simulation.game} while the website is running {self.__class__.__name__}!",
568
+ "yellow",
569
+ "fa-solid fa-exclamation",
570
+ 0,
571
+ )
572
+ if simulation.version != self.configure_version():
573
+ show_alert(
574
+ "Warning: version mismatch!",
575
+ f"Simulation file is for version {simulation.version} while the website is running {self.configure_version()}!",
576
+ "yellow",
577
+ "fa-solid fa-exclamation",
578
+ 0,
579
+ )
580
+ while document.getElementById("loader") is None:
581
+ await asyncio.sleep(0.01)
582
+ if not hasattr(self, "_initialized"):
583
+ self._initialize()
584
+ self.map = simulation.map
585
+ self.map_image = await download_image(
586
+ self.configure_map_image_url(simulation.map)
587
+ )
588
+ self.player_names = simulation.player_names
589
+ self.background = False
590
+ self.console_visible = True
591
+ self.verbose = False
592
+ self._initialize_simulation(["" for _ in simulation.player_names])
593
+ self._decisions = simulation.decisions
594
+ self._logs = simulation.logs
595
+ self.canvas = GameCanvas(
596
+ document.getElementById("simulation"),
597
+ self.configure_board_count(),
598
+ self.map_image,
599
+ document.body.clientWidth - 440,
600
+ document.body.clientHeight - 280,
601
+ self.configure_extra_width(),
602
+ self.configure_extra_height(),
603
+ )
604
+ document.getElementById("loader").style.display = "none"
605
+ await self.setup()
606
+ self.render()
607
+ except Exception as e:
608
+ print(e)
609
+
610
+ @web_only
435
611
  async def _start_simulation_async(
436
612
  self,
437
613
  map: str,
@@ -441,7 +617,18 @@ class CodeBattles(
441
617
  console_visible: bool,
442
618
  verbose: bool,
443
619
  ):
620
+ from js import document
621
+ from pyscript import workers
622
+
623
+ # JS to Python
624
+ player_names = [str(x) for x in player_names]
625
+ player_codes = [str(x) for x in player_codes]
626
+
444
627
  try:
628
+ render_status = document.getElementById("render-status")
629
+ if render_status is not None:
630
+ render_status.textContent = "Rendering: Initializing..."
631
+
445
632
  self.map = map
446
633
  self.player_names = player_names
447
634
  self.map_image = await download_image(self.configure_map_image_url(map))
@@ -458,7 +645,9 @@ class CodeBattles(
458
645
  document.body.clientWidth - 440
459
646
  if console_visible
460
647
  else document.body.clientWidth - 40,
461
- document.body.clientHeight - 280,
648
+ document.body.clientHeight - 280
649
+ if console_visible
650
+ else document.body.clientHeight - 160,
462
651
  self.configure_extra_width(),
463
652
  self.configure_extra_height(),
464
653
  )
@@ -472,11 +661,52 @@ class CodeBattles(
472
661
  document.getElementById("loader").style.display = "none"
473
662
  self.render()
474
663
 
664
+ self._worker = await workers["worker"]
665
+ self._worker.update_step = self._update_step
666
+ self._worker._run_webworker_simulation(
667
+ map, json.dumps(player_names), json.dumps(player_codes)
668
+ )
669
+
475
670
  if self.background:
476
671
  await self._play_pause()
477
672
  except Exception:
478
673
  traceback.print_exc()
479
674
 
675
+ def _update_step(self, decisions_str: str, logs_str: str, is_over_str: str):
676
+ from js import window, document
677
+
678
+ now = time.time()
679
+ decisions = base64.b64decode(str(decisions_str))
680
+ logs: list = json.loads(str(logs_str))
681
+ is_over = str(is_over_str) == "true"
682
+
683
+ self._decisions.append(decisions)
684
+ self._logs.append(logs)
685
+
686
+ if is_over:
687
+ try:
688
+ simulation = Simulation(
689
+ self.map,
690
+ self.player_names,
691
+ self.__class__.__name__,
692
+ self.configure_version(),
693
+ datetime.datetime.now(),
694
+ self._logs,
695
+ self._decisions,
696
+ )
697
+ window.simulationToDownload = simulation.dump()
698
+ show_download()
699
+ except Exception as e:
700
+ print(e)
701
+
702
+ render_status = document.getElementById("render-status")
703
+ if render_status is not None:
704
+ render_status.textContent = (
705
+ f"Rendering: Complete! ({int(now - self._start_time)}s)"
706
+ if is_over
707
+ else f"Rendering: Frame {len(self._decisions)} ({int(now - self._start_time)}s)"
708
+ )
709
+
480
710
  def _get_initial_player_globals(self, player_codes: List[str]):
481
711
  contexts = [
482
712
  self.create_api_implementation(i) for i in range(len(self.player_names))
@@ -542,7 +772,10 @@ class CodeBattles(
542
772
 
543
773
  return player_globals
544
774
 
775
+ @web_only
545
776
  def _resize_canvas(self):
777
+ from js import document
778
+
546
779
  if not hasattr(self, "canvas"):
547
780
  return
548
781
 
@@ -550,14 +783,33 @@ class CodeBattles(
550
783
  document.body.clientWidth - 440
551
784
  if self.console_visible
552
785
  else document.body.clientWidth - 40,
553
- document.body.clientHeight - 280,
786
+ document.body.clientHeight - 280
787
+ if self.console_visible
788
+ else document.body.clientHeight - 160,
554
789
  )
555
790
  if not self.background:
556
791
  self.render()
557
792
 
793
+ @web_only
558
794
  def _step(self):
795
+ from js import document, setTimeout
796
+ from pyscript.ffi import create_proxy
797
+
559
798
  if not self.over:
560
- self.apply_decisions(self.make_decisions())
799
+ if len(self._decisions) == self._decision_index:
800
+ print("Warning: sleeping because decisions were not made yet!")
801
+ setTimeout(create_proxy(self._step), 100)
802
+ return
803
+ else:
804
+ logs = self._logs[self._decision_index]
805
+ for log in logs:
806
+ console_log(
807
+ -1 if log["player_index"] is None else log["player_index"],
808
+ log["text"],
809
+ log["color"],
810
+ )
811
+ self.apply_decisions(self._decisions[self._decision_index])
812
+ self._decision_index += 1
561
813
 
562
814
  if not self.over:
563
815
  self.step += 1
@@ -568,9 +820,17 @@ class CodeBattles(
568
820
  set_results(
569
821
  self.player_names, self._eliminated[::-1], self.map, self.verbose
570
822
  )
823
+ self.render()
571
824
 
572
825
  if not self.background:
573
- self.render()
826
+ if self._since_last_render >= self.configure_render_rate(
827
+ self._get_playback_speed()
828
+ ):
829
+ self.render()
830
+ self._since_last_render = 1
831
+ else:
832
+ self._since_last_render += 1
833
+
574
834
  if (
575
835
  self.over
576
836
  and "Pause" in document.getElementById("playpause").textContent
@@ -585,7 +845,10 @@ class CodeBattles(
585
845
  if self.over:
586
846
  document.getElementById("noui-progress").style.display = "none"
587
847
 
848
+ @web_only
588
849
  def _should_play(self):
850
+ from js import document
851
+
589
852
  if self.over:
590
853
  return False
591
854
 
@@ -600,7 +863,10 @@ class CodeBattles(
600
863
 
601
864
  return True
602
865
 
866
+ @web_only
603
867
  def _get_playback_speed(self):
868
+ from js import document
869
+
604
870
  return 2 ** float(
605
871
  document.getElementById("timescale")
606
872
  .getElementsByClassName("mantine-Slider-thumb")
@@ -608,7 +874,10 @@ class CodeBattles(
608
874
  .ariaValueNow
609
875
  )
610
876
 
877
+ @web_only
611
878
  def _get_breakpoint(self):
879
+ from js import document
880
+
612
881
  breakpoint_element = document.getElementById("breakpoint")
613
882
  if breakpoint_element is None or breakpoint_element.value == "":
614
883
  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
32
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}")
33
69
 
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,32 @@ 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
83
+ def show_download():
84
+ from js import window
85
+
86
+ if hasattr(window, "showDownload"):
87
+ try:
88
+ window.showDownload()
89
+ except Exception as e:
90
+ print(e)
91
+
92
+
93
+ @web_only
94
+ def navigate(route: str):
95
+ from js import window
96
+
97
+ if hasattr(window, "_navigate"):
98
+ try:
99
+ window._navigate(route)
100
+ except Exception as e:
101
+ print(e)
102
+
103
+
104
+ @web_only
42
105
  def download_json(filename: str, contents: str):
106
+ from js import window
107
+
43
108
  if hasattr(window, "downloadJson"):
44
109
  try:
45
110
  window.downloadJson(filename, contents)
@@ -47,7 +112,10 @@ def download_json(filename: str, contents: str):
47
112
  print(e)
48
113
 
49
114
 
115
+ @web_only
50
116
  def console_log(player_index: int, text: str, color: str):
117
+ from js import window
118
+
51
119
  if hasattr(window, "consoleLog"):
52
120
  try:
53
121
  window.consoleLog(player_index, text, color)
@@ -71,9 +139,9 @@ class GameCanvas:
71
139
 
72
140
  def __init__(
73
141
  self,
74
- canvas: Element,
142
+ canvas: "js.Element",
75
143
  player_count: int,
76
- map_image: Image,
144
+ map_image: "js.Image",
77
145
  max_width: int,
78
146
  max_height: int,
79
147
  extra_width: int,
@@ -89,7 +157,7 @@ class GameCanvas:
89
157
 
90
158
  def draw_element(
91
159
  self,
92
- image: Image,
160
+ image: "js.Image",
93
161
  x: int,
94
162
  y: int,
95
163
  width: int,
@@ -143,6 +211,26 @@ class GameCanvas:
143
211
  self.context.fillStyle = color
144
212
  self.context.fillText(text, x, y)
145
213
 
214
+ def draw_line(
215
+ self,
216
+ start_x: int,
217
+ start_y: int,
218
+ end_x: int,
219
+ end_y: int,
220
+ stroke="black",
221
+ stroke_width=10,
222
+ board_index=0,
223
+ ):
224
+ start_x, start_y = self._translate_position(board_index, start_x, start_y)
225
+ end_x, end_y = self._translate_position(board_index, end_x, end_y)
226
+
227
+ self.context.strokeStyle = stroke
228
+ self.context.lineWidth = stroke_width * self._scale
229
+ self.context.beginPath()
230
+ self.context.moveTo(start_x, start_y)
231
+ self.context.lineTo(end_x, end_y)
232
+ self.context.stroke()
233
+
146
234
  def draw_circle(
147
235
  self,
148
236
  x: int,
@@ -150,6 +238,7 @@ class GameCanvas:
150
238
  radius: float,
151
239
  fill="black",
152
240
  stroke="transparent",
241
+ stroke_width=2,
153
242
  board_index=0,
154
243
  ):
155
244
  """
@@ -157,8 +246,10 @@ class GameCanvas:
157
246
  """
158
247
 
159
248
  x, y = self._translate_position(board_index, x, y)
249
+
160
250
  self.context.fillStyle = fill
161
251
  self.context.strokeStyle = stroke
252
+ self.context.lineWidth = stroke_width * self._scale
162
253
  self.context.beginPath()
163
254
  self.context.arc(x, y, radius * self._scale, 0, 2 * math.pi)
164
255
  self.context.stroke()
@@ -187,6 +278,8 @@ class GameCanvas:
187
278
  return self.map_image.width * self.player_count
188
279
 
189
280
  def _fit_into(self, max_width: int, max_height: int):
281
+ from js import window
282
+
190
283
  if self.map_image.width == 0 or self.map_image.height == 0:
191
284
  raise Exception("Map image invalid!")
192
285
  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
@@ -1,6 +1,7 @@
1
1
  import '@mantine/core/styles.css';
2
2
  import '@mantine/dates/styles.css';
3
3
  import '@mantine/notifications/styles.css';
4
+ import '@mantine/dropzone/styles.css';
4
5
  import { TextInput, Button, Alert, Slider, Box, Select, NumberInput, Table, Badge, Autocomplete, Loader, MantineProvider } from '@mantine/core';
5
6
  import { TimeInput, DatesProvider } from '@mantine/dates';
6
7
  import { useColorScheme, useViewportSize } from '@mantine/hooks';
@@ -10,6 +11,7 @@ import { onAuthStateChanged, signInWithEmailAndPassword, signOut, getAuth } from
10
11
  import { onSnapshot, refEqual, doc, getDoc, setDoc, Timestamp, getFirestore } from 'firebase/firestore';
11
12
  import React, { useEffect, useReducer, useCallback, useMemo, createContext, useContext, useState, useRef, cloneElement, Component, createElement } from 'react';
12
13
  import { useNavigate, useLocation, useParams, Routes, Route, BrowserRouter } from 'react-router-dom';
14
+ import { Dropzone } from '@mantine/dropzone';
13
15
  import { jsx } from 'react/jsx-runtime';
14
16
 
15
17
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
@@ -409,19 +411,28 @@ const toPlacing = (n) => {
409
411
  };
410
412
  const runNoUI = (map, apis, playerBots, verbose) => {
411
413
  const players = playerBots.map((api) => (api === "None" ? "" : apis[api]));
412
- tryUntilFailure(() =>
414
+ tryUntilSuccess(() =>
413
415
  // @ts-ignore
414
416
  window._startSimulation(map, players, playerBots, true, false, verbose));
415
417
  };
416
- const tryUntilFailure = (f, timeout = 500) => {
418
+ const tryUntilSuccess = (f, timeout = 500) => {
417
419
  try {
418
420
  f();
419
421
  }
420
422
  catch (error) {
421
423
  console.log("Failed, waiting for timeout...", error === null || error === void 0 ? void 0 : error.message);
422
- setTimeout(() => tryUntilFailure(f, timeout), timeout);
424
+ setTimeout(() => tryUntilSuccess(f, timeout), timeout);
423
425
  }
424
426
  };
427
+ const downloadFile = (filename, mimeType, contents) => {
428
+ const a = document.createElement("a");
429
+ a.style.display = "none";
430
+ document.body.appendChild(a);
431
+ a.href = `data:${mimeType};charset=utf-8,${encodeURIComponent(contents)}`;
432
+ a.download = filename;
433
+ a.click();
434
+ document.body.removeChild(a);
435
+ };
425
436
 
426
437
  const useLocalStorage = ({ key, defaultValue, }) => {
427
438
  const [value, setValue] = useState(getLocalStorage(key, defaultValue !== null && defaultValue !== void 0 ? defaultValue : null));
@@ -2499,6 +2510,8 @@ const RunSimulationBlock = () => {
2499
2510
  remaining = Math.max(...Object.values(runningNoUIN));
2500
2511
  }
2501
2512
  const run = () => {
2513
+ // @ts-ignore
2514
+ window._isSimulationFromFile = false;
2502
2515
  navigate(`/simulation/${map.replaceAll(" ", "-")}/${playerBots.join("-")}`);
2503
2516
  };
2504
2517
  const startRunNoUI = () => {
@@ -2581,7 +2594,31 @@ const RunSimulationBlock = () => {
2581
2594
  remaining !== 0 && (React.createElement("p", { style: { textAlign: "center", marginTop: 10 } },
2582
2595
  "Remaining Simulations: ",
2583
2596
  remaining)),
2584
- React.createElement("p", { style: { textAlign: "center", display: "none", marginTop: 10 }, id: "noui-progress" })));
2597
+ React.createElement("p", { style: { textAlign: "center", display: "none", marginTop: 10 }, id: "noui-progress" }),
2598
+ React.createElement(Dropzone, { mt: "xs", multiple: false, onDrop: (files) => __awaiter(void 0, void 0, void 0, function* () {
2599
+ if (files.length !== 1) {
2600
+ return;
2601
+ }
2602
+ const file = files[0];
2603
+ const text = yield file.text();
2604
+ // @ts-ignore
2605
+ window._navigate = navigate;
2606
+ // @ts-ignore
2607
+ window._isSimulationFromFile = true;
2608
+ tryUntilSuccess(() => {
2609
+ // @ts-ignore
2610
+ window._startSimulationFromFile(text);
2611
+ });
2612
+ }), style: {
2613
+ textAlign: "center",
2614
+ paddingTop: 20,
2615
+ paddingBottom: 20,
2616
+ paddingLeft: 10,
2617
+ paddingRight: 10,
2618
+ } },
2619
+ React.createElement("span", null,
2620
+ React.createElement("i", { className: "fa-solid fa-file-code", style: { marginRight: 10 } }),
2621
+ "Drag a simulation file here or click to select a file to run a simulation from a file"))));
2585
2622
  };
2586
2623
 
2587
2624
  const TournamentBlock = ({ pointModifier, inline, title }) => {
@@ -15900,12 +15937,17 @@ const Simulation = () => {
15900
15937
  let { map, playerapis } = useParams();
15901
15938
  const location = useLocation();
15902
15939
  const [winner, setWinner] = useState();
15940
+ const [downloadBytes, setDownloadBytes] = useState(false);
15903
15941
  const navigate = useNavigate();
15904
15942
  const colorScheme = useColorScheme();
15905
15943
  const showcaseMode = location.search.includes("showcase=true");
15906
15944
  useEffect(() => {
15907
15945
  n((engine) => __awaiter(void 0, void 0, void 0, function* () { return yield loadFull(engine); }));
15908
15946
  // @ts-ignore
15947
+ window.showDownload = () => {
15948
+ setDownloadBytes(true);
15949
+ };
15950
+ // @ts-ignore
15909
15951
  window.showWinner = (winner, verbose) => {
15910
15952
  if (admin) {
15911
15953
  setWinner(winner);
@@ -15935,8 +15977,12 @@ const Simulation = () => {
15935
15977
  const playerNames = (_a = playerapis === null || playerapis === void 0 ? void 0 : playerapis.split("-")) !== null && _a !== void 0 ? _a : [];
15936
15978
  const players = playerNames.map((api) => (api === "None" ? "" : apis[api]));
15937
15979
  useEffect(() => {
15938
- if (!loading && players && playerapis) {
15939
- tryUntilFailure(() =>
15980
+ if (!loading &&
15981
+ players &&
15982
+ playerapis &&
15983
+ // @ts-ignore
15984
+ window._isSimulationFromFile !== true) {
15985
+ tryUntilSuccess(() =>
15940
15986
  // @ts-ignore
15941
15987
  window._startSimulation(map, players, playerNames, false, !showcaseMode, true));
15942
15988
  }
@@ -16018,9 +16064,14 @@ const Simulation = () => {
16018
16064
  !location.search.includes("background=true") && (React.createElement(React.Fragment, null,
16019
16065
  React.createElement("div", { style: { textAlign: "center", flexGrow: 1 } },
16020
16066
  showcaseMode || (React.createElement(React.Fragment, null,
16067
+ React.createElement("span", { id: "render-status", style: { marginRight: 10 } }),
16021
16068
  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
16069
  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
- React.createElement(PlayPauseButton, null)),
16070
+ React.createElement(PlayPauseButton, null),
16071
+ downloadBytes && (React.createElement(Button, { ml: "xs", radius: "20px", leftSection: React.createElement("i", { className: "fa-solid fa-download" }), color: "blue", onClick: () => downloadFile(`${playerNames.join("-")}.btl`, "text/plain",
16072
+ // @ts-ignore
16073
+ window.simulationToDownload) }, "Download Simulation"))),
16074
+ showcaseMode && React.createElement("span", { id: "render-status" }),
16024
16075
  React.createElement("p", { style: { margin: 0 } }, "Playback Speed"),
16025
16076
  React.createElement(Slider, { style: { flex: "none" }, mb: 30, w: 500, maw: "85%", min: -2, defaultValue: 0, marks: [
16026
16077
  { value: -2, label: "1/4" },
@@ -2,6 +2,7 @@ import "@fortawesome/fontawesome-free/css/all.css";
2
2
  import "@mantine/core/styles.css";
3
3
  import "@mantine/dates/styles.css";
4
4
  import "@mantine/notifications/styles.css";
5
+ import "@mantine/dropzone/styles.css";
5
6
  import "prismjs/plugins/line-numbers/prism-line-numbers.css";
6
7
  import "prismjs/themes/prism.css";
7
8
  import "./index.css";
@@ -11,4 +11,5 @@ export declare const updatePointModifier: () => void;
11
11
  export declare const toPlacing: (n: number) => string;
12
12
  export declare const zeroPad: (s: string, l: number) => string;
13
13
  export declare const runNoUI: (map: string, apis: Record<string, any>, playerBots: string[], verbose: boolean) => void;
14
- export declare const tryUntilFailure: (f: () => void, timeout?: number) => void;
14
+ export declare const tryUntilSuccess: (f: () => void, timeout?: number) => void;
15
+ export declare const downloadFile: (filename: string, mimeType: string, contents: string) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-battles",
3
- "version": "1.2.1",
3
+ "version": "1.4.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",
@@ -38,11 +38,12 @@
38
38
  "tsparticles": "^3.5.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@mantine/core": "^7.13.3",
42
- "@mantine/dates": "^7.13.3",
43
- "@mantine/hooks": "^7.13.3",
44
- "@mantine/notifications": "^7.13.3",
45
- "firebase": "^10.14.1",
41
+ "@mantine/core": "^7.13.4",
42
+ "@mantine/dates": "^7.13.4",
43
+ "@mantine/dropzone": "^7.13.4",
44
+ "@mantine/hooks": "^7.13.4",
45
+ "@mantine/notifications": "^7.13.4",
46
+ "firebase": "^11.0.1",
46
47
  "react": "^18.3.1",
47
48
  "react-dom": "^18.3.1",
48
49
  "react-router-dom": "^6.27.0"
@@ -52,16 +53,16 @@
52
53
  "@rollup/plugin-commonjs": "^26.0.3",
53
54
  "@rollup/plugin-node-resolve": "^15.3.0",
54
55
  "@rollup/plugin-typescript": "^11.1.6",
55
- "@types/prismjs": "^1.26.4",
56
- "@types/react": "^18.3.11",
56
+ "@types/prismjs": "^1.26.5",
57
+ "@types/react": "^18.3.12",
57
58
  "@types/react-dom": "^18.3.1",
58
59
  "chokidar-cli": "^3.0.0",
59
- "rollup": "^4.24.0",
60
+ "rollup": "^4.24.4",
60
61
  "rollup-plugin-copy": "^3.5.0",
61
62
  "rollup-plugin-dts": "^6.1.1",
62
- "rollup-plugin-import-css": "^3.5.5",
63
+ "rollup-plugin-import-css": "^3.5.6",
63
64
  "rollup-plugin-peer-deps-external": "^2.2.4",
64
- "tslib": "^2.8.0",
65
+ "tslib": "^2.8.1",
65
66
  "typescript": "^5.6.3"
66
67
  },
67
68
  "prettier": {