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.
- fletchtime/__init__.py +0 -0
- fletchtime/__main__.py +149 -0
- fletchtime/engine/__init__.py +16 -0
- fletchtime/engine/engine.py +270 -0
- fletchtime/engine/models.py +60 -0
- fletchtime/engine/modes/__init__.py +11 -0
- fletchtime/engine/modes/base.py +27 -0
- fletchtime/engine/modes/flint.py +262 -0
- fletchtime/engine/modes/indoor.py +165 -0
- fletchtime/engine/sequence.py +71 -0
- fletchtime/engine/turn_modes.py +14 -0
- fletchtime/server/__init__.py +0 -0
- fletchtime/server/config_store.py +205 -0
- fletchtime/server/http_static.py +59 -0
- fletchtime/server/match_server.py +403 -0
- fletchtime/server/ws_server.py +48 -0
- fletchtime/web/__init__.py +0 -0
- fletchtime/web/_defaults/banners/README.md +8 -0
- fletchtime/web/_defaults/club/README.md +14 -0
- fletchtime/web/_defaults/sounds/packs/README.md +48 -0
- fletchtime/web/_defaults/sounds/packs/classic/countdown_tick.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/emergency_end.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/emergency_start.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/end_of_match.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/end_of_volee.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/pause_end.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/pause_start.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/prep_start.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/shoot_start.wav +0 -0
- fletchtime/web/_defaults/sounds/packs/classic/warning_orange.wav +0 -0
- fletchtime/web/_defaults/targets/flint_20cm_4spot.jpg +0 -0
- fletchtime/web/_defaults/targets/flint_35cm_1spot.jpg +0 -0
- fletchtime/web/_defaults/targets/indoor_compound.jpg +0 -0
- fletchtime/web/_defaults/targets/indoor_recurve.jpg +0 -0
- fletchtime/web/config.html +503 -0
- fletchtime/web/control.html +671 -0
- fletchtime/web/display.html +408 -0
- fletchtime/web/i18n.js +407 -0
- fletchtime/web/index.html +211 -0
- fletchtime/web/logo.svg +57 -0
- fletchtime/web/manual.html +221 -0
- fletchtime/web/theme.js +37 -0
- fletchtime-0.1.0.dist-info/METADATA +153 -0
- fletchtime-0.1.0.dist-info/RECORD +48 -0
- fletchtime-0.1.0.dist-info/WHEEL +5 -0
- fletchtime-0.1.0.dist-info/entry_points.txt +2 -0
- fletchtime-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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,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
|