code-battles 1.3.0 → 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
  }
@@ -16023,7 +16069,11 @@ const Simulation = () => {
16023
16069
  React.createElement("span", { id: "render-status", style: { marginRight: 10 } }),
16024
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" }),
16025
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"))),
16026
- 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" }),
16027
16077
  React.createElement("p", { style: { margin: 0 } }, "Playback Speed"),
16028
16078
  React.createElement(core.Slider, { style: { flex: "none" }, mb: 30, w: 500, maw: "85%", min: -2, defaultValue: 0, marks: [
16029
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;
@@ -14,6 +14,9 @@ def run_game(battles: CodeBattles):
14
14
  from pyscript.ffi import create_proxy
15
15
 
16
16
  window._startSimulation = create_proxy(battles._start_simulation)
17
+ window._startSimulationFromFile = create_proxy(
18
+ battles._start_simulation_from_file
19
+ )
17
20
  elif is_worker():
18
21
  setattr(
19
22
  sys.modules["__main__"],
@@ -1,19 +1,24 @@
1
1
  import asyncio
2
2
  import base64
3
+ from dataclasses import dataclass
4
+ import datetime
3
5
  import json
4
6
  import math
5
7
  import time
6
8
  import random
7
9
  import sys
8
10
  import traceback
11
+ import gzip
9
12
 
10
13
  from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar
11
14
  from code_battles.utilities import (
12
15
  GameCanvas,
13
16
  console_log,
14
17
  download_image,
18
+ navigate,
15
19
  set_results,
16
20
  show_alert,
21
+ show_download,
17
22
  web_only,
18
23
  is_web,
19
24
  )
@@ -29,6 +34,50 @@ APIType = TypeVar("APIType")
29
34
  PlayerRequestsType = TypeVar("PlayerRequestsType")
30
35
 
31
36
 
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
+ )
79
+
80
+
32
81
  class CodeBattles(
33
82
  Generic[GameStateType, APIImplementationType, APIType, PlayerRequestsType]
34
83
  ):
@@ -77,6 +126,7 @@ class CodeBattles(
77
126
  _eliminated: List[int]
78
127
  _sounds: Dict[str, "js.Audio"] = {}
79
128
  _decisions: List[bytes]
129
+ _since_last_render: int
80
130
 
81
131
  def render(self) -> None:
82
132
  """
@@ -188,12 +238,12 @@ class CodeBattles(
188
238
 
189
239
  return 1
190
240
 
191
- def configure_map_image_url(self, map: str):
241
+ def configure_map_image_url(self, map: str) -> str:
192
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."""
193
243
 
194
244
  return "/images/maps/" + map.lower().replace(" ", "_") + ".png"
195
245
 
196
- def configure_sound_url(self, name: str):
246
+ def configure_sound_url(self, name: str) -> str:
197
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."""
198
248
 
199
249
  return "/sounds/" + name.lower().replace(" ", "_") + ".mp3"
@@ -203,6 +253,14 @@ class CodeBattles(
203
253
 
204
254
  return "CodeBattlesBot"
205
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
+
206
264
  def configure_bot_globals(self) -> Dict[str, Any]:
207
265
  """
208
266
  Configure additional available global items, such as libraries from the Python standard library, bots can use.
@@ -221,6 +279,10 @@ class CodeBattles(
221
279
  "random": random,
222
280
  }
223
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
+
224
286
  @web_only
225
287
  def download_images(
226
288
  self, sources: List[Tuple[str, str]]
@@ -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:
@@ -389,6 +460,7 @@ class CodeBattles(
389
460
  self.step = 0
390
461
  self._logs = []
391
462
  self._decisions = []
463
+ self._decision_index = 0
392
464
  self.active_players = list(range(len(self.player_names)))
393
465
  self.active_players = list(range(len(self.player_names)))
394
466
  self.state = self.create_initial_state()
@@ -398,7 +470,8 @@ class CodeBattles(
398
470
  ]
399
471
  self._eliminated = []
400
472
  self._player_globals = self._get_initial_player_globals(player_codes)
401
- self._webworker_frame = 0
473
+ self._since_last_render = 1
474
+ self._start_time = time.time()
402
475
 
403
476
  def _run_webworker_simulation(
404
477
  self, map: str, player_names_str: str, player_codes_str: str
@@ -469,6 +542,71 @@ class CodeBattles(
469
542
  loop = asyncio.get_event_loop()
470
543
  loop.run_until_complete(self._start_simulation_async(*args, **kwargs))
471
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
+
472
610
  @web_only
473
611
  async def _start_simulation_async(
474
612
  self,
@@ -507,7 +645,9 @@ class CodeBattles(
507
645
  document.body.clientWidth - 440
508
646
  if console_visible
509
647
  else document.body.clientWidth - 40,
510
- document.body.clientHeight - 280,
648
+ document.body.clientHeight - 280
649
+ if console_visible
650
+ else document.body.clientHeight - 160,
511
651
  self.configure_extra_width(),
512
652
  self.configure_extra_height(),
513
653
  )
@@ -533,22 +673,38 @@ class CodeBattles(
533
673
  traceback.print_exc()
534
674
 
535
675
  def _update_step(self, decisions_str: str, logs_str: str, is_over_str: str):
536
- from js import document
676
+ from js import window, document
537
677
 
678
+ now = time.time()
538
679
  decisions = base64.b64decode(str(decisions_str))
539
680
  logs: list = json.loads(str(logs_str))
540
681
  is_over = str(is_over_str) == "true"
541
682
 
542
683
  self._decisions.append(decisions)
543
684
  self._logs.append(logs)
544
- self._webworker_frame += 1
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)
545
701
 
546
702
  render_status = document.getElementById("render-status")
547
703
  if render_status is not None:
548
704
  render_status.textContent = (
549
- "Rendering: Complete!"
705
+ f"Rendering: Complete! ({int(now - self._start_time)}s)"
550
706
  if is_over
551
- else f"Rendering: Frame {self._webworker_frame}"
707
+ else f"Rendering: Frame {len(self._decisions)} ({int(now - self._start_time)}s)"
552
708
  )
553
709
 
554
710
  def _get_initial_player_globals(self, player_codes: List[str]):
@@ -627,7 +783,9 @@ class CodeBattles(
627
783
  document.body.clientWidth - 440
628
784
  if self.console_visible
629
785
  else document.body.clientWidth - 40,
630
- document.body.clientHeight - 280,
786
+ document.body.clientHeight - 280
787
+ if self.console_visible
788
+ else document.body.clientHeight - 160,
631
789
  )
632
790
  if not self.background:
633
791
  self.render()
@@ -638,19 +796,20 @@ class CodeBattles(
638
796
  from pyscript.ffi import create_proxy
639
797
 
640
798
  if not self.over:
641
- if len(self._decisions) == 0:
799
+ if len(self._decisions) == self._decision_index:
642
800
  print("Warning: sleeping because decisions were not made yet!")
643
801
  setTimeout(create_proxy(self._step), 100)
644
802
  return
645
803
  else:
646
- logs = self._logs.pop(0)
804
+ logs = self._logs[self._decision_index]
647
805
  for log in logs:
648
806
  console_log(
649
807
  -1 if log["player_index"] is None else log["player_index"],
650
808
  log["text"],
651
809
  log["color"],
652
810
  )
653
- self.apply_decisions(self._decisions.pop(0))
811
+ self.apply_decisions(self._decisions[self._decision_index])
812
+ self._decision_index += 1
654
813
 
655
814
  if not self.over:
656
815
  self.step += 1
@@ -661,9 +820,17 @@ class CodeBattles(
661
820
  set_results(
662
821
  self.player_names, self._eliminated[::-1], self.map, self.verbose
663
822
  )
823
+ self.render()
664
824
 
665
825
  if not self.background:
666
- 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
+
667
834
  if (
668
835
  self.over
669
836
  and "Pause" in document.getElementById("playpause").textContent
@@ -79,6 +79,28 @@ def set_results(player_names: List[str], places: List[int], map: str, verbose: b
79
79
  print(e)
80
80
 
81
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
+
82
104
  @web_only
83
105
  def download_json(filename: str, contents: str):
84
106
  from js import window
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
  }
@@ -16021,7 +16067,11 @@ const Simulation = () => {
16021
16067
  React.createElement("span", { id: "render-status", style: { marginRight: 10 } }),
16022
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" }),
16023
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"))),
16024
- 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" }),
16025
16075
  React.createElement("p", { style: { margin: 0 } }, "Playback Speed"),
16026
16076
  React.createElement(Slider, { style: { flex: "none" }, mb: 30, w: 500, maw: "85%", min: -2, defaultValue: 0, marks: [
16027
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.3.0",
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"
@@ -56,7 +57,7 @@
56
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.3",
60
+ "rollup": "^4.24.4",
60
61
  "rollup-plugin-copy": "^3.5.0",
61
62
  "rollup-plugin-dts": "^6.1.1",
62
63
  "rollup-plugin-import-css": "^3.5.6",