From 4add9f65b74209c64b05083fd0e30b7de8f5e39a Mon Sep 17 00:00:00 2001 From: Matthew Anderson Date: Sat, 19 Sep 2026 09:36:25 -0500 Subject: [PATCH 1/4] fix: stop the hourly timer restarting the server, and actually run schedules Two bugs found while building bedtime mode. Both are pre-existing. The update timer restarted the server every hour systemd/minecraft-update.service ran `docker compose up -d --force-recreate` unconditionally, on an hourly timer. --force-recreate recreates containers even when nothing has changed, so everyone online was disconnected on the hour whether or not a new image existed. The comment above the line says "Restart container if image changed", but nothing checked. It also restarted servers that had been stopped deliberately, which would have undone bedtime within the hour. scripts/auto-update.sh now pulls, compares the image id before and after, and restarts only when it actually changed. A server that is not running is left alone, because it was stopped for a reason and starting it again behind the owner's back is worse than being a version behind. It also skips the pull in that case, so a stopped server costs nothing hourly. Scheduled commands were never executed The Scheduler page writes to config/command-schedule.json, and scripts/command-scheduler.py has a `run` subcommand that executes whatever is due. Nothing invoked it: no timer, no cron entry, no loop in the API. Every schedule created through the web UI was stored and silently ignored, and the page gave no sign of it. systemd/minecraft-scheduler.{service,timer} runs it once a minute, which is the finest granularity the schedule format expresses. Co-Authored-By: Claude Opus 5 --- scripts/auto-update.sh | 102 +++++++++++++++++++++ systemd/minecraft-scheduler.service | 16 ++++ systemd/minecraft-scheduler.timer | 12 +++ systemd/minecraft-update.service | 15 ++- tests/unit/test-auto-update.sh | 137 ++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 8 deletions(-) create mode 100755 scripts/auto-update.sh create mode 100644 systemd/minecraft-scheduler.service create mode 100644 systemd/minecraft-scheduler.timer create mode 100755 tests/unit/test-auto-update.sh 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/unit/test-auto-update.sh b/tests/unit/test-auto-update.sh new file mode 100755 index 0000000..a89932f --- /dev/null +++ b/tests/unit/test-auto-update.sh @@ -0,0 +1,137 @@ +#!/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. + +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. + mkdir -p bin + STATE_DIR="$TEST_DIR/state" + mkdir -p "$STATE_DIR" + echo "running" > "$STATE_DIR/ps" + echo "image-a" > "$STATE_DIR/image" + : > "$STATE_DIR/calls" + + cat > bin/docker <> "$STATE_DIR/calls" +case "\$*" in + *"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" +} + +@test "auto-update.sh passes shellcheck-able syntax" { + run bash -n scripts/auto-update.sh + assert_success +} + +@test "auto-update.sh shows usage without a subcommand" { + run scripts/auto-update.sh + assert_failure + assert_output --partial "Usage:" +} + +@test "auto-update.sh rejects an unknown subcommand" { + run scripts/auto-update.sh frobnicate + assert_failure + assert_output --partial "Usage:" +} + +@test "run does not restart when the image is unchanged" { + run scripts/auto-update.sh run + assert_success + assert_output --partial "Already up to date" + refute_line --partial "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_output --partial "New image found" + + run grep -c "up -d" "$STATE_DIR/calls" + assert_success +} + +@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_output --partial "not running" + + run grep -q "up -d" "$STATE_DIR/calls" + assert_failure +} + +@test "run does not pull when the server is stopped" { + : > "$STATE_DIR/ps" + + run scripts/auto-update.sh run + assert_success + + run grep -q "pull" "$STATE_DIR/calls" + assert_failure +} + +@test "check reports when up to date" { + run scripts/auto-update.sh check + assert_success + assert_output --partial "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_output --partial "update is available" +} + +@test "check never restarts the server" { + echo "image-b" > "$STATE_DIR/new-image" + + run scripts/auto-update.sh check + assert_success + + run grep -q "up -d" "$STATE_DIR/calls" + assert_failure +} From 095c38dd4c6963486128ac0e1a150390d5abc33c Mon Sep 17 00:00:00 2001 From: Matthew Anderson Date: Sat, 19 Sep 2026 09:36:25 -0500 Subject: [PATCH 2/4] feat: add bedtime mode W5 from the family server roadmap. A shutdown that arrives without warning starts an argument. Bedtime gives a countdown instead: a bossbar that empties, titles at the configured marks, and a goodnight message. The end of the evening becomes something the server announced rather than something a parent did. A window, not a moment 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. So bedtime defines a closed window: between bedtime and the wake time, anyone who joins is sent straight back out with a message saying when the server opens again. That holds regardless of how the server came back. The window spans midnight, with anything before the wake time belonging to the previous evening, so 01:00 on Saturday is still Friday night. Weeknight and weekend bedtimes are chosen by the evening rather than the day, because it is the night before a non-school morning that matters. Controls Extend by a configured amount, skip tonight, or start bedtime now. Extending re-arms the warnings, so the countdown is announced again against the new deadline rather than going quiet. All three refuse once bedtime has happened and say why; a refusal is 409, because the request was well-formed and the server simply will not do it. Notes on two decisions Bedtime defaults to disabled. Something that can stop the server and turn people away should never switch itself on because a default said so. The join kick is queued rather than sent in the event handler. Bus handlers run on the thread that follows the server log, and sending a command makes a network call; this is the same trap that the Hall of Deaths review caught, avoided here rather than repeated. A bug the tests caught Warnings fired the largest unfired mark rather than the one describing the time actually left, so a server started with ten minutes to go greeted everyone with "30 minutes until bedtime". Every mark at or above the remaining time is now treated as given, and the smallest is the one announced. Elsewhere - New page at /bedtime with the countdown, the controls and the schedule. - GET /api/bedtime (server.view, so a read-only account can watch the countdown), and POST /api/bedtime/extend, /skip and /now (server.control). - config/bedtime.conf.example, and docs/BEDTIME.md linked from the docs index. All scheduling takes the current time as an argument, so a whole evening is tested without waiting for one. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 41 ++ api/bedtime.py | 597 +++++++++++++++++++++++ api/openapi.yaml | 126 +++++ api/server.py | 88 ++++ config/bedtime.conf.example | 38 ++ docs/BEDTIME.md | 111 +++++ docs/INDEX.md | 1 + tests/api/test_bedtime.py | 539 ++++++++++++++++++++ web/src/App.jsx | 13 + web/src/components/Layout.jsx | 1 + web/src/pages/Bedtime.jsx | 214 ++++++++ web/src/pages/__tests__/Bedtime.test.jsx | 173 +++++++ web/src/services/api.js | 21 + 13 files changed, 1963 insertions(+) create mode 100644 api/bedtime.py create mode 100644 config/bedtime.conf.example create mode 100644 docs/BEDTIME.md create mode 100644 tests/api/test_bedtime.py create mode 100644 web/src/pages/Bedtime.jsx create mode 100644 web/src/pages/__tests__/Bedtime.test.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 27941f0..17d4b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ 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. + +- **`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..7f2416a --- /dev/null +++ b/api/bedtime.py @@ -0,0 +1,597 @@ +#!/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: + pass + elif key == "MAX_EXTENSIONS": + try: + config.max_extensions = max(0, int(value)) + except ValueError: + 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.""" + with self._lock: + 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/tests/api/test_bedtime.py b/tests/api/test_bedtime.py new file mode 100644 index 0000000..9e34e0d --- /dev/null +++ b/tests/api/test_bedtime.py @@ -0,0 +1,539 @@ +"""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 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..cfcc8c4 --- /dev/null +++ b/web/src/pages/Bedtime.jsx @@ -0,0 +1,214 @@ +import { useCallback, useEffect, 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); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + // The countdown moves on its own, so keep it honest without a refresh. + 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) { + // A refused control is a 409 carrying a reason worth showing. + const refusal = err?.response?.data?.error; + if (refusal) { + setNotice(refusal); + } else { + console.error(`Bedtime ${label} failed:`, err); + setError(`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..ba213bf --- /dev/null +++ b/web/src/pages/__tests__/Bedtime.test.jsx @@ -0,0 +1,173 @@ +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('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..0146309 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -160,6 +160,27 @@ 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'); + return response.data; + }, + + async skipBedtime() { + const response = await apiClient.post('/bedtime/skip'); + return response.data; + }, + + async startBedtimeNow() { + const response = await apiClient.post('/bedtime/now'); + return response.data; + }, + // Hall of Deaths: epitaphs, stats and the leaderboard async getDeaths({ limit = 50, player = null, category = null } = {}) { const params = { limit }; From 63d5f6456933814b86a8c626b0a8ebdfda89ebf3 Mon Sep 17 00:00:00 2001 From: Matthew Anderson Date: Sat, 19 Sep 2026 09:39:06 -0500 Subject: [PATCH 3/4] test: rewrite the auto-update tests against the real assertion helpers The tests used bats-assert's `--partial` flag and `refute_line`, neither of which exists in this repo's vendored helper. tests/helpers/bats-assert is a minimal stub: assert_output matches exactly, assert_line greps, and there are no refute helpers, so every partial match was being compared against the literal string "--partial". The script logic was correct throughout; only the assertions were wrong. They now use assert_line for substring matches, and negative checks are written as an explicit grep whose failure is asserted, wrapped in two named helpers so the intent stays readable. The stub docker also needed to answer `docker compose version`, which the compose() wrapper in scripts/lib/common.sh probes before running anything. Verified locally with bats this time rather than on CI. Co-Authored-By: Claude Opus 5 --- tests/unit/test-auto-update.sh | 60 +++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/tests/unit/test-auto-update.sh b/tests/unit/test-auto-update.sh index a89932f..cb7fd1f 100755 --- a/tests/unit/test-auto-update.sh +++ b/tests/unit/test-auto-update.sh @@ -4,6 +4,10 @@ # 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' @@ -16,9 +20,8 @@ setup() { cd "$TEST_DIR" || exit 1 # A stub docker whose behaviour each test controls through these files. - mkdir -p bin STATE_DIR="$TEST_DIR/state" - mkdir -p "$STATE_DIR" + mkdir -p bin "$STATE_DIR" echo "running" > "$STATE_DIR/ps" echo "image-a" > "$STATE_DIR/image" : > "$STATE_DIR/calls" @@ -27,6 +30,9 @@ setup() { #!/bin/bash echo "\$@" >> "$STATE_DIR/calls" case "\$*" in + "compose version") + echo "Docker Compose version v2.0.0" + ;; *"ps --status running"*) cat "$STATE_DIR/ps" ;; @@ -54,28 +60,35 @@ teardown() { rm -rf "$TEST_DIR" } -@test "auto-update.sh passes shellcheck-able syntax" { - run bash -n scripts/auto-update.sh +# 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_output --partial "Usage:" + assert_line "Usage:" } @test "auto-update.sh rejects an unknown subcommand" { run scripts/auto-update.sh frobnicate assert_failure - assert_output --partial "Usage:" + assert_line "Usage:" } @test "run does not restart when the image is unchanged" { run scripts/auto-update.sh run assert_success - assert_output --partial "Already up to date" - refute_line --partial "up -d" + assert_line "Already up to date" + assert_docker_not_called_with "up -d" } @test "run restarts when the image changed" { @@ -83,10 +96,8 @@ teardown() { run scripts/auto-update.sh run assert_success - assert_output --partial "New image found" - - run grep -c "up -d" "$STATE_DIR/calls" - assert_success + assert_line "New image found" + assert_docker_called_with "up -d" } @test "run leaves a stopped server stopped" { @@ -96,10 +107,8 @@ teardown() { run scripts/auto-update.sh run assert_success - assert_output --partial "not running" - - run grep -q "up -d" "$STATE_DIR/calls" - assert_failure + assert_line "not running" + assert_docker_not_called_with "up -d" } @test "run does not pull when the server is stopped" { @@ -107,15 +116,22 @@ teardown() { run scripts/auto-update.sh run assert_success + assert_docker_not_called_with "pull" +} - run grep -q "pull" "$STATE_DIR/calls" - assert_failure +@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_output --partial "Up to date" + assert_line "Up to date" } @test "check reports an available update" { @@ -123,7 +139,7 @@ teardown() { run scripts/auto-update.sh check assert_success - assert_output --partial "update is available" + assert_line "update is available" } @test "check never restarts the server" { @@ -131,7 +147,5 @@ teardown() { run scripts/auto-update.sh check assert_success - - run grep -q "up -d" "$STATE_DIR/calls" - assert_failure + assert_docker_not_called_with "up -d" } From aadb02744ed3514341873dec0787537288e75205 Mon Sep 17 00:00:00 2001 From: Matthew Anderson Date: Sat, 19 Sep 2026 09:46:19 -0500 Subject: [PATCH 4/4] fix: address review findings on bedtime mode All four Copilot findings and both CodeQL alerts. Each was checked against the code; all six were real. Enforcement could close the evening twice _enforce() set the enforced flag without first checking it. tick() runs on the bedtime thread and start_now() runs on a request thread, and both check the flag before calling, so the check and the set sat in separate lock acquisitions. Two callers arriving together could both pass their check and both act: two goodnights, two kicks, two attempts to stop the server. _enforce() now does an atomic check-and-set under the lock and returns early if the evening is already closed. Worth noting about the tests: my first attempt spawned threads at a barrier and passed with the guard removed, so it proved nothing. The overlap is too narrow to reproduce reliably under the GIL. Calling _enforce() twice is the same condition without the timing, and that test does fail without the guard. The thread test is kept as a stress check and labelled as one. A server error was shown as a friendly refusal The dashboard treated any response carrying an `error` field as a refusal and showed it as a green notice. A 500 or 503 also carries that field, so a genuine server failure was dressed up as a normal "no extensions left" message, and a previous error could stay on screen underneath. Only 409 is a refusal now; everything else is an error, and the two clear each other. The page loaded twice on mount usePolling already calls its fetch function on mount, so the separate useEffect fired a second identical request every time the page opened. Bedtime controls left the cache stale Every other state-changing method in web/src/services/api.js calls invalidateCache(). The three bedtime POSTs did not, which matters most for "bedtime now", since it can stop the server while a cached /status still says it is running. CodeQL: both empty except clauses in the config parser now state why a bad value is ignored rather than raised. The three dashboard fixes have tests confirmed to fail without them. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + api/bedtime.py | 15 +++- tests/api/test_bedtime.py | 104 +++++++++++++++++++++++ web/src/pages/Bedtime.jsx | 26 +++--- web/src/pages/__tests__/Bedtime.test.jsx | 47 ++++++++++ web/src/services/api.js | 3 + 6 files changed, 186 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d4b0b..fbb5215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ All notable changes to this project will be documented in this file. `/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 diff --git a/api/bedtime.py b/api/bedtime.py index 7f2416a..429a042 100644 --- a/api/bedtime.py +++ b/api/bedtime.py @@ -133,11 +133,14 @@ def load_bedtime_config(config_file: Optional[Path] = None) -> BedtimeConfig: 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() @@ -455,8 +458,18 @@ def _clear_bossbar(self) -> None: self._bossbar_shown = False def _enforce(self, now: datetime) -> None: - """Bedtime has arrived. Say goodnight, then do what was configured.""" + """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() diff --git a/tests/api/test_bedtime.py b/tests/api/test_bedtime.py index 9e34e0d..38e1fed 100644 --- a/tests/api/test_bedtime.py +++ b/tests/api/test_bedtime.py @@ -537,3 +537,107 @@ 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/web/src/pages/Bedtime.jsx b/web/src/pages/Bedtime.jsx index cfcc8c4..7dd1cd3 100644 --- a/web/src/pages/Bedtime.jsx +++ b/web/src/pages/Bedtime.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { usePolling } from '../hooks/usePolling'; import { api } from '../services/api'; @@ -41,11 +41,9 @@ const Bedtime = () => { } }, []); - useEffect(() => { - load(); - }, [load]); - - // The countdown moves on its own, so keep it honest without a refresh. + // 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) => { @@ -57,13 +55,19 @@ const Bedtime = () => { setNotice(result.message || `${label} done.`); setError(null); } catch (err) { - // A refused control is a 409 carrying a reason worth showing. - const refusal = err?.response?.data?.error; - if (refusal) { - setNotice(refusal); + // 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); - setError(`Could not ${label.toLowerCase()}.`); + setNotice(null); + setError(detail || `Could not ${label.toLowerCase()}.`); } } finally { setBusy(false); diff --git a/web/src/pages/__tests__/Bedtime.test.jsx b/web/src/pages/__tests__/Bedtime.test.jsx index ba213bf..6504e7b 100644 --- a/web/src/pages/__tests__/Bedtime.test.jsx +++ b/web/src/pages/__tests__/Bedtime.test.jsx @@ -134,6 +134,53 @@ describe('Bedtime', () => { }); }); + 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()); diff --git a/web/src/services/api.js b/web/src/services/api.js index 0146309..93ee66c 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -168,16 +168,19 @@ export const api = { 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; },