diff --git a/CHANGELOG.md b/CHANGELOG.md index 27941f0..fbb5215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,50 @@ All notable changes to this project will be documented in this file. ### Added +- **Bedtime mode** (`api/bedtime.py`) — see [docs/BEDTIME.md](docs/BEDTIME.md) + + - A scheduled, warned end to the evening: a bossbar countdown, titles at the + configured marks, a goodnight message, then save and stop, remove everyone, + or just announce. + - Bedtime is a window rather than a moment. Between bedtime and the wake time, + anyone who joins is sent back out with a message saying when the server + opens again. Stopping the server is not enough on its own, because a restart + policy or an update timer reopens the evening. + - Separate weeknight and weekend bedtimes, chosen by the evening rather than + the day, so Friday and Saturday nights get the later one. The window spans + midnight correctly. + - New page at `/bedtime` with the countdown and three controls: extend by a + configured amount, skip tonight, or start bedtime now. A refused control + returns `409` with the reason, since the request was well-formed. + - New endpoints `GET /api/bedtime` (`server.view`) and `POST /api/bedtime/extend`, + `/skip` and `/now` (`server.control`), with matching OpenAPI paths and schemas. + - Configured through `config/bedtime.conf`; see `config/bedtime.conf.example`. + Disabled unless the config says otherwise. + - Enforcement is idempotent. The bedtime thread and an API request can both + reach it, so closing the evening twice would mean two goodnights, two kicks + and two attempts to stop the server. + +- **`systemd/minecraft-scheduler.{service,timer}`** — nothing executed the + scheduled commands the web UI creates. The Scheduler page wrote entries to + `config/command-schedule.json` and `scripts/command-scheduler.py run` was never + invoked by any timer, cron entry or loop, so every schedule was stored and + silently ignored. The timer runs it once a minute. + +- **`scripts/auto-update.sh`** — pulls and restarts only when the image actually + changed, and leaves a stopped server stopped. + +### Fixed + +- **The hourly update timer restarted the server every hour regardless of + whether a new image existed.** `systemd/minecraft-update.service` ran + `docker compose up -d --force-recreate` unconditionally, which recreates + containers even when nothing has changed, so everyone online was kicked on the + hour. It also restarted servers that had been stopped deliberately, which would + have reopened the server after bedtime closed it. It now calls + `scripts/auto-update.sh run`. + +### Added + - **Hall of Deaths** (`api/hall_of_deaths.py`, `api/epitaphs.py`) — see [docs/HALL_OF_DEATHS.md](docs/HALL_OF_DEATHS.md) diff --git a/api/bedtime.py b/api/bedtime.py new file mode 100644 index 0000000..429a042 --- /dev/null +++ b/api/bedtime.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +"""Bedtime mode: a scheduled, warned, enforceable end to the evening. + +A shutdown that arrives without warning starts an argument. This gives a +countdown instead: a bossbar that fills up, titles at the agreed marks, and a +goodnight message. The end of the evening becomes something the server +announced rather than something a parent did. + +It also holds. Stopping the server is not enough on its own, because anything +that brings the container back, a restart policy or an update timer, reopens +the evening. So bedtime defines a closed window rather than a single moment: +between bedtime and the wake time, anyone who joins is sent straight back out +with a message saying when the server opens again. + +All the scheduling logic takes the current time as an argument so it can be +tested without waiting for the evening. The thread in :meth:`Bedtime.start` +supplies the real clock. +""" + +from __future__ import annotations + +import queue +import threading +import time as time_module +from dataclasses import dataclass, field +from datetime import date, datetime, time, timedelta +from pathlib import Path +from typing import Callable, Optional + +PROJECT_ROOT = Path(__file__).parent.parent +BEDTIME_CONFIG_FILE = PROJECT_ROOT / "config" / "bedtime.conf" + +# What happens when the countdown reaches zero. +ACTION_STOP = "stop" +ACTION_KICK = "kick" +ACTION_ANNOUNCE = "announce" +VALID_ACTIONS = (ACTION_STOP, ACTION_KICK, ACTION_ANNOUNCE) + +WEEKDAY_NAMES = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday") + +DEFAULT_WEEKNIGHT = time(20, 30) +DEFAULT_WEEKEND = time(21, 30) +DEFAULT_WAKE = time(7, 0) +# Friday and Saturday nights are the late ones: it is the night before a +# non-school morning that matters, not the day itself. +DEFAULT_WEEKEND_NIGHTS = ("friday", "saturday") +DEFAULT_WARN_MINUTES = (30, 15, 10, 5, 1) +DEFAULT_EXTEND_MINUTES = 15 +DEFAULT_MAX_EXTENSIONS = 1 + +BOSSBAR_ID = "minecraft:bedtime" +# How often the thread re-evaluates. Fine enough for a smooth bossbar without +# putting a meaningful load on a Pi. +TICK_SECONDS = 5.0 + + +def parse_clock(value: str, fallback: time) -> time: + """Parse ``HH:MM`` into a :class:`~datetime.time`, falling back on nonsense.""" + try: + hour, minute = value.strip().split(":", 1) + return time(int(hour), int(minute)) + except (AttributeError, ValueError): + return fallback + + +@dataclass +class BedtimeConfig: + """When bedtime is, how it is announced, and what it does.""" + + enabled: bool = False + weeknight: time = DEFAULT_WEEKNIGHT + weekend: time = DEFAULT_WEEKEND + wake: time = DEFAULT_WAKE + weekend_nights: tuple[str, ...] = DEFAULT_WEEKEND_NIGHTS + warn_minutes: tuple[int, ...] = DEFAULT_WARN_MINUTES + extend_minutes: int = DEFAULT_EXTEND_MINUTES + max_extensions: int = DEFAULT_MAX_EXTENSIONS + action: str = ACTION_STOP + bossbar: bool = True + + def bedtime_for(self, day: date) -> time: + """The bedtime that applies to the evening of ``day``.""" + return self.weekend if WEEKDAY_NAMES[day.weekday()] in self.weekend_nights else self.weeknight + + +def load_bedtime_config(config_file: Optional[Path] = None) -> BedtimeConfig: + """Read ``config/bedtime.conf``. + + Bedtime is **disabled unless the file says otherwise**. A feature that can + stop the server and turn people away should never switch itself on because + a default said so. + """ + path = config_file if config_file is not None else BEDTIME_CONFIG_FILE + config = BedtimeConfig() + + if not path.exists(): + return config + + try: + content = path.read_text(encoding="utf-8") + except OSError: + return config + + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("\"'") + + if key == "ENABLED": + config.enabled = value.lower() in ("1", "true", "yes", "on") + elif key == "WEEKNIGHT_BEDTIME": + config.weeknight = parse_clock(value, DEFAULT_WEEKNIGHT) + elif key == "WEEKEND_BEDTIME": + config.weekend = parse_clock(value, DEFAULT_WEEKEND) + elif key == "WAKE_TIME": + config.wake = parse_clock(value, DEFAULT_WAKE) + elif key == "WEEKEND_NIGHTS": + nights = tuple(n.strip().lower() for n in value.split(",") if n.strip().lower() in WEEKDAY_NAMES) + config.weekend_nights = nights + elif key == "WARN_MINUTES": + minutes = [] + for part in value.split(","): + try: + minutes.append(int(part.strip())) + except ValueError: + continue + if minutes: + config.warn_minutes = tuple(sorted({m for m in minutes if m > 0}, reverse=True)) + elif key == "EXTEND_MINUTES": + try: + config.extend_minutes = max(0, int(value)) + except ValueError: + # A typo here must not stop the server starting. The default + # stands and bedtime still works, which beats refusing to boot. + pass + elif key == "MAX_EXTENSIONS": + try: + config.max_extensions = max(0, int(value)) + except ValueError: + # As above: keep the default rather than fail on a bad value. + pass + elif key == "ACTION" and value.lower() in VALID_ACTIONS: + config.action = value.lower() + elif key == "BOSSBAR": + config.bossbar = value.lower() in ("1", "true", "yes", "on") + + return config + + +@dataclass +class _NightState: + """What has already happened tonight. + + Keyed by the date of the evening, so everything resets by itself when the + next one comes round rather than needing to be cleared. + """ + + night: Optional[date] = None + extensions: int = 0 + extra_minutes: int = 0 + skipped: bool = False + warned: set = field(default_factory=set) + enforced: bool = False + + def reset_to(self, night: date) -> None: + self.night = night + self.extensions = 0 + self.extra_minutes = 0 + self.skipped = False + self.warned = set() + self.enforced = False + + +class Bedtime: + """Runs the countdown and enforces the closed window.""" + + def __init__( + self, + config: Optional[BedtimeConfig] = None, + runner: Optional[Callable[[str], None]] = None, + stopper: Optional[Callable[[], None]] = None, + ) -> None: + self.config = config or BedtimeConfig() + # Injected rather than imported so the whole class is testable without + # a server, RCON or Docker. + self.runner = runner + self.stopper = stopper + + self._state = _NightState() + self._lock = threading.RLock() + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._error_logger: Optional[Callable[[str], None]] = None + self._bossbar_shown = False + # Commands raised from other threads, chiefly the event bus. See + # on_player_join for why they cannot be sent where they are raised. + self._actions: queue.Queue = queue.Queue() + + def set_error_logger(self, logger: Callable[[str], None]) -> None: + self._error_logger = logger + + def _log_error(self, message: str) -> None: + if self._error_logger is not None: + try: + self._error_logger(message) + except Exception: # noqa: BLE001 - reporting a failure must not fail + # Supplied by the caller and possibly closed. Losing the report + # beats raising on the timer thread. + pass + + def _run(self, command: str) -> bool: + """Send one command, reporting rather than raising on failure.""" + if self.runner is None: + return False + try: + self.runner(command) + return True + except Exception as exc: # noqa: BLE001 - the server may be down + self._log_error(f"Bedtime command failed ({command.split()[0]}): {exc}") + return False + + # Scheduling + + def _night_of(self, moment: datetime) -> date: + """Which evening a moment belongs to. + + Anything before the wake time belongs to the previous evening, so that + 01:00 on Saturday is still Friday night. + """ + if moment.time() < self.config.wake: + return (moment - timedelta(days=1)).date() + return moment.date() + + def _base_bedtime(self, night: date) -> datetime: + """Tonight's bedtime before any extension.""" + return datetime.combine(night, self.config.bedtime_for(night)) + + def bedtime_on(self, night: date) -> datetime: + """Tonight's bedtime, including any extension granted tonight.""" + base = self._base_bedtime(night) + with self._lock: + if self._state.night == night: + return base + timedelta(minutes=self._state.extra_minutes) + return base + + def wake_after(self, night: date) -> datetime: + """When the server opens again after ``night``.""" + bedtime = self._base_bedtime(night) + wake = datetime.combine(night, self.config.wake) + if wake <= bedtime: + wake += timedelta(days=1) + return wake + + def next_bedtime(self, now: datetime) -> datetime: + """The next bedtime that will actually be enforced.""" + night = self._night_of(now) + candidate = self.bedtime_on(night) + if now < candidate and not self.is_skipped(night): + return candidate + + # Tonight has passed or been skipped; look at following evenings. + following = night + timedelta(days=1) + for _ in range(8): + if not self.is_skipped(following): + return self._base_bedtime(following) + following += timedelta(days=1) + return self._base_bedtime(following) + + def is_skipped(self, night: date) -> bool: + with self._lock: + return self._state.night == night and self._state.skipped + + def is_closed(self, now: datetime) -> bool: + """Whether the server is inside a bedtime window right now.""" + if not self.config.enabled: + return False + + night = self._night_of(now) + if self.is_skipped(night): + return False + return self.bedtime_on(night) <= now < self.wake_after(night) + + def seconds_until_bedtime(self, now: datetime) -> Optional[float]: + """Seconds remaining, or ``None`` when the window is already closed.""" + if self.is_closed(now): + return None + return (self.next_bedtime(now) - now).total_seconds() + + # Controls + + def extend(self, now: Optional[datetime] = None) -> tuple[bool, str]: + """Grant "five more minutes", within the configured limit.""" + now = now or datetime.now() + night = self._night_of(now) + + with self._lock: + self._ensure_night(night) + if self.config.max_extensions <= 0: + return False, "Extensions are disabled" + if self._state.extensions >= self.config.max_extensions: + return False, "No extensions left tonight" + if self._state.enforced: + return False, "Bedtime has already happened tonight" + + self._state.extensions += 1 + self._state.extra_minutes += self.config.extend_minutes + minutes = self.config.extend_minutes + # A warning already given no longer applies to the new deadline. + self._state.warned = set() + + self._announce(f"Bedtime extended by {minutes} minutes. Make them count.") + return True, f"Extended by {minutes} minutes" + + def skip_tonight(self, now: Optional[datetime] = None) -> tuple[bool, str]: + """Cancel bedtime for this evening only.""" + now = now or datetime.now() + night = self._night_of(now) + + with self._lock: + self._ensure_night(night) + if self._state.enforced: + return False, "Bedtime has already happened tonight" + self._state.skipped = True + + self._clear_bossbar() + self._announce("No bedtime tonight. Enjoy it.") + return True, "Bedtime skipped for tonight" + + def start_now(self, now: Optional[datetime] = None) -> tuple[bool, str]: + """Bring bedtime forward to right now.""" + now = now or datetime.now() + night = self._night_of(now) + + with self._lock: + self._ensure_night(night) + if self._state.enforced: + return False, "Bedtime has already happened tonight" + + self._enforce(now) + return True, "Bedtime started" + + def _ensure_night(self, night: date) -> None: + """Roll state over to a new evening. Caller holds the lock.""" + if self._state.night != night: + self._state.reset_to(night) + + # The countdown + + def status(self, now: Optional[datetime] = None) -> dict: + """Everything a dashboard or a phone widget needs, in one shape.""" + now = now or datetime.now() + night = self._night_of(now) + closed = self.is_closed(now) + remaining = self.seconds_until_bedtime(now) + + with self._lock: + extensions_used = self._state.extensions if self._state.night == night else 0 + skipped = self._state.night == night and self._state.skipped + + return { + "enabled": self.config.enabled, + "closed": closed, + "skipped_tonight": skipped, + "next_bedtime": self.next_bedtime(now).isoformat(timespec="minutes"), + "seconds_until_bedtime": None if remaining is None else max(0, int(remaining)), + "opens_at": self.wake_after(night).isoformat(timespec="minutes") if closed else None, + "extensions_used": extensions_used, + "extensions_allowed": self.config.max_extensions, + "extend_minutes": self.config.extend_minutes, + "action": self.config.action, + "weeknight_bedtime": self.config.weeknight.strftime("%H:%M"), + "weekend_bedtime": self.config.weekend.strftime("%H:%M"), + "wake_time": self.config.wake.strftime("%H:%M"), + } + + def tick(self, now: Optional[datetime] = None) -> None: + """One evaluation of the clock. Safe to call as often as you like.""" + if not self.config.enabled: + return + + now = now or datetime.now() + night = self._night_of(now) + + with self._lock: + self._ensure_night(night) + if self._state.skipped: + return + already_enforced = self._state.enforced + + bedtime = self.bedtime_on(night) + remaining = (bedtime - now).total_seconds() + + if remaining <= 0: + if not already_enforced: + self._enforce(now) + return + + self._warn_if_due(remaining) + self._update_bossbar(remaining) + + def _warn_if_due(self, remaining_seconds: float) -> None: + """Fire the warning that describes the time actually left. + + Every mark at or above the remaining time is due, and the one worth + saying is the smallest of them. Announcing the largest instead would + mean a server started with ten minutes to go greeting everyone with + "30 minutes until bedtime". + + All the due marks are recorded as given, so the larger ones do not fire + afterwards once they no longer describe anything. + """ + remaining_minutes = remaining_seconds / 60.0 + due = [mark for mark in self.config.warn_minutes if remaining_minutes <= mark] + if not due: + return + + with self._lock: + if all(mark in self._state.warned for mark in due): + return + self._state.warned.update(due) + + mark = min(due) + unit = "minute" if mark == 1 else "minutes" + self._title(f"{mark} {unit} until bedtime") + + def _update_bossbar(self, remaining_seconds: float) -> None: + """Show a bar that empties as bedtime approaches. + + Only shown inside the first warning mark, so it is not sitting on the + screen all afternoon. + """ + if not self.config.bossbar or not self.config.warn_minutes: + return + + window_seconds = max(self.config.warn_minutes) * 60 + if remaining_seconds > window_seconds: + self._clear_bossbar() + return + + if not self._bossbar_shown: + self._run(f'bossbar add {BOSSBAR_ID} {{"text":"Bedtime"}}') + self._run(f"bossbar set {BOSSBAR_ID} color red") + self._run(f"bossbar set {BOSSBAR_ID} max {int(window_seconds)}") + self._run(f"bossbar set {BOSSBAR_ID} players @a") + self._bossbar_shown = True + + minutes_left = max(0, int(remaining_seconds // 60)) + label = f"Bedtime in {minutes_left} min" if minutes_left else "Bedtime now" + self._run(f'bossbar set {BOSSBAR_ID} name {{"text":"{label}"}}') + self._run(f"bossbar set {BOSSBAR_ID} value {max(0, int(remaining_seconds))}") + + def _clear_bossbar(self) -> None: + if self._bossbar_shown: + self._run(f"bossbar remove {BOSSBAR_ID}") + self._bossbar_shown = False + + def _enforce(self, now: datetime) -> None: + """Bedtime has arrived. Say goodnight, then do what was configured. + + Idempotent, and it has to be. The bedtime thread reaches this from + tick(), and an API request can reach it from start_now() at the same + moment. Both of those check `enforced` before calling, so without an + atomic check-and-set here the evening could be closed twice: two + goodnights, two kicks, two attempts to stop the server. + """ + with self._lock: + self._ensure_night(self._night_of(now)) + if self._state.enforced: + return + self._state.enforced = True + + self._clear_bossbar() + self._announce("Goodnight. The server is closing.") + self._run("save-all") + + if self.config.action == ACTION_KICK: + self._run("kick @a The server is closed until morning. Goodnight!") + elif self.config.action == ACTION_STOP: + if self.stopper is not None: + try: + self.stopper() + except Exception as exc: # noqa: BLE001 - report and carry on + self._log_error(f"Bedtime could not stop the server: {exc}") + else: + self._run("stop") + + def on_player_join(self, event, now: Optional[datetime] = None) -> None: + """Turn players away while the window is closed. + + Subscribed to the event bus. Stopping the server is not enough by + itself: a restart policy or an update timer can bring it back, and then + the evening is open again. This is what actually holds the line. + + The kick is queued rather than sent here. Event bus handlers run on the + thread that follows the server log, and sending a command makes a + network call that can block for as long as its timeouts allow. The + bedtime thread picks it up, normally within a few milliseconds. + """ + if getattr(event, "type", None) != "join" or not event.player: + return + + now = now or datetime.now() + if not self.is_closed(now): + return + + opens = self.wake_after(self._night_of(now)).strftime("%H:%M") + self._actions.put(f"kick {event.player} The server is closed until {opens}. Goodnight!") + + def pending_actions(self) -> int: + """How many queued commands are waiting. Used by tests.""" + return self._actions.qsize() + + def run_pending(self, limit: int = 100) -> int: + """Send queued commands now. Returns how many were sent. + + The loop calls this; tests call it directly instead of starting a thread. + """ + sent = 0 + while sent < limit: + try: + command = self._actions.get_nowait() + except queue.Empty: + break + if command is None: + break + self._run(command) + sent += 1 + return sent + + def _announce(self, message: str) -> None: + self._run(f'tellraw @a {{"text":"{message}","color":"aqua"}}') + + def _title(self, message: str) -> None: + self._run(f'title @a title {{"text":"{message}","color":"gold"}}') + + # Background thread + + def start(self) -> None: + """Begin ticking on a background thread.""" + with self._lock: + if self._thread is not None: + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._loop, daemon=True, name="bedtime") + self._thread.start() + + def stop(self, timeout: float = 5.0) -> None: + """Stop ticking.""" + with self._lock: + thread, self._thread = self._thread, None + if thread is None: + return + self._stop_event.set() + # Wake the loop immediately rather than waiting out its timeout. + self._actions.put(None) + thread.join(timeout) + + def _loop(self) -> None: + """Tick on a schedule, but act on queued commands straight away.""" + next_tick = time_module.monotonic() + + while not self._stop_event.is_set(): + timeout = max(0.05, next_tick - time_module.monotonic()) + try: + command = self._actions.get(timeout=timeout) + except queue.Empty: + command = None + + if command is not None: + self._run(command) + + if time_module.monotonic() >= next_tick: + try: + self.tick() + except Exception as exc: # noqa: BLE001 - must outlive a bad tick + self._log_error(f"Bedtime tick failed: {exc}") + next_tick = time_module.monotonic() + TICK_SECONDS + + +_bedtime: Optional[Bedtime] = None +_bedtime_lock = threading.Lock() + + +def get_bedtime( + runner: Optional[Callable[[str], None]] = None, + stopper: Optional[Callable[[], None]] = None, +) -> Bedtime: + """Return the shared Bedtime, built from config on first use.""" + global _bedtime + with _bedtime_lock: + if _bedtime is None: + _bedtime = Bedtime(config=load_bedtime_config(), runner=runner, stopper=stopper) + else: + if runner is not None and _bedtime.runner is None: + _bedtime.runner = runner + if stopper is not None and _bedtime.stopper is None: + _bedtime.stopper = stopper + return _bedtime + + +def reset_bedtime() -> None: + """Drop the shared instance. Used by tests.""" + global _bedtime + with _bedtime_lock: + if _bedtime is not None: + _bedtime.stop() + _bedtime = None diff --git a/api/openapi.yaml b/api/openapi.yaml index 420824e..d4c83ba 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -86,6 +86,53 @@ components: description: Session cookie from login schemas: + BedtimeStatus: + type: object + properties: + enabled: + type: boolean + closed: + type: boolean + description: Whether the server is inside a bedtime window right now + skipped_tonight: + type: boolean + next_bedtime: + type: string + description: Local time of the next bedtime that will be enforced + seconds_until_bedtime: + type: integer + nullable: true + description: Null while the window is already closed + opens_at: + type: string + nullable: true + description: When the server opens again, while closed + extensions_used: + type: integer + extensions_allowed: + type: integer + extend_minutes: + type: integer + action: + type: string + enum: [stop, kick, announce] + weeknight_bedtime: + type: string + weekend_bedtime: + type: string + wake_time: + type: string + + BedtimeControlResult: + type: object + properties: + success: + type: boolean + message: + type: string + status: + $ref: '#/components/schemas/BedtimeStatus' + DeathRecord: type: object properties: @@ -698,6 +745,85 @@ paths: total_lines: type: integer + /api/bedtime: + get: + tags: + - Bedtime + summary: Get bedtime status + description: | + The countdown, the closed window and the extension budget. + Requires the `server.view` permission, so a read-only account can watch + the countdown. + operationId: getBedtime + responses: + '200': + description: Current bedtime status + content: + application/json: + schema: + $ref: '#/components/schemas/BedtimeStatus' + '503': + description: Bedtime mode is unavailable + + /api/bedtime/extend: + post: + tags: + - Bedtime + summary: Grant more time tonight + description: Pushes tonight's bedtime back. Requires `server.control`. + operationId: extendBedtime + responses: + '200': + description: Extension granted + content: + application/json: + schema: + $ref: '#/components/schemas/BedtimeControlResult' + '409': + description: >- + Refused, with a reason. The request was well-formed; the server will + not do it, because the budget is spent or bedtime already happened. + '503': + description: Bedtime mode is unavailable + + /api/bedtime/skip: + post: + tags: + - Bedtime + summary: Skip bedtime tonight + description: Cancels bedtime for this evening only. Requires `server.control`. + operationId: skipBedtime + responses: + '200': + description: Bedtime skipped + content: + application/json: + schema: + $ref: '#/components/schemas/BedtimeControlResult' + '409': + description: Refused, with a reason + '503': + description: Bedtime mode is unavailable + + /api/bedtime/now: + post: + tags: + - Bedtime + summary: Start bedtime immediately + description: Brings bedtime forward to right now. Requires `server.control`. + operationId: startBedtimeNow + responses: + '200': + description: Bedtime started + content: + application/json: + schema: + $ref: '#/components/schemas/BedtimeControlResult' + '409': + description: Refused, with a reason + '503': + description: Bedtime mode is unavailable + /api/deaths: get: tags: diff --git a/api/server.py b/api/server.py index e2f82bc..5f341f1 100644 --- a/api/server.py +++ b/api/server.py @@ -117,6 +117,15 @@ def is_rate_limit_exceeded(*args, **kwargs): DEATHS_AVAILABLE = False hall_of_deaths = None +# Bedtime mode. Warns, counts down, then closes the server for the night. +try: + from api import bedtime as bedtime_mode + + BEDTIME_AVAILABLE = True +except ImportError: + BEDTIME_AVAILABLE = False + bedtime_mode = None + app = Flask(__name__) # SECRET_KEY is resolved further down, once config/api.conf has been read. @@ -413,6 +422,20 @@ def run_rcon_command(command): return run_script("rcon-client.sh", "command", command) +def _stop_server_for_bedtime(): + """Stop the server the way the rest of the project does.""" + _, stderr, code = run_script("manage.sh", "stop", timeout=600) + if code != 0: + raise RuntimeError(stderr or f"manage.sh stop returned {code}") + + +def _run_game_command(command): + """Run a command for a feature that needs the server to see it.""" + _, stderr, code = run_rcon_command(command) + if code != 0: + raise RuntimeError(stderr or f"RCON returned {code}") + + def _announce_in_game(command): """Run a command whose only purpose is to show players something. @@ -2340,6 +2363,62 @@ def get_deaths_leaderboard(): return jsonify({"error": "Internal server error"}), 500 +@app.route("/api/bedtime", methods=["GET"]) +@require_permission("server.view") +def get_bedtime_status(): + """Current bedtime status: when it is, how long is left, whether it holds.""" + if not BEDTIME_AVAILABLE: + return jsonify({"error": "Bedtime mode is unavailable"}), 503 + + try: + return jsonify(bedtime_mode.get_bedtime().status()) + except Exception as e: + app.logger.error(f"Error reading bedtime status: {e}") + return jsonify({"error": "Internal server error"}), 500 + + +def _bedtime_control(operation): + """Shared plumbing for the three bedtime controls. + + Each returns (ok, message); a refusal is a 409 because the request was + well-formed and the server simply will not do it right now. + """ + if not BEDTIME_AVAILABLE: + return jsonify({"error": "Bedtime mode is unavailable"}), 503 + + try: + ok, message = operation(bedtime_mode.get_bedtime()) + except Exception as e: + app.logger.error(f"Bedtime control failed: {e}") + return jsonify({"error": "Internal server error"}), 500 + + log_audit_event(get_username_from_request(), "bedtime.control", {"result": sanitize_string(message[:100])}) + if not ok: + return jsonify({"success": False, "error": message}), 409 + return jsonify({"success": True, "message": message, "status": bedtime_mode.get_bedtime().status()}) + + +@app.route("/api/bedtime/extend", methods=["POST"]) +@require_permission("server.control") +def extend_bedtime(): + """Grant "five more minutes", within the configured limit.""" + return _bedtime_control(lambda bed: bed.extend()) + + +@app.route("/api/bedtime/skip", methods=["POST"]) +@require_permission("server.control") +def skip_bedtime(): + """Cancel bedtime for tonight only.""" + return _bedtime_control(lambda bed: bed.skip_tonight()) + + +@app.route("/api/bedtime/now", methods=["POST"]) +@require_permission("server.control") +def start_bedtime_now(): + """Bring bedtime forward to right now.""" + return _bedtime_control(lambda bed: bed.start_now()) + + @app.route("/api/players", methods=["GET"]) @require_permission("players.view") def get_players(): @@ -4085,6 +4164,15 @@ def start_event_capture(): hall.start_worker() bus.subscribe(hall.handle_event) + if BEDTIME_AVAILABLE: + bed = bedtime_mode.get_bedtime(runner=_run_game_command, stopper=_stop_server_for_bedtime) + bed.set_error_logger(app.logger.error) + # Stopping the server is not enough on its own: a restart policy or the + # update timer can bring it back and reopen the evening. Turning joins + # away during the closed window is what actually holds the line. + bus.subscribe(bed.on_player_join) + bed.start() + _ensure_log_reader() return True diff --git a/config/bedtime.conf.example b/config/bedtime.conf.example new file mode 100644 index 0000000..ca39f8c --- /dev/null +++ b/config/bedtime.conf.example @@ -0,0 +1,38 @@ +# Bedtime mode configuration +# +# Copy to config/bedtime.conf and edit. The real file is gitignored. +# See docs/BEDTIME.md. + +# Bedtime is off unless this says otherwise. A feature that can stop the server +# and turn players away should never switch itself on because a default said so. +ENABLED=false + +# When the evening ends, in 24-hour local time. +WEEKNIGHT_BEDTIME=20:30 +WEEKEND_BEDTIME=21:30 + +# When the server opens again. Between bedtime and this time, anyone who joins +# is sent back out with a message saying when they can return. +WAKE_TIME=07:00 + +# Which nights get the later bedtime. It is the night before a non-school +# morning that matters, so this defaults to Friday and Saturday evenings. +WEEKEND_NIGHTS=friday,saturday + +# Warnings before bedtime, in minutes. The largest also sets how early the +# bossbar countdown appears. +WARN_MINUTES=30,15,10,5,1 + +# "Five more minutes", granted from the dashboard. +EXTEND_MINUTES=15 +MAX_EXTENSIONS=1 + +# What happens at bedtime: +# stop - save and shut the server down +# kick - remove everyone, leave the server running +# announce - say goodnight and nothing else +# The closed window is enforced either way: joins are turned away until WAKE_TIME. +ACTION=stop + +# Show a bossbar countdown during the warning window. +BOSSBAR=true diff --git a/docs/BEDTIME.md b/docs/BEDTIME.md new file mode 100644 index 0000000..beb6860 --- /dev/null +++ b/docs/BEDTIME.md @@ -0,0 +1,111 @@ +# Bedtime Mode + +A scheduled, warned, enforceable end to the evening. + +A shutdown that arrives without warning starts an argument. Bedtime gives a +countdown instead: a bossbar that empties, titles at the agreed marks, and a +goodnight message. The end of the evening becomes something the server +announced rather than something a parent did. + +## What players see + +``` +[30 minutes until bedtime] title, with a bossbar appearing +[10 minutes until bedtime] +[5 minutes until bedtime] +[1 minute until bedtime] +Goodnight. The server is closing. +``` + +Then, depending on the configured action, the server saves and stops, or +everyone is removed and the server stays up. + +## The closed window + +Bedtime is a window, not a moment. Between bedtime and the wake time, anyone who +joins is sent straight back out with a message saying when the server opens +again. + +That is the part that makes it hold. Stopping the server is not enough on its +own: a restart policy, an update timer, or somebody pressing start on the +dashboard all reopen the evening. Turning joins away is what actually enforces +it, and it works regardless of how the server came back. + +The window spans midnight correctly. Anything before the wake time belongs to +the previous evening, so 01:00 on Saturday is still Friday night. + +## Controls + +The page at `/bedtime` shows the countdown and three buttons. + +| Control | What it does | +| --- | --- | +| **+15 minutes** | Pushes tonight's bedtime back, within `MAX_EXTENSIONS`. Warnings re-arm, so the countdown is announced again against the new deadline. | +| **Skip tonight** | Cancels bedtime for this evening only. Everything resets tomorrow. | +| **Bedtime now** | Brings it forward to right now. | + +All three refuse once bedtime has already happened, and say so. A refusal comes +back as `409`, because the request was fine and the server simply will not do it. + +## Configuration + +Copy `config/bedtime.conf.example` to `config/bedtime.conf`. The real file is +gitignored. + +| Setting | Default | Meaning | +| --- | --- | --- | +| `ENABLED` | `false` | Bedtime is off unless this says otherwise. | +| `WEEKNIGHT_BEDTIME` | `20:30` | School-night bedtime, local time. | +| `WEEKEND_BEDTIME` | `21:30` | Late bedtime. | +| `WAKE_TIME` | `07:00` | When the server opens again. | +| `WEEKEND_NIGHTS` | `friday,saturday` | Which evenings get the later time. | +| `WARN_MINUTES` | `30,15,10,5,1` | Warnings before bedtime. The largest also sets how early the bossbar appears. | +| `EXTEND_MINUTES` | `15` | Length of one extension. | +| `MAX_EXTENSIONS` | `1` | Extensions allowed per evening. `0` disables them. | +| `ACTION` | `stop` | `stop`, `kick` or `announce`. | +| `BOSSBAR` | `true` | Show the countdown bar. | + +**Bedtime defaults to disabled, deliberately.** Something that can stop the +server and turn people away should never switch itself on because a default said +so. + +`WEEKEND_NIGHTS` names evenings, not days. It is the night before a non-school +morning that matters, which is why it defaults to Friday and Saturday. + +## REST API + +`GET /api/bedtime` needs `server.view`, so a read-only account can watch the +countdown. The three controls need `server.control`. + +``` +GET /api/bedtime +POST /api/bedtime/extend +POST /api/bedtime/skip +POST /api/bedtime/now +``` + +```bash +curl -H "X-API-Key: $API_KEY" http://localhost:8080/api/bedtime +``` + +The status shape is the same one the dashboard renders, so it also suits a phone +widget or a Shortcut. + +## Notes + +- **Warnings describe the time actually left.** A server started with ten + minutes to go says "10 minutes until bedtime", not "30 minutes". Every mark at + or above the remaining time is treated as given, so the larger ones do not fire + afterwards once they no longer describe anything. +- **The join kick is queued, not sent inline.** Event bus handlers run on the + thread that follows the server log, and sending a command makes a network call. + The bedtime thread picks it up, normally within a few milliseconds. See + [EVENT_BUS.md](EVENT_BUS.md). +- **All scheduling takes the current time as an argument**, so a whole evening + can be tested without waiting for one. +- **Times are local to the server**, from the container's `TZ`. + +## Related + +- [EVENT_BUS.md](EVENT_BUS.md) — where join events come from +- [FAMILY_SERVER_ROADMAP.md](FAMILY_SERVER_ROADMAP.md) — W5, and what comes next diff --git a/docs/INDEX.md b/docs/INDEX.md index 7c45090..74d1fd1 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -58,6 +58,7 @@ New here? Read `INSTALL.md`, then keep `QUICK_REFERENCE.md` open. | [RCON.md](RCON.md) | Remote console setup and usage | | [EVENT_BUS.md](EVENT_BUS.md) | Typed game events parsed from the server log | | [HALL_OF_DEATHS.md](HALL_OF_DEATHS.md) | Epitaphs for every death, in game and on the dashboard | +| [BEDTIME.md](BEDTIME.md) | Scheduled, warned end to the evening | | [ANALYTICS.md](ANALYTICS.md) | Player and server analytics collection and reports | --- diff --git a/scripts/auto-update.sh b/scripts/auto-update.sh new file mode 100755 index 0000000..e03efe5 --- /dev/null +++ b/scripts/auto-update.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# Pull the latest server image and restart only if something actually changed. +# +# Run by systemd/minecraft-update.timer. The unit used to run +# `docker compose up -d --force-recreate` unconditionally every hour, which +# restarted the server whether or not a new image existed, kicking everyone off +# on the hour, and restarted servers that had been stopped on purpose. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +SERVICE_NAME="minecraft-server" + +# Function to print usage +usage() { + echo "Usage: $0 {run|check}" + echo "" + echo "Commands:" + echo " run - Pull, and restart only if the image changed" + echo " check - Report whether an update is available, change nothing" + exit 1 +} + +# Function to get the image id the service is currently using +current_image_id() { + compose images -q "$SERVICE_NAME" 2>/dev/null | head -1 +} + +# Function to check whether the container is running right now +server_is_running() { + [ -n "$(compose ps --status running --quiet "$SERVICE_NAME" 2>/dev/null)" ] +} + +# Function to pull and conditionally restart +run_update() { + if ! server_is_running; then + # A stopped server was stopped for a reason: bedtime, maintenance, or a + # deliberate shutdown. Starting it again behind the owner's back is + # worse than being a version behind. + log_info "Server is not running; leaving it stopped" + return 0 + fi + + local before after + before="$(current_image_id)" + + log_info "Pulling the latest image..." + if ! compose pull "$SERVICE_NAME"; then + log_warn "Pull failed; keeping the current image" + return 0 + fi + + after="$(current_image_id)" + + if [ "$before" = "$after" ]; then + log_info "Already up to date; not restarting" + return 0 + fi + + log_info "New image found; restarting the server" + compose up -d "$SERVICE_NAME" + log_success "Server updated" +} + +# Function to report without changing anything +check_update() { + local before after + before="$(current_image_id)" + + if ! compose pull "$SERVICE_NAME"; then + log_error "Could not reach the registry" + return 1 + fi + + after="$(current_image_id)" + + if [ "$before" = "$after" ]; then + log_info "Up to date" + else + log_warn "An update is available; run '$0 run' to apply it" + fi +} + +main() { + case "${1:-}" in + run) + run_update + ;; + check) + check_update + ;; + *) + usage + ;; + esac +} + +main "$@" diff --git a/systemd/minecraft-scheduler.service b/systemd/minecraft-scheduler.service new file mode 100644 index 0000000..b87c660 --- /dev/null +++ b/systemd/minecraft-scheduler.service @@ -0,0 +1,16 @@ +[Unit] +Description=Run Due Minecraft Scheduled Commands +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +WorkingDirectory=/home/pi/minecraft-server +# One pass over config/command-schedule.json, running whatever is due. +# Without this nothing ever executed the schedules created in the web UI. +ExecStart=/usr/bin/python3 /home/pi/minecraft-server/scripts/command-scheduler.py run +User=pi +Group=pi + +[Install] +WantedBy=multi-user.target diff --git a/systemd/minecraft-scheduler.timer b/systemd/minecraft-scheduler.timer new file mode 100644 index 0000000..826e1f9 --- /dev/null +++ b/systemd/minecraft-scheduler.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Check for Due Minecraft Scheduled Commands +Requires=minecraft-scheduler.service + +[Timer] +# Every minute, which is the finest granularity the schedule format expresses. +OnBootSec=2min +OnUnitActiveSec=1min +AccuracySec=15s + +[Install] +WantedBy=timers.target diff --git a/systemd/minecraft-update.service b/systemd/minecraft-update.service index 9237cbe..3b79d09 100644 --- a/systemd/minecraft-update.service +++ b/systemd/minecraft-update.service @@ -1,5 +1,5 @@ [Unit] -Description=Pull Latest Minecraft Server Image and Restart +Description=Update the Minecraft Server Image if a New One Exists After=network-online.target docker.service Requires=docker.service Wants=network-online.target @@ -7,15 +7,14 @@ Wants=network-online.target [Service] Type=oneshot WorkingDirectory=/home/pi/minecraft-server -# Pull latest image -ExecStart=/bin/sh -c 'cd /home/pi/minecraft-server && /usr/bin/docker compose pull' -# Restart container if image changed -ExecStart=/bin/sh -c 'cd /home/pi/minecraft-server && /usr/bin/docker compose up -d --force-recreate' -# Log the update -ExecStartPost=/bin/sh -c 'echo "$(date): Minecraft server image updated" >> /var/log/minecraft-update.log' +# Pull, and restart only when the image actually changed. This used to run +# `docker compose up -d --force-recreate` unconditionally, which restarted the +# server every hour whether or not there was anything new, and restarted servers +# that had been stopped on purpose. +ExecStart=/home/pi/minecraft-server/scripts/auto-update.sh run +ExecStartPost=/bin/sh -c 'echo "$(date): Minecraft update check finished" >> /var/log/minecraft-update.log' User=pi Group=pi [Install] WantedBy=multi-user.target - diff --git a/tests/api/test_bedtime.py b/tests/api/test_bedtime.py new file mode 100644 index 0000000..38e1fed --- /dev/null +++ b/tests/api/test_bedtime.py @@ -0,0 +1,643 @@ +"""Tests for bedtime mode (api/bedtime.py). + +Every scheduling method takes the current time as an argument, so a whole +evening can be walked through without waiting for one. +""" + +import sys +from datetime import date, datetime, time, timedelta +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from api.bedtime import ( # noqa: E402 + ACTION_ANNOUNCE, + ACTION_KICK, + ACTION_STOP, + Bedtime, + BedtimeConfig, + get_bedtime, + load_bedtime_config, + parse_clock, + reset_bedtime, +) +from api.events import GameEvent # noqa: E402 + +# 2026-09-21 is a Monday, so 09-25 is a Friday. +MONDAY = date(2026, 9, 21) +FRIDAY = date(2026, 9, 25) + + +def at(day, hour, minute=0): + return datetime.combine(day, time(hour, minute)) + + +@pytest.fixture +def sent(): + return [] + + +@pytest.fixture +def config(): + return BedtimeConfig( + enabled=True, + weeknight=time(20, 30), + weekend=time(21, 30), + wake=time(7, 0), + weekend_nights=("friday", "saturday"), + warn_minutes=(30, 10, 5, 1), + extend_minutes=15, + max_extensions=1, + action=ACTION_KICK, + bossbar=False, + ) + + +@pytest.fixture +def bed(config, sent): + return Bedtime(config=config, runner=sent.append) + + +@pytest.fixture(autouse=True) +def clean_shared_bedtime(): + reset_bedtime() + yield + reset_bedtime() + + +def messages(sent, prefix): + return [command for command in sent if command.startswith(prefix)] + + +@pytest.mark.unit +class TestClockParsing: + def test_parses_a_time(self): + assert parse_clock("21:05", time(0, 0)) == time(21, 5) + + @pytest.mark.parametrize("value", ["", "nonsense", "25:00", "9", None, "aa:bb"]) + def test_falls_back_on_nonsense(self, value): + assert parse_clock(value, time(20, 30)) == time(20, 30) + + +@pytest.mark.unit +class TestConfig: + def test_is_disabled_without_a_config_file(self, tmp_path): + """Something that can stop the server must not enable itself.""" + assert load_bedtime_config(tmp_path / "absent.conf").enabled is False + + def test_reads_the_settings(self, tmp_path): + path = tmp_path / "bedtime.conf" + path.write_text( + "# comment\n" + "ENABLED=true\n" + "WEEKNIGHT_BEDTIME=19:45\n" + "WEEKEND_BEDTIME=22:00\n" + "WAKE_TIME=06:30\n" + "WEEKEND_NIGHTS=friday,saturday\n" + "WARN_MINUTES=20,5,1\n" + "EXTEND_MINUTES=10\n" + "MAX_EXTENSIONS=2\n" + "ACTION=kick\n" + "BOSSBAR=false\n" + ) + loaded = load_bedtime_config(path) + + assert loaded.enabled is True + assert loaded.weeknight == time(19, 45) + assert loaded.weekend == time(22, 0) + assert loaded.wake == time(6, 30) + assert loaded.warn_minutes == (20, 5, 1) + assert loaded.extend_minutes == 10 + assert loaded.max_extensions == 2 + assert loaded.action == ACTION_KICK + assert loaded.bossbar is False + + def test_warn_minutes_are_sorted_and_deduplicated(self, tmp_path): + path = tmp_path / "bedtime.conf" + path.write_text("WARN_MINUTES=5,30,5,bad,10,-2\n") + assert load_bedtime_config(path).warn_minutes == (30, 10, 5) + + def test_an_unknown_action_is_ignored(self, tmp_path): + path = tmp_path / "bedtime.conf" + path.write_text("ACTION=launch_missiles\n") + assert load_bedtime_config(path).action == ACTION_STOP + + def test_unknown_weekend_nights_are_dropped(self, tmp_path): + path = tmp_path / "bedtime.conf" + path.write_text("WEEKEND_NIGHTS=friday,caturday\n") + assert load_bedtime_config(path).weekend_nights == ("friday",) + + def test_weeknight_and_weekend_bedtimes_are_selected_by_day(self, config): + assert config.bedtime_for(MONDAY) == time(20, 30) + assert config.bedtime_for(FRIDAY) == time(21, 30) + + +@pytest.mark.unit +class TestTheClosedWindow: + def test_open_before_bedtime(self, bed): + assert bed.is_closed(at(MONDAY, 20, 29)) is False + + def test_closed_after_bedtime(self, bed): + assert bed.is_closed(at(MONDAY, 20, 31)) is True + + def test_still_closed_after_midnight(self, bed): + """The window spans midnight, which is where off-by-one bugs live.""" + assert bed.is_closed(at(MONDAY + timedelta(days=1), 3, 0)) is True + + def test_closed_right_up_to_the_wake_time(self, bed): + assert bed.is_closed(at(MONDAY + timedelta(days=1), 6, 59)) is True + + def test_open_again_after_the_wake_time(self, bed): + assert bed.is_closed(at(MONDAY + timedelta(days=1), 7, 1)) is False + + def test_friday_night_gets_the_later_bedtime(self, bed): + assert bed.is_closed(at(FRIDAY, 21, 0)) is False + assert bed.is_closed(at(FRIDAY, 21, 31)) is True + + def test_nothing_is_closed_when_disabled(self, config, sent): + config.enabled = False + disabled = Bedtime(config=config, runner=sent.append) + assert disabled.is_closed(at(MONDAY, 23, 0)) is False + + +@pytest.mark.unit +class TestCountdown: + def walk(self, bed, sent, start, minutes): + for offset in range(minutes + 1): + bed.tick(start + timedelta(minutes=offset)) + return sent + + def test_each_warning_fires_once_at_its_mark(self, bed, sent): + self.walk(bed, sent, at(MONDAY, 19, 55), 35) + + titles = messages(sent, "title") + assert len(titles) == 4 + assert "30 minutes" in titles[0] + assert "10 minutes" in titles[1] + assert "5 minutes" in titles[2] + assert "1 minute" in titles[3] + + def test_the_one_minute_warning_is_singular(self, bed, sent): + self.walk(bed, sent, at(MONDAY, 20, 29), 1) + assert any("1 minute until" in m and "minutes" not in m for m in messages(sent, "title")) + + def test_warnings_do_not_repeat_on_every_tick(self, bed, sent): + for _ in range(20): + bed.tick(at(MONDAY, 20, 25)) + assert len(messages(sent, "title")) == 1 + + def test_a_late_start_announces_the_time_actually_left(self, bed, sent): + """Starting inside the window must not announce the largest mark.""" + bed.tick(at(MONDAY, 20, 20)) + titles = messages(sent, "title") + assert len(titles) == 1 + assert "10 minutes" in titles[0] + + def test_a_late_start_does_not_fire_the_bigger_marks_afterwards(self, bed, sent): + bed.tick(at(MONDAY, 20, 20)) + sent.clear() + bed.tick(at(MONDAY, 20, 21)) + assert messages(sent, "title") == [] + + def test_no_warnings_long_before_bedtime(self, bed, sent): + bed.tick(at(MONDAY, 15, 0)) + assert messages(sent, "title") == [] + + def test_bedtime_says_goodnight_and_saves(self, bed, sent): + bed.tick(at(MONDAY, 20, 31)) + assert any("Goodnight" in m for m in messages(sent, "tellraw")) + assert "save-all" in sent + + def test_kick_action_removes_everyone(self, bed, sent): + bed.tick(at(MONDAY, 20, 31)) + assert any(m.startswith("kick @a") for m in sent) + + def test_enforcement_happens_once(self, bed, sent): + for offset in range(10): + bed.tick(at(MONDAY, 20, 31) + timedelta(minutes=offset)) + assert len(messages(sent, "kick @a")) == 1 + + def test_announce_action_does_not_remove_anyone(self, config, sent): + config.action = ACTION_ANNOUNCE + Bedtime(config=config, runner=sent.append).tick(at(MONDAY, 20, 31)) + assert messages(sent, "kick") == [] + assert any("Goodnight" in m for m in messages(sent, "tellraw")) + + def test_stop_action_uses_the_stopper(self, config, sent): + config.action = ACTION_STOP + stops = [] + Bedtime(config=config, runner=sent.append, stopper=lambda: stops.append(1)).tick(at(MONDAY, 20, 31)) + assert stops == [1] + + def test_stop_action_falls_back_to_the_stop_command(self, config, sent): + config.action = ACTION_STOP + Bedtime(config=config, runner=sent.append, stopper=None).tick(at(MONDAY, 20, 31)) + assert "stop" in sent + + def test_a_failing_stopper_is_reported_not_raised(self, config, sent): + config.action = ACTION_STOP + errors = [] + + def broken(): + raise RuntimeError("compose is unhappy") + + bed = Bedtime(config=config, runner=sent.append, stopper=broken) + bed.set_error_logger(errors.append) + bed.tick(at(MONDAY, 20, 31)) + + assert errors and "stop the server" in errors[0] + + def test_a_failing_command_is_reported_not_raised(self, config): + errors = [] + + def broken(_command): + raise RuntimeError("server is down") + + bed = Bedtime(config=config, runner=broken) + bed.set_error_logger(errors.append) + bed.tick(at(MONDAY, 20, 31)) + + assert errors + + def test_nothing_happens_when_disabled(self, config, sent): + config.enabled = False + Bedtime(config=config, runner=sent.append).tick(at(MONDAY, 20, 31)) + assert sent == [] + + +@pytest.mark.unit +class TestBossbar: + @pytest.fixture + def bossbar_bed(self, config, sent): + config.bossbar = True + return Bedtime(config=config, runner=sent.append) + + def test_appears_inside_the_warning_window(self, bossbar_bed, sent): + bossbar_bed.tick(at(MONDAY, 20, 15)) + assert any(m.startswith("bossbar add") for m in sent) + + def test_stays_hidden_outside_the_window(self, bossbar_bed, sent): + bossbar_bed.tick(at(MONDAY, 17, 0)) + assert messages(sent, "bossbar") == [] + + def test_is_created_once(self, bossbar_bed, sent): + for offset in range(10): + bossbar_bed.tick(at(MONDAY, 20, 15) + timedelta(seconds=offset * 30)) + assert len(messages(sent, "bossbar add")) == 1 + + def test_value_counts_down(self, bossbar_bed, sent): + bossbar_bed.tick(at(MONDAY, 20, 10)) + first = [m for m in sent if m.startswith("bossbar set") and " value " in m][-1] + sent.clear() + bossbar_bed.tick(at(MONDAY, 20, 20)) + later = [m for m in sent if m.startswith("bossbar set") and " value " in m][-1] + + assert int(later.rsplit(" ", 1)[1]) < int(first.rsplit(" ", 1)[1]) + + def test_is_removed_at_bedtime(self, bossbar_bed, sent): + bossbar_bed.tick(at(MONDAY, 20, 15)) + sent.clear() + bossbar_bed.tick(at(MONDAY, 20, 31)) + assert any(m.startswith("bossbar remove") for m in sent) + + def test_can_be_turned_off(self, bed, sent): + bed.tick(at(MONDAY, 20, 15)) + assert messages(sent, "bossbar") == [] + + +@pytest.mark.unit +class TestControls: + def test_extend_pushes_bedtime_back(self, bed): + ok, _ = bed.extend(at(MONDAY, 20, 25)) + assert ok is True + assert bed.bedtime_on(MONDAY) == at(MONDAY, 20, 45) + + def test_extend_is_limited(self, bed): + bed.extend(at(MONDAY, 20, 25)) + ok, message = bed.extend(at(MONDAY, 20, 26)) + assert ok is False + assert "extensions left" in message + + def test_extension_re_arms_the_warnings(self, bed, sent): + """The warnings already given no longer describe the new deadline.""" + bed.tick(at(MONDAY, 20, 25)) + assert len(messages(sent, "title")) == 1 + + bed.extend(at(MONDAY, 20, 26)) + sent.clear() + bed.tick(at(MONDAY, 20, 40)) + + assert any("5 minutes" in m for m in messages(sent, "title")) + + def test_extend_is_refused_after_bedtime_has_happened(self, bed): + bed.tick(at(MONDAY, 20, 31)) + ok, message = bed.extend(at(MONDAY, 20, 32)) + assert ok is False + assert "already happened" in message + + def test_extend_is_refused_when_disabled_in_config(self, config, sent): + config.max_extensions = 0 + ok, message = Bedtime(config=config, runner=sent.append).extend(at(MONDAY, 20, 0)) + assert ok is False + assert "disabled" in message + + def test_skip_opens_the_whole_night(self, bed): + bed.skip_tonight(at(MONDAY, 19, 0)) + assert bed.is_closed(at(MONDAY, 23, 0)) is False + + def test_skip_moves_the_next_bedtime_to_tomorrow(self, bed): + bed.skip_tonight(at(MONDAY, 19, 0)) + assert bed.next_bedtime(at(MONDAY, 19, 0)).date() == MONDAY + timedelta(days=1) + + def test_skip_is_refused_after_bedtime_has_happened(self, bed): + bed.tick(at(MONDAY, 20, 31)) + ok, _ = bed.skip_tonight(at(MONDAY, 20, 32)) + assert ok is False + + def test_start_now_enforces_immediately(self, bed, sent): + ok, _ = bed.start_now(at(MONDAY, 18, 0)) + assert ok is True + assert any(m.startswith("kick @a") for m in sent) + + def test_start_now_is_refused_twice(self, bed): + bed.start_now(at(MONDAY, 18, 0)) + ok, _ = bed.start_now(at(MONDAY, 18, 5)) + assert ok is False + + def test_a_new_evening_resets_everything(self, bed): + bed.extend(at(MONDAY, 20, 25)) + bed.tick(at(MONDAY, 20, 50)) + + tuesday = MONDAY + timedelta(days=1) + status = bed.status(at(tuesday, 18, 0)) + assert status["extensions_used"] == 0 + assert status["skipped_tonight"] is False + + +@pytest.mark.unit +class TestClosedWindowEnforcement: + def join(self, player="Jonah"): + return GameEvent(type="join", timestamp="t", player=player, data={}) + + def test_a_join_during_the_window_is_kicked(self, bed, sent): + bed.on_player_join(self.join(), now=at(MONDAY, 22, 0)) + bed.run_pending() + + kicks = messages(sent, "kick Jonah") + assert kicks and "closed until 07:00" in kicks[0] + + def test_a_join_outside_the_window_is_left_alone(self, bed, sent): + bed.on_player_join(self.join(), now=at(MONDAY, 18, 0)) + bed.run_pending() + assert sent == [] + + def test_other_events_are_ignored(self, bed, sent): + death = GameEvent(type="death", timestamp="t", player="Jonah", data={}) + bed.on_player_join(death, now=at(MONDAY, 22, 0)) + bed.run_pending() + assert sent == [] + + def test_a_skipped_night_lets_people_in(self, bed, sent): + bed.skip_tonight(at(MONDAY, 19, 0)) + sent.clear() + bed.on_player_join(self.join(), now=at(MONDAY, 22, 0)) + bed.run_pending() + assert sent == [] + + def test_the_kick_is_queued_not_sent_on_the_calling_thread(self, bed, sent): + """Bus handlers run on the log follower; a network call must not block it.""" + bed.on_player_join(self.join(), now=at(MONDAY, 22, 0)) + + assert sent == [] + assert bed.pending_actions() == 1 + + assert bed.run_pending() == 1 + assert len(sent) == 1 + + def test_run_pending_with_nothing_queued(self, bed): + assert bed.run_pending() == 0 + + +@pytest.mark.unit +class TestStatus: + def test_reports_the_countdown(self, bed): + status = bed.status(at(MONDAY, 20, 0)) + assert status["closed"] is False + assert status["seconds_until_bedtime"] == 30 * 60 + assert status["next_bedtime"].endswith("20:30") + + def test_reports_the_closed_window(self, bed): + status = bed.status(at(MONDAY, 22, 0)) + assert status["closed"] is True + assert status["seconds_until_bedtime"] is None + assert status["opens_at"].endswith("07:00") + + def test_reports_extension_budget(self, bed): + bed.extend(at(MONDAY, 20, 0)) + status = bed.status(at(MONDAY, 20, 1)) + assert status["extensions_used"] == 1 + assert status["extensions_allowed"] == 1 + + def test_reports_the_configured_times(self, bed): + status = bed.status(at(MONDAY, 12, 0)) + assert status["weeknight_bedtime"] == "20:30" + assert status["weekend_bedtime"] == "21:30" + assert status["wake_time"] == "07:00" + + +@pytest.mark.unit +class TestSharedInstance: + def test_returns_a_singleton(self): + assert get_bedtime() is get_bedtime() + + def test_reset_rebuilds(self): + first = get_bedtime() + reset_bedtime() + assert get_bedtime() is not first + + def test_runner_is_attached_on_first_supply(self, sent): + assert get_bedtime(runner=sent.append).runner is not None + + def test_thread_starts_and_stops(self, bed): + bed.start() + try: + assert bed._thread is not None + finally: + bed.stop() + assert bed._thread is None + + def test_starting_twice_runs_one_thread(self, bed): + bed.start() + first = bed._thread + bed.start() + try: + assert bed._thread is first + finally: + bed.stop() + + def test_stopping_without_starting_is_safe(self, bed): + bed.stop() + + +@pytest.mark.api +class TestBedtimeEndpoints: + """Tests for /api/bedtime and its three controls.""" + + @pytest.fixture(autouse=True) + def shared_bedtime(self, config, sent, monkeypatch): + instance = Bedtime(config=config, runner=sent.append) + monkeypatch.setattr("api.bedtime.get_bedtime", lambda *_args, **_kwargs: instance) + return instance + + def test_status_requires_authentication(self, client): + assert client.get("/api/bedtime").status_code == 401 + + def test_status_reports_the_schedule(self, client, mock_api_keys): + response = client.get("/api/bedtime", headers={"X-API-Key": mock_api_keys}) + assert response.status_code == 200 + + payload = response.get_json() + assert payload["enabled"] is True + assert payload["weeknight_bedtime"] == "20:30" + assert "seconds_until_bedtime" in payload + + def test_extend_requires_authentication(self, client): + assert client.post("/api/bedtime/extend").status_code == 401 + + def test_extend_grants_more_time(self, client, mock_api_keys, shared_bedtime): + response = client.post("/api/bedtime/extend", headers={"X-API-Key": mock_api_keys}) + assert response.status_code == 200 + + payload = response.get_json() + assert payload["success"] is True + assert payload["status"]["extensions_used"] == 1 + + def test_a_refused_extension_is_a_conflict_not_an_error(self, client, mock_api_keys): + """The request was fine; the server simply will not do it again.""" + client.post("/api/bedtime/extend", headers={"X-API-Key": mock_api_keys}) + response = client.post("/api/bedtime/extend", headers={"X-API-Key": mock_api_keys}) + + assert response.status_code == 409 + assert response.get_json()["success"] is False + + def test_skip_cancels_tonight(self, client, mock_api_keys): + response = client.post("/api/bedtime/skip", headers={"X-API-Key": mock_api_keys}) + assert response.status_code == 200 + assert response.get_json()["status"]["skipped_tonight"] is True + + def test_start_now_enforces_immediately(self, client, mock_api_keys, sent): + response = client.post("/api/bedtime/now", headers={"X-API-Key": mock_api_keys}) + assert response.status_code == 200 + assert any(command.startswith("kick @a") for command in sent) + + def test_start_now_twice_is_a_conflict(self, client, mock_api_keys): + client.post("/api/bedtime/now", headers={"X-API-Key": mock_api_keys}) + response = client.post("/api/bedtime/now", headers={"X-API-Key": mock_api_keys}) + assert response.status_code == 409 + + +@pytest.mark.unit +class TestEnforcementIsIdempotent: + """The bedtime thread and an API request can both reach enforcement. + + tick() runs on the bedtime thread and start_now() runs on a request thread. + Both check `enforced` before acting, so without an atomic check-and-set the + evening could be closed twice: two goodnights, two kicks, two attempts to + stop the server. + """ + + def test_tick_and_start_now_together_enforce_once(self, bed, sent): + bed.start_now(at(MONDAY, 20, 31)) + bed.tick(at(MONDAY, 20, 31)) + + assert len(messages(sent, "kick @a")) == 1 + # "Goodnight" appears in the kick message too, so count announcements. + assert len([m for m in messages(sent, "tellraw") if "Goodnight" in m]) == 1 + assert len([m for m in sent if m == "save-all"]) == 1 + + def test_enforcing_twice_acts_once(self, bed, sent): + """The guard itself, tested directly. + + The two callers reach _enforce through their own `enforced` checks, and + those checks are far enough apart in time that a thread test cannot + reproduce the overlap reliably. Calling _enforce twice is the same + condition without the timing, and it fails without the guard. + """ + moment = at(MONDAY, 20, 31) + bed._enforce(moment) + bed._enforce(moment) + + assert len(messages(sent, "kick @a")) == 1 + assert len([m for m in messages(sent, "tellraw") if "Goodnight" in m]) == 1 + + def test_enforcing_twice_stops_the_server_once(self, config): + config.action = ACTION_STOP + stops = [] + bed = Bedtime(config=config, runner=lambda _c: None, stopper=lambda: stops.append(1)) + + moment = at(MONDAY, 20, 31) + bed._enforce(moment) + bed._enforce(moment) + + assert stops == [1] + + def test_concurrent_callers_enforce_once(self, config): + """A stress check. It cannot reliably reproduce the overlap on its own, + so the deterministic tests above are what actually cover the guard.""" + import threading + + sent = [] + sent_lock = threading.Lock() + + def record(command): + with sent_lock: + sent.append(command) + + bed = Bedtime(config=config, runner=record) + moment = at(MONDAY, 20, 31) + barrier = threading.Barrier(8) + + def race(): + barrier.wait() + bed.tick(moment) + + threads = [threading.Thread(target=race) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert len(messages(sent, "kick @a")) == 1 + + def test_stop_action_is_attempted_once_under_load(self, config): + import threading + + config.action = ACTION_STOP + stops = [] + bed = Bedtime(config=config, runner=lambda _c: None, stopper=lambda: stops.append(1)) + + moment = at(MONDAY, 20, 31) + barrier = threading.Barrier(6) + + def race(): + barrier.wait() + bed.tick(moment) + + threads = [threading.Thread(target=race) for _ in range(6)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert stops == [1] + + def test_a_new_evening_can_be_enforced_again(self, bed, sent): + """Idempotence is per evening, not forever.""" + bed.tick(at(MONDAY, 20, 31)) + sent.clear() + bed.tick(at(MONDAY + timedelta(days=1), 20, 31)) + + assert len(messages(sent, "kick @a")) == 1 diff --git a/tests/unit/test-auto-update.sh b/tests/unit/test-auto-update.sh new file mode 100755 index 0000000..cb7fd1f --- /dev/null +++ b/tests/unit/test-auto-update.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bats +# Unit Tests: auto-update.sh +# +# The unit this replaces ran `docker compose up -d --force-recreate` every hour +# regardless of whether a new image existed, so these tests are mostly about +# what the script declines to do. +# +# tests/helpers/bats-assert is a minimal stub: assert_output matches exactly and +# assert_line greps, with no refute helpers. Negative checks are therefore +# written as an explicit grep whose failure is asserted. + +load '../helpers/bats-support/load' +load '../helpers/bats-assert/load' + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + TEST_DIR="$(mktemp -d)" + cp -R "$REPO_DIR/scripts" "$TEST_DIR/scripts" + cd "$TEST_DIR" || exit 1 + + # A stub docker whose behaviour each test controls through these files. + STATE_DIR="$TEST_DIR/state" + mkdir -p bin "$STATE_DIR" + echo "running" > "$STATE_DIR/ps" + echo "image-a" > "$STATE_DIR/image" + : > "$STATE_DIR/calls" + + cat > bin/docker <> "$STATE_DIR/calls" +case "\$*" in + "compose version") + echo "Docker Compose version v2.0.0" + ;; + *"ps --status running"*) + cat "$STATE_DIR/ps" + ;; + *"images -q"*) + cat "$STATE_DIR/image" + ;; + *pull*) + if [ -f "$STATE_DIR/new-image" ]; then + cat "$STATE_DIR/new-image" > "$STATE_DIR/image" + fi + echo "pulled" + ;; + *) + echo "ok" + ;; +esac +exit 0 +STUB + chmod +x bin/docker + export PATH="$TEST_DIR/bin:$PATH" +} + +teardown() { + cd / + rm -rf "$TEST_DIR" +} + +# Helper: assert the stub was never asked to do something +assert_docker_not_called_with() { + run grep -q -- "$1" "$STATE_DIR/calls" + assert_failure +} + +# Helper: assert the stub was asked to do something +assert_docker_called_with() { + run grep -q -- "$1" "$STATE_DIR/calls" + assert_success +} + +@test "auto-update.sh shows usage without a subcommand" { + run scripts/auto-update.sh + assert_failure + assert_line "Usage:" +} + +@test "auto-update.sh rejects an unknown subcommand" { + run scripts/auto-update.sh frobnicate + assert_failure + assert_line "Usage:" +} + +@test "run does not restart when the image is unchanged" { + run scripts/auto-update.sh run + assert_success + assert_line "Already up to date" + assert_docker_not_called_with "up -d" +} + +@test "run restarts when the image changed" { + echo "image-b" > "$STATE_DIR/new-image" + + run scripts/auto-update.sh run + assert_success + assert_line "New image found" + assert_docker_called_with "up -d" +} + +@test "run leaves a stopped server stopped" { + # A stopped server was stopped for a reason; starting it behind the owner's + # back would undo bedtime or a deliberate maintenance shutdown. + : > "$STATE_DIR/ps" + + run scripts/auto-update.sh run + assert_success + assert_line "not running" + assert_docker_not_called_with "up -d" +} + +@test "run does not pull when the server is stopped" { + : > "$STATE_DIR/ps" + + run scripts/auto-update.sh run + assert_success + assert_docker_not_called_with "pull" +} + +@test "run still restarts a changed image only once" { + echo "image-b" > "$STATE_DIR/new-image" + run scripts/auto-update.sh run + assert_success + + run bash -c "grep -c -- 'up -d' '$STATE_DIR/calls'" + assert_output "1" +} + +@test "check reports when up to date" { + run scripts/auto-update.sh check + assert_success + assert_line "Up to date" +} + +@test "check reports an available update" { + echo "image-b" > "$STATE_DIR/new-image" + + run scripts/auto-update.sh check + assert_success + assert_line "update is available" +} + +@test "check never restarts the server" { + echo "image-b" > "$STATE_DIR/new-image" + + run scripts/auto-update.sh check + assert_success + assert_docker_not_called_with "up -d" +} diff --git a/web/src/App.jsx b/web/src/App.jsx index 23edbfe..ac839d4 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -13,6 +13,7 @@ const Players = lazy(() => import('./pages/Players')); const Backups = lazy(() => import('./pages/Backups')); const Worlds = lazy(() => import('./pages/Worlds')); const HallOfDeaths = lazy(() => import('./pages/HallOfDeaths')); +const Bedtime = lazy(() => import('./pages/Bedtime')); const Plugins = lazy(() => import('./pages/Plugins')); const ConfigFiles = lazy(() => import('./pages/ConfigFiles')); const FileBrowser = lazy(() => import('./pages/FileBrowser')); @@ -137,6 +138,18 @@ function App() { } /> + + + }> + + + + + } + /> { { path: '/logs', label: 'Logs', icon: '📝', category: 'main' }, { path: '/console', label: 'Console', icon: '💻', category: 'main' }, { path: '/scheduler', label: 'Scheduler', icon: '⏰', category: 'main' }, + { path: '/bedtime', label: 'Bedtime', icon: '🌙', category: 'main' }, { path: '/players', label: 'Players', icon: '👥', category: 'server' }, { path: '/deaths', label: 'Hall of Deaths', icon: '💀', category: 'server' }, { path: '/backups', label: 'Backups', icon: '💾', category: 'server' }, diff --git a/web/src/pages/Bedtime.jsx b/web/src/pages/Bedtime.jsx new file mode 100644 index 0000000..7dd1cd3 --- /dev/null +++ b/web/src/pages/Bedtime.jsx @@ -0,0 +1,218 @@ +import { useCallback, useState } from 'react'; +import { usePolling } from '../hooks/usePolling'; +import { api } from '../services/api'; + +const formatCountdown = seconds => { + if (seconds === null || seconds === undefined) return null; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}H ${minutes}M`; + if (minutes > 0) return `${minutes}M`; + return 'NOW'; +}; + +const formatWhen = isoString => { + if (!isoString) return ''; + const when = new Date(isoString); + if (Number.isNaN(when.getTime())) return isoString; + return when.toLocaleString(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const Bedtime = () => { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + setStatus(await api.getBedtime()); + setError(null); + } catch (err) { + console.error('Failed to load bedtime status:', err); + setError('Could not load bedtime status.'); + } finally { + setLoading(false); + } + }, []); + + // usePolling fetches on mount as well as on its interval, so this covers the + // initial load too. A separate effect here would just fire a second request. + // The countdown moves on its own, so it needs refreshing regardless. + usePolling(load, 15000); + + const act = async (action, label) => { + setBusy(true); + setNotice(null); + try { + const result = await action(); + setStatus(result.status || null); + setNotice(result.message || `${label} done.`); + setError(null); + } catch (err) { + // Only 409 means "the request was fine, the server will not do it". + // A 500 or 503 also carries an `error` field, and showing that as a + // friendly notice would dress a server failure up as a normal refusal. + const status = err?.response?.status; + const detail = err?.response?.data?.error; + + if (status === 409 && detail) { + setNotice(detail); + setError(null); + } else { + console.error(`Bedtime ${label} failed:`, err); + setNotice(null); + setError(detail || `Could not ${label.toLowerCase()}.`); + } + } finally { + setBusy(false); + } + }; + + const countdown = formatCountdown(status?.seconds_until_bedtime); + const extensionsLeft = status ? status.extensions_allowed - status.extensions_used : 0; + + return ( +
+

