fletchtime 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. fletchtime/__init__.py +0 -0
  2. fletchtime/__main__.py +149 -0
  3. fletchtime/engine/__init__.py +16 -0
  4. fletchtime/engine/engine.py +270 -0
  5. fletchtime/engine/models.py +60 -0
  6. fletchtime/engine/modes/__init__.py +11 -0
  7. fletchtime/engine/modes/base.py +27 -0
  8. fletchtime/engine/modes/flint.py +262 -0
  9. fletchtime/engine/modes/indoor.py +165 -0
  10. fletchtime/engine/sequence.py +71 -0
  11. fletchtime/engine/turn_modes.py +14 -0
  12. fletchtime/server/__init__.py +0 -0
  13. fletchtime/server/config_store.py +205 -0
  14. fletchtime/server/http_static.py +59 -0
  15. fletchtime/server/match_server.py +403 -0
  16. fletchtime/server/ws_server.py +48 -0
  17. fletchtime/web/__init__.py +0 -0
  18. fletchtime/web/_defaults/banners/README.md +8 -0
  19. fletchtime/web/_defaults/club/README.md +14 -0
  20. fletchtime/web/_defaults/sounds/packs/README.md +48 -0
  21. fletchtime/web/_defaults/sounds/packs/classic/countdown_tick.wav +0 -0
  22. fletchtime/web/_defaults/sounds/packs/classic/emergency_end.wav +0 -0
  23. fletchtime/web/_defaults/sounds/packs/classic/emergency_start.wav +0 -0
  24. fletchtime/web/_defaults/sounds/packs/classic/end_of_match.wav +0 -0
  25. fletchtime/web/_defaults/sounds/packs/classic/end_of_volee.wav +0 -0
  26. fletchtime/web/_defaults/sounds/packs/classic/pause_end.wav +0 -0
  27. fletchtime/web/_defaults/sounds/packs/classic/pause_start.wav +0 -0
  28. fletchtime/web/_defaults/sounds/packs/classic/prep_start.wav +0 -0
  29. fletchtime/web/_defaults/sounds/packs/classic/shoot_start.wav +0 -0
  30. fletchtime/web/_defaults/sounds/packs/classic/warning_orange.wav +0 -0
  31. fletchtime/web/_defaults/targets/flint_20cm_4spot.jpg +0 -0
  32. fletchtime/web/_defaults/targets/flint_35cm_1spot.jpg +0 -0
  33. fletchtime/web/_defaults/targets/indoor_compound.jpg +0 -0
  34. fletchtime/web/_defaults/targets/indoor_recurve.jpg +0 -0
  35. fletchtime/web/config.html +503 -0
  36. fletchtime/web/control.html +671 -0
  37. fletchtime/web/display.html +408 -0
  38. fletchtime/web/i18n.js +407 -0
  39. fletchtime/web/index.html +211 -0
  40. fletchtime/web/logo.svg +57 -0
  41. fletchtime/web/manual.html +221 -0
  42. fletchtime/web/theme.js +37 -0
  43. fletchtime-0.1.0.dist-info/METADATA +153 -0
  44. fletchtime-0.1.0.dist-info/RECORD +48 -0
  45. fletchtime-0.1.0.dist-info/WHEEL +5 -0
  46. fletchtime-0.1.0.dist-info/entry_points.txt +2 -0
  47. fletchtime-0.1.0.dist-info/licenses/LICENSE +674 -0
  48. fletchtime-0.1.0.dist-info/top_level.txt +1 -0