+ BEDTIME +

+

+ A WARNED AND ORDERLY END TO THE EVENING +

+ + {error && ( +
+

{error}

+
+ )} + + {notice && ( +
+

+ {notice} +

+
+ )} + + {loading ? ( +
+ CHECKING THE CLOCK... +
+ ) : !status ? ( +
+ BEDTIME STATUS UNAVAILABLE +
+ ) : !status.enabled ? ( +
+

+ BEDTIME IS OFF +

+

+ SET ENABLED=TRUE IN CONFIG/BEDTIME.CONF TO TURN IT ON +

+
+ ) : ( + <> +
+ {status.closed ? ( + <> +

+ THE SERVER IS CLOSED +

+

+ GOODNIGHT +

+

+ OPENS AT {status.wake_time} +

+ + ) : ( + <> +

+ {status.skipped_tonight ? 'NO BEDTIME TONIGHT' : 'BEDTIME IN'} +

+

+ {status.skipped_tonight ? 'SKIPPED' : countdown} +

+

+ {formatWhen(status.next_bedtime)} +

+ + )} +
+ +
+

+ CONTROLS +

+
+ + + +
+
+ +
+

+ SCHEDULE +

+
+
+
SCHOOL NIGHTS
+
+ {status.weeknight_bedtime} +
+
+
+
+ FRIDAY & SATURDAY +
+
+ {status.weekend_bedtime} +
+
+
+
OPENS AGAIN
+
+ {status.wake_time} +
+
+
+
AT BEDTIME
+
+ {status.action.toUpperCase()} +
+
+
+
+ + )} +
+ ); +}; + +export default Bedtime; diff --git a/web/src/pages/__tests__/Bedtime.test.jsx b/web/src/pages/__tests__/Bedtime.test.jsx new file mode 100644 index 0000000..6504e7b --- /dev/null +++ b/web/src/pages/__tests__/Bedtime.test.jsx @@ -0,0 +1,220 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as api from '../../services/api'; +import { renderWithRouter } from '../../test/utils'; +import Bedtime from '../Bedtime'; + +vi.mock('../../services/api', () => ({ + api: { + getBedtime: vi.fn(), + extendBedtime: vi.fn(), + skipBedtime: vi.fn(), + startBedtimeNow: vi.fn(), + }, +})); + +const aStatus = (overrides = {}) => ({ + enabled: true, + closed: false, + skipped_tonight: false, + next_bedtime: '2026-09-21T20:30', + seconds_until_bedtime: 1800, + opens_at: null, + extensions_used: 0, + extensions_allowed: 1, + extend_minutes: 15, + action: 'stop', + weeknight_bedtime: '20:30', + weekend_bedtime: '21:30', + wake_time: '07:00', + ...overrides, +}); + +describe('Bedtime', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the page title', async () => { + api.api.getBedtime.mockResolvedValue(aStatus()); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/^BEDTIME$/)).toBeInTheDocument(); + }); + }); + + it('shows a loading state first', () => { + api.api.getBedtime.mockImplementation(() => new Promise(() => {})); + renderWithRouter(); + + expect(screen.getByText(/checking the clock/i)).toBeInTheDocument(); + }); + + it('shows the countdown in hours and minutes', async () => { + api.api.getBedtime.mockResolvedValue(aStatus({ seconds_until_bedtime: 5400 })); + renderWithRouter(); + + await waitFor(() => expect(screen.getByText('1H 30M')).toBeInTheDocument()); + }); + + it('shows minutes alone when under an hour', async () => { + api.api.getBedtime.mockResolvedValue(aStatus({ seconds_until_bedtime: 600 })); + renderWithRouter(); + + await waitFor(() => expect(screen.getByText('10M')).toBeInTheDocument()); + }); + + it('says the server is closed during the window', async () => { + api.api.getBedtime.mockResolvedValue( + aStatus({ closed: true, seconds_until_bedtime: null, opens_at: '2026-09-22T07:00' }) + ); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/goodnight/i)).toBeInTheDocument(); + expect(screen.getByText(/opens at 07:00/i)).toBeInTheDocument(); + }); + }); + + it('explains how to turn bedtime on when it is off', async () => { + api.api.getBedtime.mockResolvedValue(aStatus({ enabled: false })); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/bedtime is off/i)).toBeInTheDocument(); + expect(screen.getByText(/bedtime\.conf/i)).toBeInTheDocument(); + }); + }); + + it('extends bedtime and shows the result', async () => { + const user = userEvent.setup(); + api.api.getBedtime.mockResolvedValue(aStatus()); + api.api.extendBedtime.mockResolvedValue({ + success: true, + message: 'Extended by 15 minutes', + status: aStatus({ extensions_used: 1 }), + }); + + renderWithRouter(); + await waitFor(() => expect(screen.getByText(/\+15 MINUTES/)).toBeInTheDocument()); + + await user.click(screen.getByText(/\+15 MINUTES/)); + + await waitFor(() => { + expect(screen.getByText(/extended by 15 minutes/i)).toBeInTheDocument(); + }); + }); + + it('disables the extend button when none are left', async () => { + api.api.getBedtime.mockResolvedValue(aStatus({ extensions_used: 1 })); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/0 LEFT/)).toBeDisabled(); + }); + }); + + it('shows the reason when a control is refused', async () => { + // A 409 carries a reason worth reading, not a generic failure. + const user = userEvent.setup(); + api.api.getBedtime.mockResolvedValue(aStatus()); + api.api.extendBedtime.mockRejectedValue({ + response: { status: 409, data: { error: 'No extensions left tonight' } }, + }); + + renderWithRouter(); + await waitFor(() => expect(screen.getByText(/\+15 MINUTES/)).toBeInTheDocument()); + + await user.click(screen.getByText(/\+15 MINUTES/)); + + await waitFor(() => { + expect(screen.getByText(/no extensions left tonight/i)).toBeInTheDocument(); + }); + }); + + it('treats a server error as an error, not a refusal', async () => { + // A 500 also carries an `error` field. Showing it as a friendly notice + // would dress a server failure up as a normal refusal. + const user = userEvent.setup(); + api.api.getBedtime.mockResolvedValue(aStatus()); + api.api.startBedtimeNow.mockRejectedValue({ + response: { status: 500, data: { error: 'Internal server error' } }, + }); + + renderWithRouter(); + await waitFor(() => expect(screen.getByText(/bedtime now/i)).toBeInTheDocument()); + + await user.click(screen.getByText(/bedtime now/i)); + + await waitFor(() => { + expect(screen.getByText(/internal server error/i)).toBeInTheDocument(); + }); + // The error banner is red; a refusal notice is not. + expect(screen.getByText(/internal server error/i).className).toMatch(/text-red/); + }); + + it('treats a 503 as an error too', async () => { + const user = userEvent.setup(); + api.api.getBedtime.mockResolvedValue(aStatus()); + api.api.skipBedtime.mockRejectedValue({ + response: { status: 503, data: { error: 'Bedtime mode is unavailable' } }, + }); + + renderWithRouter(); + await waitFor(() => expect(screen.getByText(/skip tonight/i)).toBeInTheDocument()); + + await user.click(screen.getByText(/skip tonight/i)); + + await waitFor(() => { + expect(screen.getByText(/bedtime mode is unavailable/i).className).toMatch(/text-red/); + }); + }); + + it('loads once on mount, not twice', async () => { + // usePolling already fetches on mount; a separate effect would double it. + api.api.getBedtime.mockResolvedValue(aStatus()); + renderWithRouter(); + + await waitFor(() => expect(api.api.getBedtime).toHaveBeenCalled()); + expect(api.api.getBedtime).toHaveBeenCalledTimes(1); + }); + + it('skips tonight', async () => { + const user = userEvent.setup(); + api.api.getBedtime.mockResolvedValue(aStatus()); + api.api.skipBedtime.mockResolvedValue({ + success: true, + message: 'Bedtime skipped for tonight', + status: aStatus({ skipped_tonight: true }), + }); + + renderWithRouter(); + await waitFor(() => expect(screen.getByText(/skip tonight/i)).toBeInTheDocument()); + + await user.click(screen.getByText(/skip tonight/i)); + + await waitFor(() => expect(screen.getByText(/^SKIPPED$/)).toBeInTheDocument()); + }); + + it('shows the configured schedule', async () => { + api.api.getBedtime.mockResolvedValue(aStatus()); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('20:30')).toBeInTheDocument(); + expect(screen.getByText('21:30')).toBeInTheDocument(); + expect(screen.getByText('STOP')).toBeInTheDocument(); + }); + }); + + it('reports a load failure', async () => { + api.api.getBedtime.mockRejectedValue(new Error('network down')); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/could not load bedtime status/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/services/api.js b/web/src/services/api.js index de2ee14..93ee66c 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -160,6 +160,30 @@ export const api = { return cachedGet('/events/types', {}, 300000); }, + // Bedtime mode: the countdown and its controls + async getBedtime() { + const response = await apiClient.get('/bedtime'); + return response.data; + }, + + async extendBedtime() { + const response = await apiClient.post('/bedtime/extend'); + invalidateCache(); // Bedtime moved, so cached reads are stale + return response.data; + }, + + async skipBedtime() { + const response = await apiClient.post('/bedtime/skip'); + invalidateCache(); // Bedtime moved, so cached reads are stale + return response.data; + }, + + async startBedtimeNow() { + const response = await apiClient.post('/bedtime/now'); + invalidateCache(); // This can stop the server, so /status is stale too + return response.data; + }, + // Hall of Deaths: epitaphs, stats and the leaderboard async getDeaths({ limit = 50, player = null, category = null } = {}) { const params = { limit };