fletchtime/__init__.py ADDED
File without changes
fletchtime/__main__.py ADDED
@@ -0,0 +1,149 @@
1
+ """Point d'entrée `python -m fletchtime` (ou la commande `fletchtime` si le
2
+ paquet est installé -- voir `[project.scripts]` dans pyproject.toml) : lance
3
+ le serveur de contrôle et d'affichage.
4
+
5
+ Deux notions de dossier bien distinctes ici, à ne pas confondre :
6
+
7
+ - Les **pages de l'application** (control.html, display.html, i18n.js,
8
+ logo.svg...) : toujours en lecture seule du point de vue du club, peu
9
+ importe comment FletchTime a été installé -- elles vivent dans le paquet
10
+ installé (``fletchtime.web``), ou à côté de l'exécutable pour un build
11
+ PyInstaller.
12
+ - Les **données propres à un club** (logo, bannières, images de cible,
13
+ packs de sons, réglages TOML) : doivent rester modifiables, donc jamais
14
+ dans le paquet installé lui-même. Vivent dans le répertoire courant
15
+ (comme la plupart des outils en ligne de commande -- jekyll, hugo...),
16
+ ou à côté de l'exécutable pour un build PyInstaller (pour rester
17
+ cohérent avec le comportement historique de cette distribution-là).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import shutil
24
+ import socket
25
+ import sys
26
+ import threading
27
+ from importlib import resources
28
+ from pathlib import Path
29
+
30
+ from fletchtime.server.http_static import start_http_server
31
+ from fletchtime.server.ws_server import run_ws_server
32
+
33
+ HTTP_PORT = 8000
34
+ WS_PORT = 8765
35
+
36
+ # Dossiers pré-remplis avec un contenu par défaut fourni avec FletchTime
37
+ # (copié une seule fois, au tout premier lancement -- jamais écrasé
38
+ # ensuite, donc une personnalisation du club est toujours préservée même
39
+ # après une mise à jour du paquet).
40
+ BOOTSTRAP_DEFAULT_DIRS = {
41
+ "targets": "_defaults/targets",
42
+ "sounds/packs/classic": "_defaults/sounds/packs/classic",
43
+ }
44
+
45
+ # Fichiers isolés copiés une seule fois (même logique que ci-dessus, mais
46
+ # pour un seul fichier plutôt qu'un dossier entier) -- des README qui
47
+ # expliquent la convention à suivre pour chaque type de donnée du club.
48
+ BOOTSTRAP_DEFAULT_FILES = {
49
+ "club/README.md": "_defaults/club/README.md",
50
+ "banners/README.md": "_defaults/banners/README.md",
51
+ "sounds/packs/README.md": "_defaults/sounds/packs/README.md",
52
+ }
53
+
54
+
55
+ def _app_web_dir() -> Path:
56
+ """Où vivent les pages de l'application elles-même."""
57
+ if getattr(sys, "frozen", False):
58
+ return Path(sys.executable).resolve().parent / "web"
59
+ return Path(str(resources.files("fletchtime.web")))
60
+
61
+
62
+ def _data_root() -> Path:
63
+ """Où vivent les données propres au club (voir docstring du module)."""
64
+ if getattr(sys, "frozen", False):
65
+ return Path(sys.executable).resolve().parent
66
+ return Path.cwd()
67
+
68
+
69
+ def ensure_directories(data_root: Path, app_web_dir: Path) -> None:
70
+ """Crée les dossiers attendus s'ils manquent, et copie le contenu par
71
+ défaut fourni avec FletchTime (README explicatifs, images de blasons,
72
+ pack de sons "classic") au tout premier lancement -- idempotent, sans
73
+ danger à chaque redémarrage, et ne touche jamais à un fichier/dossier
74
+ déjà existant (donc ne casse ni n'écrase jamais une personnalisation
75
+ faite par le club, y compris après une mise à jour)."""
76
+ for rel, default_rel in BOOTSTRAP_DEFAULT_DIRS.items():
77
+ directory = data_root / "web" / "assets" / rel
78
+ if directory.exists():
79
+ continue
80
+ source = app_web_dir / default_rel
81
+ if source.is_dir():
82
+ shutil.copytree(source, directory)
83
+ else:
84
+ # ancien build sans _defaults (ou chemin inattendu) -- pas de
85
+ # contenu par défaut à copier, mais ne doit jamais planter
86
+ # le démarrage pour autant.
87
+ directory.mkdir(parents=True, exist_ok=True)
88
+
89
+ for rel, default_rel in BOOTSTRAP_DEFAULT_FILES.items():
90
+ dest = data_root / "web" / "assets" / rel
91
+ if dest.exists():
92
+ continue
93
+ source = app_web_dir / default_rel
94
+ dest.parent.mkdir(parents=True, exist_ok=True)
95
+ if source.is_file():
96
+ shutil.copy2(source, dest)
97
+
98
+ (data_root / "config").mkdir(parents=True, exist_ok=True)
99
+
100
+
101
+ def local_ip() -> str:
102
+ """Best-effort local network IP (works even offline, since a UDP
103
+ 'connect' only consults the routing table, no packet is actually
104
+ sent). Falls back to 127.0.0.1 if there is no network route at all."""
105
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
106
+ try:
107
+ s.connect(("8.8.8.8", 80))
108
+ return s.getsockname()[0]
109
+ except OSError:
110
+ return "127.0.0.1"
111
+ finally:
112
+ s.close()
113
+
114
+
115
+ def main() -> None:
116
+ data_root = _data_root()
117
+ app_web_dir = _app_web_dir()
118
+ ensure_directories(data_root, app_web_dir)
119
+ assets_dir = data_root / "web" / "assets"
120
+
121
+ ip = local_ip()
122
+ print("=" * 60)
123
+ print(" FletchTime -- serveur de contrôle et d'affichage")
124
+ print("=" * 60)
125
+ print(f" Accueil : http://{ip}:{HTTP_PORT}/")
126
+ print(f" Contrôle : http://{ip}:{HTTP_PORT}/control.html")
127
+ print(f" Affichage : http://{ip}:{HTTP_PORT}/display.html?lane=1")
128
+ print()
129
+ print(" Depuis CE téléphone, remplace l'IP par 127.0.0.1 si besoin.")
130
+ print(" Depuis une tablette/PC sur le même WiFi, utilise l'IP ci-dessus.")
131
+ print(" (si l'IP semble fausse : vérifie-la dans les réglages WiFi du téléphone)")
132
+ print(f" Données du club (logo, sons, config...) : {data_root}")
133
+ print("=" * 60)
134
+
135
+ http_thread = threading.Thread(
136
+ target=start_http_server,
137
+ args=(str(app_web_dir), HTTP_PORT, str(assets_dir)),
138
+ daemon=True,
139
+ )
140
+ http_thread.start()
141
+
142
+ try:
143
+ asyncio.run(run_ws_server(WS_PORT))
144
+ except KeyboardInterrupt:
145
+ print("Arrêt du serveur.")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -0,0 +1,16 @@
1
+ from .engine import MatchEngine
2
+ from .models import MatchState, Phase
3
+ from .modes.flint import FlintConfig, FlintMode
4
+ from .modes.indoor import IndoorConfig, IndoorMode
5
+ from .sequence import Step
6
+
7
+ __all__ = [
8
+ "MatchEngine",
9
+ "MatchState",
10
+ "Phase",
11
+ "Step",
12
+ "IndoorMode",
13
+ "IndoorConfig",
14
+ "FlintMode",
15
+ "FlintConfig",
16
+ ]
@@ -0,0 +1,270 @@
1
+ """Plays back the ordered list of :class:`Step` produced by a
2
+ :class:`ShootingMode`, exposing the controls a DOS (Director Of Shooting)
3
+ needs: regular ticking, manual advance, stop, restart, jump to a specific
4
+ end, emergency stop/resume, temporary pause.
5
+
6
+ This is intentionally the only stateful class in the engine package -- modes
7
+ themselves stay pure/stateless so they are trivial to unit test in
8
+ isolation (see tests/test_indoor_mode.py, tests/test_flint_mode.py).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .models import MatchState, Phase
14
+ from .modes.base import ShootingMode
15
+ from .sequence import Step
16
+
17
+
18
+ class MatchEngine:
19
+ def __init__(self, mode: ShootingMode, countdown_tick_seconds: int = 5) -> None:
20
+ if countdown_tick_seconds < 0:
21
+ raise ValueError(f"countdown_tick_seconds must be >= 0, got {countdown_tick_seconds}")
22
+ self._countdown_tick_seconds = countdown_tick_seconds
23
+
24
+ self._steps: list[Step] = mode.build_sequence()
25
+ if not self._steps:
26
+ raise ValueError("A shooting mode must produce at least one step")
27
+
28
+ self._index = 0
29
+ self._time_left: float | None = self._steps[0].duration
30
+ self._finished = False
31
+ self._paused = False
32
+
33
+ self._emergency = False
34
+ self._emergency_saved_time: float | None = None
35
+
36
+ self._orange_event_fired = False
37
+ self._countdown_ticks_fired: set = set()
38
+
39
+ self._pending_events: list[str] = []
40
+ self._emit_current_step_event()
41
+
42
+ # -- controls --------------------------------------------------------
43
+
44
+ def tick(self, dt: float) -> MatchState:
45
+ """Advance the clock by ``dt`` seconds. Call regularly (e.g. 10Hz).
46
+
47
+ Safe to call with a ``dt`` larger than the remaining time on the
48
+ current step: the engine will cascade through as many steps as
49
+ needed (e.g. after a lag spike), matching the "catch up to real
50
+ time" behaviour described in the ArcheryClock manual. The cascade
51
+ stops as soon as it lands on an indefinite (PAUSE) step: those
52
+ never count down on their own, only :meth:`next` moves past them.
53
+ """
54
+ if self._emergency or self._paused or self._finished or self._time_left is None:
55
+ return self.current_state
56
+
57
+ self._time_left -= dt
58
+ while self._time_left is not None and self._time_left <= 0 and not self._finished:
59
+ overflow = -self._time_left
60
+ self._advance_step()
61
+ if self._finished or self._time_left is None:
62
+ break
63
+ self._time_left -= overflow
64
+ self._maybe_emit_orange_threshold_event()
65
+ self._maybe_emit_countdown_ticks()
66
+ return self.current_state
67
+
68
+ def next(self) -> MatchState:
69
+ """Manual advance, as if the DOS pressed the "next" button. This is
70
+ how a PAUSE step (end of a volée, arrows being retrieved) is left:
71
+ pressing next starts the following volée's preparation time."""
72
+ if not self._emergency:
73
+ self._advance_step()
74
+ return self.current_state
75
+
76
+ def stop(self) -> MatchState:
77
+ """Hard stop: end the current match right now, regardless of where
78
+ it is in the sequence. Distinct from :meth:`emergency` (which can
79
+ be resumed) -- this is a deliberate abandon/cancel by the DOS."""
80
+ self._finished = True
81
+ self._time_left = 0.0
82
+ self._pending_events.append("end_of_match")
83
+ return self.current_state
84
+
85
+ def restart(self) -> MatchState:
86
+ """Restart the whole match from its very first step, keeping the
87
+ same mode/config."""
88
+ self._index = 0
89
+ self._time_left = self._steps[0].duration
90
+ self._finished = False
91
+ self._paused = False
92
+ self._emergency = False
93
+ self._emergency_saved_time = None
94
+ self._orange_event_fired = False
95
+ self._countdown_ticks_fired = set()
96
+ self._pending_events = []
97
+ self._emit_current_step_event()
98
+ return self.current_state
99
+
100
+ def goto(
101
+ self, unit_number: int, end_number: int, arrow_in_end: int = 0, turn: str = ""
102
+ ) -> MatchState:
103
+ """Jump to a specific volée (and, for a walk-up end, optionally a
104
+ specific arrow). ``turn`` disambiguates when the same
105
+ (unit, end_number) occurs more than once -- e.g. Flint, where each
106
+ relay repeats the whole unit, so volée 3 exists once per relay.
107
+ Leave ``turn`` empty to match the first occurrence regardless of
108
+ relay. Lands on the PAUSE step immediately preceding it when one
109
+ exists -- more practical for the DOS: the screen already previews
110
+ the target end/distance, and the countdown only actually starts
111
+ once ``next()`` is pressed. Falls back to the shooting step itself
112
+ when there is no preceding pause (e.g. the very first step of the
113
+ match, or an individual walk-up arrow after the first). Raises
114
+ ``ValueError`` if no step matches."""
115
+ for index, step in enumerate(self._steps):
116
+ if step.phase == Phase.PAUSE:
117
+ continue
118
+ if step.unit_number != unit_number or step.end_number != end_number:
119
+ continue
120
+ if arrow_in_end and step.arrow_in_end != arrow_in_end:
121
+ continue
122
+ if turn and step.current_turn != turn:
123
+ continue
124
+
125
+ target_index = index
126
+ if index > 0 and self._steps[index - 1].phase == Phase.PAUSE:
127
+ target_index = index - 1
128
+
129
+ self._index = target_index
130
+ self._time_left = self._steps[target_index].duration
131
+ self._finished = False
132
+ self._paused = False
133
+ self._emergency = False
134
+ self._emergency_saved_time = None
135
+ self._pending_events = []
136
+ self._orange_event_fired = False
137
+ self._countdown_ticks_fired = set()
138
+ self._emit_current_step_event()
139
+ return self.current_state
140
+ raise ValueError(
141
+ f"No step found for unit={unit_number} end={end_number} "
142
+ f"arrow={arrow_in_end or '-'} turn={turn or '-'}"
143
+ )
144
+
145
+ def emergency(self) -> MatchState:
146
+ """Freeze the clock immediately. Position/time-left are remembered
147
+ so :meth:`resume` can pick up where it left off."""
148
+ if not self._emergency:
149
+ self._emergency = True
150
+ self._emergency_saved_time = self._time_left
151
+ self._pending_events.append("emergency_start")
152
+ return self.current_state
153
+
154
+ def resume(self, adjusted_time_left: float | None = None) -> MatchState:
155
+ """Recover from emergency. ``adjusted_time_left`` lets the DOS
156
+ compensate the archers for time lost, per FFTL rules on equipment
157
+ failure."""
158
+ if self._emergency:
159
+ self._emergency = False
160
+ self._time_left = (
161
+ adjusted_time_left if adjusted_time_left is not None else self._emergency_saved_time
162
+ )
163
+ self._emergency_saved_time = None
164
+ self._pending_events.append("emergency_end")
165
+ return self.current_state
166
+
167
+ def pause(self) -> MatchState:
168
+ if not self._paused:
169
+ self._paused = True
170
+ self._pending_events.append("pause_start")
171
+ return self.current_state
172
+
173
+ def play(self) -> MatchState:
174
+ if self._paused:
175
+ self._paused = False
176
+ self._pending_events.append("pause_end")
177
+ return self.current_state
178
+
179
+ def pop_pending_events(self) -> list[str]:
180
+ """Return and clear sound events accumulated since the last call.
181
+
182
+ Kept separate from ``current_state`` on purpose: an event (e.g.
183
+ "shoot_start") should be broadcast exactly once, at the transition,
184
+ not re-read on every tick.
185
+ """
186
+ events, self._pending_events = self._pending_events, []
187
+ return events
188
+
189
+ # -- internals ---------------------------------------------------------
190
+
191
+ def _advance_step(self) -> None:
192
+ if self._index + 1 >= len(self._steps):
193
+ self._finished = True
194
+ self._time_left = 0.0
195
+ self._pending_events.append("end_of_match")
196
+ else:
197
+ self._index += 1
198
+ self._time_left = self._steps[self._index].duration
199
+ self._orange_event_fired = False
200
+ self._countdown_ticks_fired = set()
201
+ if self._steps[self._index].phase == Phase.PAUSE:
202
+ self._pending_events.append("end_of_volee")
203
+ self._emit_current_step_event()
204
+
205
+ def _emit_current_step_event(self) -> None:
206
+ event = self._steps[self._index].sound_event
207
+ if event:
208
+ self._pending_events.append(event)
209
+
210
+ def _maybe_emit_orange_threshold_event(self) -> None:
211
+ """Check whether the current step just crossed its orange
212
+ threshold (e.g. 30s remaining out of a continuous 240s countdown)
213
+ and fire its sound event exactly once. Does not change ``_index``
214
+ or reset the countdown -- the displayed phase switch is computed
215
+ in :attr:`current_state`."""
216
+ if self._finished or self._orange_event_fired or self._time_left is None:
217
+ return
218
+ step = self._steps[self._index]
219
+ if step.orange_threshold is not None and self._time_left <= step.orange_threshold:
220
+ self._orange_event_fired = True
221
+ if step.orange_sound_event:
222
+ self._pending_events.append(step.orange_sound_event)
223
+
224
+ def _maybe_emit_countdown_ticks(self) -> None:
225
+ """Fire one "countdown_tick" event for each of the last
226
+ ``self._countdown_tick_seconds`` seconds of the current step's
227
+ countdown, once each -- e.g. a beep every second on the final
228
+ stretch of a volée or a walk-up arrow. Works for any timed step
229
+ (RED prep or GREEN shoot); frozen during emergency/pause since
230
+ :meth:`tick` doesn't run at all then."""
231
+ if self._finished or self._time_left is None or self._countdown_tick_seconds == 0:
232
+ return
233
+ for seconds in range(self._countdown_tick_seconds, 0, -1):
234
+ if seconds not in self._countdown_ticks_fired and self._time_left <= seconds:
235
+ self._countdown_ticks_fired.add(seconds)
236
+ self._pending_events.append("countdown_tick")
237
+
238
+ @property
239
+ def current_state(self) -> MatchState:
240
+ if self._finished:
241
+ return MatchState(phase=Phase.FINISHED, finished=True)
242
+
243
+ step = self._steps[self._index]
244
+ time_left = self._emergency_saved_time if self._emergency else self._time_left
245
+
246
+ if self._emergency:
247
+ phase = Phase.EMERGENCY
248
+ elif (
249
+ step.orange_threshold is not None
250
+ and time_left is not None
251
+ and time_left <= step.orange_threshold
252
+ ):
253
+ phase = Phase.ORANGE
254
+ else:
255
+ phase = step.phase
256
+
257
+ return MatchState(
258
+ phase=phase,
259
+ time_left=round(max(time_left, 0.0), 1) if time_left is not None else 0.0,
260
+ current_turn=step.current_turn,
261
+ end_number=step.end_number,
262
+ total_ends=step.total_ends,
263
+ unit_number=step.unit_number,
264
+ arrow_in_end=step.arrow_in_end,
265
+ total_arrows_in_end=step.total_arrows_in_end,
266
+ distance_label=step.distance_label,
267
+ target_image=step.target_image,
268
+ target_image_2=step.target_image_2,
269
+ finished=False,
270
+ )
@@ -0,0 +1,60 @@
1
+ """Core data model shared by every shooting mode and by the match engine.
2
+
3
+ Deliberately dependency-free (stdlib only) so this package can be tested and
4
+ reused without pulling in FastAPI/websockets: the engine only produces
5
+ ``MatchState`` snapshots, it says nothing about how they are transported.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+
13
+
14
+ class Phase(StrEnum):
15
+ """Visual/safety phase of the current step.
16
+
17
+ WAIT -- before the match starts, or between matches. No countdown.
18
+ RED -- preparation time (archers approach / take position).
19
+ GREEN -- main shooting time.
20
+ ORANGE -- warning period near the end of shooting time.
21
+ PAUSE -- end of a volée: archers retrieve arrows, no countdown.
22
+ The engine waits here indefinitely until the DOS manually
23
+ starts the next volée (``MatchEngine.next()``).
24
+ EMERGENCY -- danger signal, clock frozen, must be explicitly resumed.
25
+ FINISHED -- sequence exhausted, nothing left to shoot.
26
+ """
27
+
28
+ WAIT = "wait"
29
+ RED = "red"
30
+ GREEN = "green"
31
+ ORANGE = "orange"
32
+ PAUSE = "pause"
33
+ EMERGENCY = "emergency"
34
+ FINISHED = "finished"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class MatchState:
39
+ """Immutable snapshot of where the match currently stands.
40
+
41
+ This is the object a transport layer (FastAPI/WebSocket) would serialize
42
+ and push to display screens -- see docs/architecture.md.
43
+ """
44
+
45
+ phase: Phase = Phase.WAIT
46
+ time_left: float = 0.0
47
+
48
+ # sequencing / display context
49
+ current_turn: str = "" # e.g. "A-B", "C-D", "" if not applicable
50
+ end_number: int = 0 # 1-indexed end/volée within the unit
51
+ total_ends: int = 0 # total ends in the unit (incl. walk-up end)
52
+ unit_number: int = 1 # for Flint: which "unité standard" (1..n)
53
+ arrow_in_end: int = 0 # for walk-up ends: which arrow (1-indexed)
54
+ total_arrows_in_end: int = 0 # for walk-up ends: arrows in this end
55
+
56
+ distance_label: str = ""
57
+ target_image: str = ""
58
+ target_image_2: str = ""
59
+
60
+ finished: bool = False
@@ -0,0 +1,11 @@
1
+ from .base import ShootingMode
2
+ from .flint import FlintConfig, FlintMode
3
+ from .indoor import IndoorConfig, IndoorMode
4
+
5
+ __all__ = [
6
+ "ShootingMode",
7
+ "IndoorMode",
8
+ "IndoorConfig",
9
+ "FlintMode",
10
+ "FlintConfig",
11
+ ]
@@ -0,0 +1,27 @@
1
+ """Common interface for shooting modes.
2
+
3
+ Unlike the legacy ArcheryClock approach (a single 12k-line file dispatching
4
+ on a string like ``archerysystem = 'fita'`` in dozens of scattered ``if``
5
+ branches), each mode here is a self-contained class that only knows how to
6
+ build its own sequence of steps. Adding a new mode never touches existing
7
+ ones -- see docs/dev-guide for the walkthrough.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+
14
+ from ..sequence import Step
15
+
16
+
17
+ class ShootingMode(ABC):
18
+ """Builds the full ordered list of :class:`Step` for one competition
19
+ round. Modes are stateless sequence generators; all runtime state
20
+ (current position, elapsed time, pause/emergency) lives in
21
+ :class:`fletchtime.engine.engine.MatchEngine`.
22
+ """
23
+
24
+ @abstractmethod
25
+ def build_sequence(self) -> list[Step]:
26
+ """Return the ordered, non-empty list of steps for this round."""
27
+ raise NotImplementedError