From f8a9ea69e5f4603a66a557f1528bfd19cb8cbfad Mon Sep 17 00:00:00 2001 From: Amy Troschinetz Date: Thu, 10 Sep 2026 18:07:06 -0700 Subject: [PATCH 1/5] Add live activity widget to spellbot.io Showcase the queues page on the marketing site with live server, player and game counts. The counts are served by a new `/stats.json` endpoint that reads only from Redis, never the database. A task loop in the bot process recomputes them every minute and writes them to Redis with a TTL well beyond the refresh interval, so a transient failure shows slightly stale numbers rather than an empty widget. Homepage traffic is the highest-volume traffic we have, so it must not translate into database load. Counts are derived from the same service calls that back the queues page, so the numbers on spellbot.io always agree with what a visitor sees after clicking through. `STARTED_GAMES_WINDOW` moves into the service layer so the page, `/queues.json` and the cache share one definition. CORS is added for an explicit allowlist of public, unauthenticated, read-only JSON endpoints, matched as exact paths so an authenticated route can never pick it up by accident. This also makes `/status.json` readable cross-origin; CORS only ever grants access, so nothing that worked before is affected. The widget itself is progressive enhancement: it stays hidden unless the fetch succeeds, leaving the page unchanged when the API is unreachable. Its API base is a Jekyll config value so it can point at a local API during development (`make dev` in docs/). Co-Authored-By: Claude Opus 5 (1M context) --- docs/Makefile | 14 +- docs/_config.yml | 5 + docs/_config_dev.yml | 8 ++ docs/assets/css/spellbot.css | 161 +++++++++++++++++++++++ docs/index.html | 78 ++++++++++- src/spellbot/cogs/tasks_cog.py | 20 +++ src/spellbot/public_stats.py | 115 +++++++++++++++++ src/spellbot/services/queues.py | 4 + src/spellbot/settings.py | 1 + src/spellbot/web/api/queues.py | 2 +- src/spellbot/web/api/stats.py | 47 +++++++ src/spellbot/web/builder.py | 12 ++ tests/test_public_stats.py | 222 ++++++++++++++++++++++++++++++++ tests/web/test_stats.py | 108 ++++++++++++++++ 14 files changed, 794 insertions(+), 3 deletions(-) create mode 100644 docs/_config_dev.yml create mode 100644 src/spellbot/public_stats.py create mode 100644 src/spellbot/web/api/stats.py create mode 100644 tests/test_public_stats.py create mode 100644 tests/web/test_stats.py diff --git a/docs/Makefile b/docs/Makefile index 38cc7d27d..40452285c 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,7 +1,19 @@ -.PHONY: all run build +.PHONY: all run dev build all: run +# Serve with local development overrides so the live-stats widget points at a +# locally running API instead of production. See `_config_dev.yml`. +dev: + cd .. && npm ci && npm run docs:vendor + @command -v rbenv >/dev/null 2>&1 || brew install rbenv + rbenv install --skip-existing + eval "$$(rbenv init - zsh)" && \ + rbenv rehash && \ + (bundle update --bundler 2>/dev/null || true) && \ + bundle install && \ + bundle exec jekyll serve --config _config.yml,_config_dev.yml + run: cd .. && npm ci && npm run docs:vendor @command -v rbenv >/dev/null 2>&1 || brew install rbenv diff --git a/docs/_config.yml b/docs/_config.yml index c7a10a1ad..2f164c213 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -279,6 +279,11 @@ exclude: - screenshot.png - docs/ +# Base URL the live-stats widget on the home page fetches from. Overridden for +# local development by `_config_dev.yml` (see `make dev` in docs/Makefile), since +# the widget is cross-origin and must point at whichever API you are running. +spellbot_api: "https://queues.spellbot.io" + plugins: - jekyll-redirect-from - jekyll-sitemap diff --git a/docs/_config_dev.yml b/docs/_config_dev.yml new file mode 100644 index 000000000..f649ed5ad --- /dev/null +++ b/docs/_config_dev.yml @@ -0,0 +1,8 @@ +# Local development overrides, layered on top of `_config.yml`. +# Used by `make dev`; never part of a production build. +# +# The live-stats widget fetches cross-origin, so it has to point at whichever API +# you are running. Start one with `uv run spellbot -da` (serves on PORT, 3008 by +# default) and a bot with `uv run spellbot -dm` to populate the stats cache that +# `/stats.json` reads from. +spellbot_api: "http://localhost:3008" diff --git a/docs/assets/css/spellbot.css b/docs/assets/css/spellbot.css index 80cefab34..04d818a24 100644 --- a/docs/assets/css/spellbot.css +++ b/docs/assets/css/spellbot.css @@ -191,3 +191,164 @@ code { .social-icon { color: white; } + +/* Live activity widget (see the #live-stats block in index.html). + A KPI row of stat tiles rather than a chart: three independent headline counts + with no series, no trend and no axis, so there is nothing to plot. + + This site commits to a single dark palette (page-col #1a202c / text-col #fff in + _config.yml) and does not follow the OS setting, so there is deliberately no + prefers-color-scheme branch here. Every color below was checked for contrast + against #1a202c. + + The whole card is one click target, built with the "stretched link" pattern: + the card is positioned, and the single CTA anchor's ::after is stretched over + it. That keeps exactly one real in the markup, so middle-click, right-click + -> open in new tab, keyboard focus and screen-reader link semantics all behave + normally -- none of which survives a JS onclick handler on a
. */ +.live-stats { + position: relative; + max-width: 640px; + margin: 1.5rem auto 2rem; + padding: 1.25rem 1rem 1rem; + background-color: rgba(124, 92, 255, 0.07); + /* Solid rather than a low-opacity tint: at 55% over #1a202c this composites to + 2.02:1, under the 3:1 floor for non-text UI, which is what made the original + border hard to pick out. Solid #7c5cff is 3.75:1. */ + border: 1px solid #7c5cff; + border-radius: 12px; + text-align: center; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +/* The card is the one conversion target on the page, so it is the one element + that spends a lift on hover; everything around it stays flat. */ +.live-stats:hover { + border-color: #a892ff; + background-color: rgba(124, 92, 255, 0.13); + box-shadow: 0 8px 28px rgba(124, 92, 255, 0.28); + transform: translateY(-2px); +} + +/* Keyboard users get the same affordance: the inner anchor takes focus, the + card shows the ring. */ +.live-stats:focus-within { + border-color: #a892ff; + box-shadow: 0 0 0 3px rgba(168, 146, 255, 0.45); +} + +.live-stats:focus-within .live-stats-cta a, +.live-stats a:focus-visible { + outline: none; /* the ring is drawn on the card instead */ +} + +.live-stats-live { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.75rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #a0aec0; +} + +.live-stats-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background-color: #48bb78; + box-shadow: 0 0 0 0 rgba(72, 187, 120, 0.7); + animation: live-pulse 2.4s ease-out infinite; +} + +@keyframes live-pulse { + 70% { + box-shadow: 0 0 0 7px rgba(72, 187, 120, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(72, 187, 120, 0); + } +} + +.live-stats-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem 1.5rem; +} + +.live-stat { + flex: 1 1 120px; + padding: 0.25rem 0.5rem; +} + +.live-stat-value { + display: block; + font-size: 2.25rem; + font-weight: 600; + line-height: 1.1; + /* The brand purple (#5a3efd) is only 2.75:1 on this background, which fails + even the large-text floor. This is that hue lifted to 6.39:1. */ + color: #a892ff; + /* Deliberately NOT tabular-nums: at display sizes tabular figures pad every + digit to the width of a `0`, which makes short values look gappy. Tabular is + for columns of numbers that must align vertically. */ +} + +.live-stat-label { + display: block; + margin-top: 0.15rem; + font-size: 0.85rem; + color: var(--text-col); +} + +.live-stats-cta { + margin-top: 0.9rem; + margin-bottom: 0; + font-size: 1rem; +} + +/* Stretched to cover the card. `position: static` on the anchor itself keeps the + ::after anchored to .live-stats rather than to the link's own box. */ +.live-stats-cta a::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 12px; +} + +.live-stats-arrow { + display: inline-block; + transition: transform 0.2s ease; +} + +.live-stats:hover .live-stats-arrow { + transform: translateX(4px); +} + +.live-stats-updated { + margin-top: 0.35rem; + margin-bottom: 0; + font-size: 0.75rem; + color: #a0aec0; /* 7.23:1 -- muted but still AA at this small size */ +} + +@media (prefers-reduced-motion: reduce) { + .live-stats, + .live-stats-arrow { + transition: none; + } + .live-stats:hover { + transform: none; + } + .live-stats-dot { + animation: none; + } +} diff --git a/docs/index.html b/docs/index.html index 554fdf776..659a8b60a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -71,12 +71,88 @@ alt="/lfg" />

- Visit queues.spellbot.io, where you + Visit live queues, where you can log in with Discord to browse the games currently queuing across every server, filter them down to just the communities you play in, set up notifications so you're pinged when a game you care about is forming, and look back through your own game history and records.

+ + + + +
diff --git a/src/spellbot/cogs/tasks_cog.py b/src/spellbot/cogs/tasks_cog.py index f09c479c3..d911ffef3 100644 --- a/src/spellbot/cogs/tasks_cog.py +++ b/src/spellbot/cogs/tasks_cog.py @@ -11,6 +11,7 @@ from spellbot.actions import TasksAction from spellbot.environment import running_in_pytest +from spellbot.public_stats import update_public_stats from spellbot.settings import settings from spellbot.shard_status import update_shard_status @@ -88,6 +89,7 @@ def __init__(self, bot: SpellBot) -> None: self.cleanup_old_voice_channels.start() self.expire_inactive_games.start() self.notify_pending_games.start() + self.refresh_public_stats.start() # Start tasks that don't require discord.py ready signal self._shard_status_task = asyncio.create_task(run_shard_status_loop(bot)) @@ -147,6 +149,24 @@ async def notify_pending_games(self) -> None: async def before_notify_pending_games(self) -> None: await wait_until_ready(self.bot) + ############################################### + # Refresh cached public stats + ############################################### + # Runs in the bot process and writes to Redis; `/stats.json` in the web + # processes only ever reads that cached value, so traffic to the public + # marketing site never reaches the database. + @tasks.loop(minutes=settings.PUBLIC_STATS_LOOP_M) + async def refresh_public_stats(self) -> None: + try: + with tracer.trace(name="command", resource="refresh_public_stats"): + await update_public_stats() + except BaseException: # Catch EVERYTHING so tasks don't die + logger.exception("error: exception in task cog") + + @refresh_public_stats.before_loop + async def before_refresh_public_stats(self) -> None: + await wait_until_ready(self.bot) + async def setup(bot: SpellBot) -> None: # pragma: no cover await bot.add_cog(TasksCog(bot), guild=settings.GUILD_OBJECT) diff --git a/src/spellbot/public_stats.py b/src/spellbot/public_stats.py new file mode 100644 index 000000000..0e3b3654f --- /dev/null +++ b/src/spellbot/public_stats.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026 spellbot@lexicalunit.com + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from typing import Any + +from spellbot import services +from spellbot.database import db_session_manager +from spellbot.redis_client import get_redis + +from .settings import settings + +logger = logging.getLogger(__name__) + +# Redis key holding the most recently computed public stats. +PUBLIC_STATS_KEY = "public_stats" + +# Deliberately several times longer than the refresh interval. The task recomputes +# every `PUBLIC_STATS_LOOP_M` minutes, so a longer TTL means a transient failure +# (or a bot restart) shows slightly stale numbers instead of an empty widget. The +# payload carries `last_updated` so consumers can judge staleness themselves. +PUBLIC_STATS_TTL = 1800 + + +@dataclass +class PublicStats: + """Aggregate activity counts safe to expose publicly.""" + + active_servers: int + active_players: int + active_games: int + last_updated: str # ISO format timestamp + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PublicStats: + return cls( + active_servers=int(data["active_servers"]), + active_players=int(data["active_players"]), + active_games=int(data["active_games"]), + last_updated=str(data["last_updated"]), + ) + + +async def compute_public_stats() -> PublicStats: + """ + Compute current activity counts from the database. + + Counts are derived from the same service calls that back the queues page, so + the numbers shown on spellbot.io always agree with what a visitor sees after + clicking through. A player in a pending queue and a player seated in a game + that just started both count as active; started games are full, so their seat + count is the player count. + """ + async with db_session_manager(): + queues = await services.queues.public_active_queues() + games = await services.queues.public_active_games( + services.queues.STARTED_GAMES_WINDOW, + ) + + servers = {row["guild_xid"] for row in queues} | {game["guild_xid"] for game in games} + players = sum(row["players"] for row in queues) + sum(game["seats"] for game in games) + + return PublicStats( + active_servers=len(servers), + active_players=players, + active_games=len(queues) + len(games), + last_updated=datetime.now(tz=UTC).isoformat(), + ) + + +async def update_public_stats() -> None: + """Recompute public stats and write them to Redis.""" + if not settings.REDIS_URL: + logger.debug("REDIS_URL not configured, skipping public stats update") + return + + try: + stats = await compute_public_stats() + redis = await get_redis() + await redis.set(PUBLIC_STATS_KEY, json.dumps(stats.to_dict()), ex=PUBLIC_STATS_TTL) + except Exception: + # Never propagate: this runs on a task loop and a failed refresh should + # leave the last good value in place rather than kill the loop. + logger.warning("Failed to update public stats in Redis", exc_info=True) + else: + logger.debug("Updated public stats: %s", stats.to_dict()) + + +async def get_public_stats() -> PublicStats | None: + """ + Read the cached public stats from Redis. + + Returns `None` when Redis is unconfigured, empty, or unreachable. Callers are + expected to degrade gracefully rather than fall back to querying the database: + this endpoint exists specifically to keep marketing-site traffic off the DB. + """ + if not settings.REDIS_URL: + return None + + try: + redis = await get_redis() + raw = await redis.get(PUBLIC_STATS_KEY) + if not raw: + return None + return PublicStats.from_dict(json.loads(raw)) + except Exception: + logger.warning("Failed to get public stats from Redis", exc_info=True) + return None diff --git a/src/spellbot/services/queues.py b/src/spellbot/services/queues.py index 1b669c0b8..024d1aeb1 100644 --- a/src/spellbot/services/queues.py +++ b/src/spellbot/services/queues.py @@ -26,6 +26,10 @@ else_="Unknown", ).label("service") +# How far back a started game still counts as "active". Shared by the queues page, +# `/queues.json`, and the cached public stats so all three report the same games. +STARTED_GAMES_WINDOW = timedelta(hours=2) + async def public_active_games( within: timedelta, diff --git a/src/spellbot/settings.py b/src/spellbot/settings.py index a78bad2df..3de40e434 100644 --- a/src/spellbot/settings.py +++ b/src/spellbot/settings.py @@ -134,6 +134,7 @@ class Settings(BaseSettings): PATREON_TOKEN: str | None = None PATREON_CAMPAIGN: str | None = None PATREON_SYNC_LOOP_M: int = 60 + PUBLIC_STATS_LOOP_M: int = 1 # Girudo GIRUDO_BASE_URL: str = "https://game.girudo.com" diff --git a/src/spellbot/web/api/queues.py b/src/spellbot/web/api/queues.py index 37000f336..e4cb0ea08 100644 --- a/src/spellbot/web/api/queues.py +++ b/src/spellbot/web/api/queues.py @@ -24,7 +24,7 @@ SPELLBOT_DEFAULT_LOGO = "https://spellbot.io/assets/img/avatar-icon.png" ICON_FETCH_TTL = timedelta(hours=6) -STARTED_GAMES_WINDOW = timedelta(hours=2) +STARTED_GAMES_WINDOW = services.queues.STARTED_GAMES_WINDOW PLAYED_GUILDS_WINDOW = timedelta(days=365) _icon_fetch_attempts: dict[int, datetime] = {} diff --git a/src/spellbot/web/api/stats.py b/src/spellbot/web/api/stats.py new file mode 100644 index 000000000..1dd4de3ce --- /dev/null +++ b/src/spellbot/web/api/stats.py @@ -0,0 +1,47 @@ +# Copyright (c) 2026 spellbot@lexicalunit.com + +from __future__ import annotations + +import logging + +from aiohttp import web +from ddtrace.trace import tracer + +from spellbot.metrics import add_span_request_id, generate_request_id +from spellbot.public_stats import get_public_stats + +logger = logging.getLogger(__name__) + +routes = web.RouteTableDef() + +# Browser/CDN cache lifetime. The backing task refreshes on its own schedule, so +# serving a value up to a minute old costs nothing and keeps the static site from +# generating a request per visitor. +CACHE_MAX_AGE = 60 + + +@routes.get("/stats.json") +@tracer.wrap(name="web", resource="stats_json") +async def stats_json_endpoint(_: web.Request) -> web.Response: + """ + Return cached public activity counts as JSON. + + Reads only from Redis, never the database: this endpoint is called by the + static marketing site at spellbot.io, whose traffic should not translate into + database load. `update_public_stats()` refreshes the cache on a task loop. + + Responds 503 when no cached value is available so consumers can tell "no data + right now" apart from "genuinely zero activity". + """ + add_span_request_id(generate_request_id()) + stats = await get_public_stats() + if stats is None: + return web.json_response( + {"error": "stats unavailable"}, + status=503, + headers={"Cache-Control": "no-store"}, + ) + return web.json_response( + stats.to_dict(), + headers={"Cache-Control": f"public, max-age={CACHE_MAX_AGE}"}, + ) diff --git a/src/spellbot/web/builder.py b/src/spellbot/web/builder.py index 48e32fe60..7e84c6ff3 100644 --- a/src/spellbot/web/builder.py +++ b/src/spellbot/web/builder.py @@ -29,6 +29,7 @@ queues, record, rest, + stats, status, viewer_auth, ) @@ -42,8 +43,17 @@ TEMPLATES_ROOT = Path(__file__).resolve().parent / "templates" +# Endpoints readable cross-origin. Every one is public, unauthenticated and +# read-only, and is already served to anonymous visitors, so `*` exposes nothing +# new while keeping responses cacheable (an origin-specific header would force +# `Vary: Origin`). Matched as exact paths rather than by prefix so an +# authenticated route can never pick up CORS by accident -- in particular nothing +# under `/api` or `/admin` belongs here. +PUBLIC_CORS_PATHS = frozenset({"/stats.json", "/queues.json", "/status.json"}) + ALL_ROUTES = [ ping.routes, + stats.routes, status.routes, analytics.routes, record.routes, @@ -114,6 +124,8 @@ async def security_headers_middleware( response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if request.path in PUBLIC_CORS_PATHS: + response.headers["Access-Control-Allow-Origin"] = "*" return response diff --git a/tests/test_public_stats.py b/tests/test_public_stats.py new file mode 100644 index 000000000..a7f428a14 --- /dev/null +++ b/tests/test_public_stats.py @@ -0,0 +1,222 @@ +# Copyright (c) 2026 spellbot@lexicalunit.com + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from spellbot.public_stats import ( + PUBLIC_STATS_KEY, + PUBLIC_STATS_TTL, + PublicStats, + compute_public_stats, + get_public_stats, + update_public_stats, +) +from spellbot.settings import settings + +if TYPE_CHECKING: + from freezegun.api import FrozenDateTimeFactory + + from tests.fixtures import Factories + +NOW = datetime(2024, 6, 15, 12, 0, tzinfo=UTC) + + +@pytest.mark.asyncio +@pytest.mark.use_db +class TestComputePublicStats: + async def test_counts_are_zero_when_nothing_is_active(self) -> None: + stats = await compute_public_stats() + + assert stats.active_servers == 0 + assert stats.active_players == 0 + assert stats.active_games == 0 + + async def test_counts_players_and_servers_across_queues( + self, + factories: Factories, + freezer: FrozenDateTimeFactory, + ) -> None: + freezer.move_to(NOW) + guild = factories.guild.create(xid=990001, name="Guild One") + ch = factories.channel.create(xid=990101, name="lfg", guild=guild) + game = factories.game.create( + guild=guild, + channel=ch, + started_at=None, + created_at=NOW - timedelta(minutes=5), + seats=4, + ) + factories.post.create(guild=guild, channel=ch, game=game, message_xid=990201) + for xid in (881001, 881002, 881003): + user = factories.user.create(xid=xid, name=f"u{xid}") + factories.queue.create(user_xid=user.xid, game_id=game.id, og_guild_xid=guild.xid) + + stats = await compute_public_stats() + + assert stats.active_servers == 1 + assert stats.active_players == 3 + assert stats.active_games == 1 + + async def test_started_games_count_their_seats_as_players( + self, + factories: Factories, + freezer: FrozenDateTimeFactory, + ) -> None: + freezer.move_to(NOW) + guild = factories.guild.create(xid=990002, name="Guild Two") + ch = factories.channel.create(xid=990102, name="lfg", guild=guild) + game = factories.game.create( + guild=guild, + channel=ch, + started_at=NOW - timedelta(minutes=10), + created_at=NOW - timedelta(minutes=20), + seats=4, + ) + factories.post.create(guild=guild, channel=ch, game=game, message_xid=990202) + + stats = await compute_public_stats() + + # A started game is full, so its seat count is its player count. + assert stats.active_servers == 1 + assert stats.active_players == 4 + assert stats.active_games == 1 + + async def test_a_server_with_both_a_queue_and_a_game_counts_once( + self, + factories: Factories, + freezer: FrozenDateTimeFactory, + ) -> None: + freezer.move_to(NOW) + guild = factories.guild.create(xid=990003, name="Guild Three") + ch = factories.channel.create(xid=990103, name="lfg", guild=guild) + + pending = factories.game.create( + guild=guild, + channel=ch, + started_at=None, + created_at=NOW - timedelta(minutes=3), + seats=4, + ) + factories.post.create(guild=guild, channel=ch, game=pending, message_xid=990203) + user = factories.user.create(xid=881004, name="u881004") + factories.queue.create(user_xid=user.xid, game_id=pending.id, og_guild_xid=guild.xid) + + started = factories.game.create( + guild=guild, + channel=ch, + started_at=NOW - timedelta(minutes=10), + created_at=NOW - timedelta(minutes=20), + seats=4, + ) + factories.post.create(guild=guild, channel=ch, game=started, message_xid=990204) + + stats = await compute_public_stats() + + assert stats.active_servers == 1 # deduplicated + assert stats.active_players == 5 # 1 queued + 4 seated + assert stats.active_games == 2 + + +class TestPublicStatsSerialization: + def test_round_trips_through_a_dict(self) -> None: + stats = PublicStats( + active_servers=3, + active_players=12, + active_games=5, + last_updated="2024-06-15T12:00:00+00:00", + ) + + assert PublicStats.from_dict(stats.to_dict()) == stats + + +@pytest.mark.asyncio +class TestUpdatePublicStats: + async def test_does_nothing_without_redis_configured(self) -> None: + with ( + patch.object(settings, "REDIS_URL", None), + patch("spellbot.public_stats.get_redis", AsyncMock()) as get_redis, + ): + await update_public_stats() + + get_redis.assert_not_called() + + async def test_writes_computed_stats_with_a_ttl(self) -> None: + stats = PublicStats( + active_servers=2, + active_players=7, + active_games=4, + last_updated="2024-06-15T12:00:00+00:00", + ) + redis = AsyncMock() + + with ( + patch.object(settings, "REDIS_URL", "redis://localhost"), + patch("spellbot.public_stats.compute_public_stats", AsyncMock(return_value=stats)), + patch("spellbot.public_stats.get_redis", AsyncMock(return_value=redis)), + ): + await update_public_stats() + + redis.set.assert_awaited_once_with( + PUBLIC_STATS_KEY, + json.dumps(stats.to_dict()), + ex=PUBLIC_STATS_TTL, + ) + + async def test_swallows_errors_so_the_task_loop_survives(self) -> None: + with ( + patch.object(settings, "REDIS_URL", "redis://localhost"), + patch( + "spellbot.public_stats.compute_public_stats", + AsyncMock(side_effect=RuntimeError("boom")), + ), + ): + await update_public_stats() # must not raise + + +@pytest.mark.asyncio +class TestGetPublicStats: + async def test_returns_none_without_redis_configured(self) -> None: + with patch.object(settings, "REDIS_URL", None): + assert await get_public_stats() is None + + async def test_returns_none_when_nothing_is_cached(self) -> None: + redis = AsyncMock() + redis.get = AsyncMock(return_value=None) + + with ( + patch.object(settings, "REDIS_URL", "redis://localhost"), + patch("spellbot.public_stats.get_redis", AsyncMock(return_value=redis)), + ): + assert await get_public_stats() is None + + async def test_returns_the_cached_stats(self) -> None: + stats = PublicStats( + active_servers=2, + active_players=7, + active_games=4, + last_updated="2024-06-15T12:00:00+00:00", + ) + redis = AsyncMock() + redis.get = AsyncMock(return_value=json.dumps(stats.to_dict())) + + with ( + patch.object(settings, "REDIS_URL", "redis://localhost"), + patch("spellbot.public_stats.get_redis", AsyncMock(return_value=redis)), + ): + assert await get_public_stats() == stats + + async def test_returns_none_when_redis_errors(self) -> None: + with ( + patch.object(settings, "REDIS_URL", "redis://localhost"), + patch( + "spellbot.public_stats.get_redis", + AsyncMock(side_effect=RuntimeError("boom")), + ), + ): + assert await get_public_stats() is None diff --git a/tests/web/test_stats.py b/tests/web/test_stats.py new file mode 100644 index 000000000..87e7a5a40 --- /dev/null +++ b/tests/web/test_stats.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026 spellbot@lexicalunit.com + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from spellbot.public_stats import PublicStats +from spellbot.web.builder import PUBLIC_CORS_PATHS + +if TYPE_CHECKING: + from aiohttp import web + from aiohttp.test_utils import TestClient + + WebClient = TestClient[web.Request, web.Application] + +pytestmark = pytest.mark.use_db + +STATS = PublicStats( + active_servers=3, + active_players=12, + active_games=5, + last_updated="2024-06-15T12:00:00+00:00", +) + + +@pytest.mark.asyncio +class TestStatsJson: + async def test_serves_cached_stats(self, client: WebClient) -> None: + with patch( + "spellbot.web.api.stats.get_public_stats", + AsyncMock(return_value=STATS), + ): + resp = await client.get("/stats.json") + + assert resp.status == 200 + assert await resp.json() == STATS.to_dict() + assert resp.headers.get("Cache-Control") == "public, max-age=60" + + async def test_never_touches_the_database(self, client: WebClient) -> None: + """The whole point of the endpoint is to keep site traffic off the DB.""" + with ( + patch( + "spellbot.web.api.stats.get_public_stats", + AsyncMock(return_value=STATS), + ), + patch("spellbot.database.begin_session", AsyncMock()) as begin_session, + ): + resp = await client.get("/stats.json") + + assert resp.status == 200 + begin_session.assert_not_called() + + async def test_returns_503_when_no_stats_are_cached(self, client: WebClient) -> None: + with patch( + "spellbot.web.api.stats.get_public_stats", + AsyncMock(return_value=None), + ): + resp = await client.get("/stats.json") + + assert resp.status == 503 + assert await resp.json() == {"error": "stats unavailable"} + # A failure must not be cached, or the widget stays broken for a minute. + assert resp.headers.get("Cache-Control") == "no-store" + + +@pytest.mark.asyncio +class TestPublicCors: + async def test_stats_json_is_readable_cross_origin(self, client: WebClient) -> None: + with patch( + "spellbot.web.api.stats.get_public_stats", + AsyncMock(return_value=STATS), + ): + resp = await client.get("/stats.json", headers={"Origin": "https://spellbot.io"}) + + assert resp.headers.get("Access-Control-Allow-Origin") == "*" + + async def test_queues_json_is_readable_cross_origin(self, client: WebClient) -> None: + resp = await client.get("/queues.json", headers={"Origin": "https://spellbot.io"}) + + assert resp.status == 200 + assert resp.headers.get("Access-Control-Allow-Origin") == "*" + + async def test_status_json_is_readable_cross_origin(self, client: WebClient) -> None: + """`/status.json` was already public; CORS only widens who may read it.""" + resp = await client.get("/status.json", headers={"Origin": "https://example.com"}) + + assert resp.status == 200 + assert resp.headers.get("Access-Control-Allow-Origin") == "*" + + async def test_status_page_still_serves_normally(self, client: WebClient) -> None: + """Navigation is not a CORS operation, so the HTML page is unaffected.""" + resp = await client.get("/status") + + assert resp.status == 200 + + async def test_other_paths_are_not_cors_enabled(self, client: WebClient) -> None: + resp = await client.get("/health") + + assert resp.status == 200 + assert "Access-Control-Allow-Origin" not in resp.headers + + async def test_no_authenticated_prefix_is_ever_allowlisted(self) -> None: + """Guard against someone adding an authenticated route to the allowlist.""" + for path in PUBLIC_CORS_PATHS: + assert not path.startswith(("/api", "/admin")) From 2aeaf43c32109e4a494f8a6ebf4f1e6a3cbed1ed Mon Sep 17 00:00:00 2001 From: Amy Troschinetz Date: Thu, 10 Sep 2026 18:11:56 -0700 Subject: [PATCH 2/5] Make the live activity widget a single click target Reuse the amber -> purple gradient from `.screenshot` as the widget's border so it reads as part of this page rather than a bolted-on component. The purple stop is lifted from the brand #5a3efd to #7c5cff: at 2px on #1a202c the brand value is only 2.75:1, under the 3:1 floor for non-text UI, which is what made the border hard to pick out. The whole card is now one click target, using the stretched-link pattern rather than a click handler on the container. A single anchor's ::after is stretched over the positioned card, which keeps middle-click, right-click -> open in new tab, keyboard focus and screen-reader link semantics working -- none of which survives an onclick on a
. Add a live indicator and count up the numbers on load. Both are there to earn the click, but the indicator also states something true: the counts really are refreshed every minute. Every colour was measured against its real backdrop rather than eyeballed; text lands between 5.3:1 and 14.9:1 across both surface states. Keyboard focus draws a ring on the card, and prefers-reduced-motion drops the lift, the arrow, the pulse and the count-up. There is deliberately no prefers-color-scheme branch: this site commits to one dark palette and does not follow the OS setting. Co-Authored-By: Claude Opus 5 (1M context) --- docs/assets/css/spellbot.css | 98 +++++++++++++++++++----------------- docs/index.html | 67 +++++++++++++++++------- 2 files changed, 101 insertions(+), 64 deletions(-) diff --git a/docs/assets/css/spellbot.css b/docs/assets/css/spellbot.css index 04d818a24..1b35032d0 100644 --- a/docs/assets/css/spellbot.css +++ b/docs/assets/css/spellbot.css @@ -196,50 +196,57 @@ code { A KPI row of stat tiles rather than a chart: three independent headline counts with no series, no trend and no axis, so there is nothing to plot. + The amber -> purple gradient border is the site's own motif, borrowed from + `.screenshot` above, so the widget reads as part of this page rather than a + bolted-on component. The purple stop is lifted from the brand #5a3efd to + #7c5cff because at 2px on #1a202c the brand value is only 2.75:1, under the + 3:1 floor for non-text UI; #7c5cff is 3.75:1 and the amber end is 8.55:1. + This site commits to a single dark palette (page-col #1a202c / text-col #fff in _config.yml) and does not follow the OS setting, so there is deliberately no - prefers-color-scheme branch here. Every color below was checked for contrast - against #1a202c. + prefers-color-scheme branch. Every color was checked against its real backdrop. The whole card is one click target, built with the "stretched link" pattern: - the card is positioned, and the single CTA anchor's ::after is stretched over + the card is positioned and the single CTA anchor's ::after is stretched over it. That keeps exactly one real in the markup, so middle-click, right-click -> open in new tab, keyboard focus and screen-reader link semantics all behave normally -- none of which survives a JS onclick handler on a
. */ .live-stats { position: relative; max-width: 640px; - margin: 1.5rem auto 2rem; - padding: 1.25rem 1rem 1rem; - background-color: rgba(124, 92, 255, 0.07); - /* Solid rather than a low-opacity tint: at 55% over #1a202c this composites to - 2.02:1, under the 3:1 floor for non-text UI, which is what made the original - border hard to pick out. Solid #7c5cff is 3.75:1. */ - border: 1px solid #7c5cff; - border-radius: 12px; + margin: 2rem auto 2.25rem; + padding: 1.35rem 1.25rem 1.15rem; + border: 2px solid transparent; + border-radius: 14px; + /* The inner surface must be an opaque colour, not a tint: the border-box + gradient sits underneath and would otherwise show through the padding area. + #23253f is rgba(124, 92, 255, 0.09) pre-composited over the page. */ + background: + linear-gradient(#23253f, #23253f) padding-box, + linear-gradient(45deg, #f6ad4b, #7c5cff) border-box; text-align: center; cursor: pointer; transition: - border-color 0.2s ease, - background-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; + background 0.25s ease, + box-shadow 0.25s ease, + transform 0.25s ease; } -/* The card is the one conversion target on the page, so it is the one element - that spends a lift on hover; everything around it stays flat. */ +/* This card is the one conversion target on the page, so it is the one element + that spends a lift and a glow; everything around it stays flat. The offset + glow echoes the `10px 10px` shadow on `.screenshot`. */ .live-stats:hover { - border-color: #a892ff; - background-color: rgba(124, 92, 255, 0.13); - box-shadow: 0 8px 28px rgba(124, 92, 255, 0.28); - transform: translateY(-2px); + background: + linear-gradient(#2a2a4e, #2a2a4e) padding-box, + linear-gradient(45deg, #ffd160, #a892ff) border-box; + box-shadow: 0 10px 30px rgba(90, 62, 253, 0.35); + transform: translateY(-3px); } -/* Keyboard users get the same affordance: the inner anchor takes focus, the - card shows the ring. */ +/* Keyboard users get the same affordance: the inner anchor takes focus, the card + shows the ring. */ .live-stats:focus-within { - border-color: #a892ff; - box-shadow: 0 0 0 3px rgba(168, 146, 255, 0.45); + box-shadow: 0 0 0 3px rgba(255, 209, 96, 0.55); } .live-stats:focus-within .live-stats-cta a, @@ -250,11 +257,11 @@ code { .live-stats-live { display: inline-flex; align-items: center; - gap: 0.4rem; - margin-bottom: 0.75rem; + gap: 0.45rem; + margin-bottom: 0.85rem; font-size: 0.7rem; font-weight: 600; - letter-spacing: 0.09em; + letter-spacing: 0.1em; text-transform: uppercase; color: #a0aec0; } @@ -281,7 +288,7 @@ code { display: flex; flex-wrap: wrap; justify-content: center; - gap: 0.5rem 1.5rem; + gap: 0.5rem 1rem; } .live-stat { @@ -291,11 +298,11 @@ code { .live-stat-value { display: block; - font-size: 2.25rem; - font-weight: 600; + font-size: 2.5rem; + font-weight: 700; line-height: 1.1; - /* The brand purple (#5a3efd) is only 2.75:1 on this background, which fails - even the large-text floor. This is that hue lifted to 6.39:1. */ + /* The brand purple (#5a3efd) is only 2.75:1 on this surface, failing even the + large-text floor. This is that hue lifted to 5.84:1. */ color: #a892ff; /* Deliberately NOT tabular-nums: at display sizes tabular figures pad every digit to the width of a `0`, which makes short values look gappy. Tabular is @@ -304,40 +311,41 @@ code { .live-stat-label { display: block; - margin-top: 0.15rem; - font-size: 0.85rem; + margin-top: 0.2rem; + font-size: 0.8rem; + letter-spacing: 0.03em; color: var(--text-col); } .live-stats-cta { - margin-top: 0.9rem; + margin-top: 1.1rem; margin-bottom: 0; - font-size: 1rem; + font-size: 1.05rem; + color: #ffb342; } -/* Stretched to cover the card. `position: static` on the anchor itself keeps the - ::after anchored to .live-stats rather than to the link's own box. */ +/* Stretched to cover the card. */ .live-stats-cta a::after { content: ""; position: absolute; inset: 0; - border-radius: 12px; + border-radius: 14px; } .live-stats-arrow { display: inline-block; - transition: transform 0.2s ease; + transition: transform 0.25s ease; } .live-stats:hover .live-stats-arrow { - transform: translateX(4px); + transform: translateX(5px); } .live-stats-updated { - margin-top: 0.35rem; + margin-top: 0.4rem; margin-bottom: 0; - font-size: 0.75rem; - color: #a0aec0; /* 7.23:1 -- muted but still AA at this small size */ + font-size: 0.72rem; + color: #a0aec0; /* 6.61:1 -- muted but still AA at this small size */ } @media (prefers-reduced-motion: reduce) { diff --git a/docs/index.html b/docs/index.html index 659a8b60a..6ef8c6935 100644 --- a/docs/index.html +++ b/docs/index.html @@ -113,23 +113,50 @@ var el = document.getElementById("live-stats"); if (!el || !window.fetch) return; - function render(stats) { - // Compact only once the numbers get long; plain grouped digits read - // better at the sizes these are displayed at. - var fmt = new Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, + var reduceMotion = + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + // Compact only once the numbers get long; plain grouped digits read better + // at the sizes these are displayed at. + var compact = new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, + }); + + function format(value) { + return value < 10000 ? value.toLocaleString() : compact.format(value); + } + + // Counting up draws the eye to the numbers and reinforces that they are + // live rather than baked into the page. It is decoration, so it is skipped + // entirely when the visitor asks for reduced motion. + function countUp(node, target) { + if (reduceMotion || !window.requestAnimationFrame || target === 0) { + node.textContent = format(target); + return; + } + var DURATION = 900; + var started = null; + requestAnimationFrame(function step(now) { + if (started === null) started = now; + var progress = Math.min((now - started) / DURATION, 1); + var eased = 1 - Math.pow(1 - progress, 3); + node.textContent = format(Math.round(target * eased)); + if (progress < 1) requestAnimationFrame(step); }); - var fields = { - "live-stat-servers": stats.active_servers, - "live-stat-players": stats.active_players, - "live-stat-games": stats.active_games, - }; - for (var id in fields) { - var value = fields[id]; - if (typeof value !== "number") return false; - document.getElementById(id).textContent = - value < 10000 ? value.toLocaleString() : fmt.format(value); + } + + function render(stats) { + var fields = [ + ["live-stat-servers", stats.active_servers], + ["live-stat-players", stats.active_players], + ["live-stat-games", stats.active_games], + ]; + // Validate everything before touching the DOM, so a malformed payload + // leaves the widget hidden rather than half-populated. + for (var i = 0; i < fields.length; i++) { + if (typeof fields[i][1] !== "number") return false; } var updated = document.getElementById("live-stats-updated"); var when = Date.parse(stats.last_updated); @@ -137,6 +164,10 @@ updated.textContent = "Updated " + new Date(when).toLocaleTimeString(); } + el.hidden = false; + for (var j = 0; j < fields.length; j++) { + countUp(document.getElementById(fields[j][0]), fields[j][1]); + } return true; } @@ -145,9 +176,7 @@ if (!res.ok) throw new Error("stats unavailable"); return res.json(); }) - .then(function (stats) { - if (render(stats)) el.hidden = false; - }) + .then(render) .catch(function () { /* Leave the widget hidden; the rest of the page is unaffected. */ }); From a535f6f4fb34c8315070f1b788db19a96d05de61 Mon Sep 17 00:00:00 2001 From: Amy Troschinetz Date: Thu, 10 Sep 2026 18:41:19 -0700 Subject: [PATCH 3/5] Refresh the spellbot.io home page Restore the typography the page was already paying for: a `*` rule forced Verdana over everything with !important, so both Google fonts loaded and were then discarded. Reassign the theme's own --body-font/--header-font instead of overriding per element, so every rule in beautifuljekyll.css follows along. Headings are Outfit, running text Open Sans. Repeat the amber -> purple gradient from `.screenshot` as a rule under each section heading so the site's existing motif becomes the connective tissue between sections rather than a one-off on images. Rebuild the server directory as an auto-fill grid. The track floor is measured rather than guessed: the longest name renders 211px at `.small` and these names are joined with   by scripts/update_servers.py, so they cannot wrap and the tile has to be wide enough to hold them. Each tile is a two-row grid so every name starts at the same y and the labels line up across the grid. Turn the Help list into real numbered steps -- these are a sequence the reader works through in order, not decoration -- and make the Discord embed responsive. Add sections for the Playgroup Live and Castlog integrations, with copy taken from what the integrations actually do rather than from their marketing pages. Use "live queues" for the queues site throughout. Every colour was measured against the surface it actually sits on rather than eyeballed, and prefers-reduced-motion drops every transform and animation, including the avatar spin. Co-Authored-By: Claude Opus 5 (1M context) --- docs/_layouts/base.html | 2 +- docs/assets/css/spellbot.css | 392 +++++++++++++++++------ docs/assets/img/logos/castlog.svg | 1 + docs/assets/img/logos/playgroup-live.svg | 14 + docs/index.html | 38 ++- 5 files changed, 352 insertions(+), 95 deletions(-) create mode 100644 docs/assets/img/logos/castlog.svg create mode 100644 docs/assets/img/logos/playgroup-live.svg diff --git a/docs/_layouts/base.html b/docs/_layouts/base.html index 59959037c..94142bb0b 100644 --- a/docs/_layouts/base.html +++ b/docs/_layouts/base.html @@ -6,7 +6,7 @@ - "/assets/vendor/fontawesome/css/all.min.css" - "/assets/css/beautifuljekyll.css" common-ext-css: - - "https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic" + - "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" - "https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800" common-js: - "/assets/vendor/jquery/jquery.slim.min.js" diff --git a/docs/assets/css/spellbot.css b/docs/assets/css/spellbot.css index 1b35032d0..0a7d1f3ff 100644 --- a/docs/assets/css/spellbot.css +++ b/docs/assets/css/spellbot.css @@ -1,27 +1,141 @@ +/* SpellBot site styles. + + Design tokens. Every value is drawn from what the site already uses: the page + and text colours come from _config.yml, the amber/purple pair from the + `.screenshot` gradient below. The neutrals are biased slightly toward the + purple accent rather than being flat greys, so raised surfaces read as chosen + rather than defaulted. Contrast of each ink colour was measured against the + surface it actually sits on. */ +:root { + --sb-ground: #1a202c; + --sb-surface: #23253f; /* rgba(124, 92, 255, 0.09) pre-composited on ground */ + --sb-surface-hi: #2a2a4e; + --sb-ink-muted: #a0aec0; /* 7.2:1 on ground */ + --sb-amber: #ffb342; + --sb-amber-hi: #ffd160; + --sb-purple: #7c5cff; /* brand #5a3efd lifted to clear 3:1 on ground */ + --sb-purple-hi: #a892ff; + --sb-rule: linear-gradient(45deg, #f6ad4b, #7c5cff); + --sb-gap: 4rem; + + /* The theme's own variables, reassigned rather than overridden per-element, so + every rule in beautifuljekyll.css follows along. The previous `*` rule forced + Verdana over these with !important, so the page paid for two webfonts and + then discarded both. + + Outfit is a geometric sans with enough presence to carry display sizes; + Open Sans is humanist and stays out of the way in running text. Pairing a + geometric display face with a humanist text face keeps the headings + characterful without making body copy harder to read. Both are sans. */ + --body-font: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + --header-font: "Outfit", "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +body { + background-color: var(--sb-ground); +} + +/* --- Rhythm --------------------------------------------------------------- + One gap value between sections instead of per-element margins, so spacing + cannot silently collapse or double as content is added. */ +main section.level2 { + margin-block: var(--sb-gap); +} + +main section.level2 > h2 { + margin-bottom: 1.25rem; + font-weight: 700; + letter-spacing: -0.02em; + text-wrap: balance; +} + +/* The amber -> purple gradient is the site's signature (see `.screenshot`). + Repeating it as a short rule under every heading turns it into the page's + connective tissue rather than a one-off flourish. */ +main section.level2 > h2::after { + content: ""; + display: block; + width: 64px; + height: 3px; + margin-top: 0.6rem; + border-radius: 2px; + background: var(--sb-rule); +} + +main section.level2 > h2 img { + height: 1.1em; + width: auto; + vertical-align: -0.12em; +} + +/* Keep running text near a comfortable measure. */ +main section.level2 > p, +main section.level2 > ol, +main section.level2 > ul { + max-width: 68ch; + line-height: 1.7; +} + +/* --- Hero ---------------------------------------------------------------- */ +.add-to-discord { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem 1rem; + margin-block: 1.5rem 2.5rem; +} + .hero { - padding: 1rem; + padding: 0.5rem; text-align: center; } .add-bot { - background-color: rgb(90, 62, 253); - border-radius: 3px; + background: linear-gradient(45deg, #5a3efd, #7c5cff); + border-radius: 999px; border: none; color: rgb(255, 255, 255); cursor: pointer; - font-size: large; - font-weight: 200; + font-size: 1.05rem; + font-weight: 600; line-height: 1; - min-height: 32px; - padding: 10px 30px; + min-height: 44px; /* comfortable touch target */ + padding: 14px 32px; transition: all 0.3s ease-in-out; user-select: none; + box-shadow: 0 6px 20px rgba(90, 62, 253, 0.35); } .add-bot:hover { - filter: brightness(90%); + filter: brightness(110%); + transform: translateY(-2px); + box-shadow: 0 10px 28px rgba(90, 62, 253, 0.45); } +.patreon { + display: inline-block; + padding: 13px 26px; + border-radius: 999px; + font-size: 0.95rem; + font-weight: 600; +} + +.patreon, +.patreon:active, +.patreon:visited, +.patreon:hover { + background-color: rgb(255, 66, 77); + color: white; +} + +.patreon:hover { + background-color: #e1000d; + color: white; + transform: translateY(-2px); +} + +/* --- Media --------------------------------------------------------------- */ .screenshot { border-radius: 40px; background: linear-gradient(45deg, rgb(246, 173, 75), rgb(90, 62, 253)); @@ -30,73 +144,42 @@ display: block; margin-left: auto; margin-right: auto; + max-width: 100%; + height: auto; } -.avatar-img { - -webkit-animation: spin 60s linear infinite; - -moz-animation: spin 60s linear infinite; - animation: spin 60s linear infinite; -} -@-moz-keyframes spin { - 100% { - -moz-transform: rotate(360deg); - } -} -@-webkit-keyframes spin { - 100% { - -webkit-transform: rotate(360deg); - } -} -@keyframes spin { - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} - -p.note { - background: linear-gradient(45deg, rgba(246, 173, 75, 0.2), rgba(90, 62, 253, 0.2)); - border-radius: 1rem; - margin: 2rem 1rem; - padding: 0.5rem 1rem 0.5rem 3rem; -} - -p.note:before { - content: "💡"; - float: left; - margin-left: -2rem; +/* --- Links --------------------------------------------------------------- */ +a { + transition: all 0.3s ease-in-out; + text-decoration: none; } -.avatar-img-border { - box-shadow: none !important; - transition: all 0.3s ease-in-out; +a:hover { + text-decoration: none !important; } -.avatar-img-border:hover { - /* filter: brightness(150%); */ - -webkit-transform: scale(1.1); - -ms-transform: scale(1.1); - transform: scale(1.1); - transition: 0.7s ease; +/* Body links get an underline that grows on hover: colour alone is a weak + affordance in running text, and amber-on-dark reads as emphasis otherwise. */ +main section.level2 p:not(.live-stats-cta) a, +main section.level2 li a { + background-image: linear-gradient(var(--sb-amber-hi), var(--sb-amber-hi)); + background-repeat: no-repeat; + background-position: 0 100%; + background-size: 0% 1px; + transition: + background-size 0.25s ease, + color 0.25s ease; } -a { - transition: all 0.3s ease-in-out; - text-decoration: none; +main section.level2 p:not(.live-stats-cta) a:hover, +main section.level2 li a:hover { + background-size: 100% 1px; } footer { border-top: 0 !important; } -a:hover { - text-decoration: none !important; -} - -*:not(.fas):not(.fab) { - font-family: "Verdana", sans-serif !important; -} - .navbar-brand { display: none !important; } @@ -113,6 +196,28 @@ a:hover { code { font-size: 1em !important; background-color: transparent !important; + color: var(--sb-amber); +} + +/* --- Avatar / nav -------------------------------------------------------- */ +.avatar-img { + animation: spin 60s linear infinite; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + +.avatar-img-border { + box-shadow: none !important; + transition: all 0.3s ease-in-out; +} + +.avatar-img-border:hover { + transform: scale(1.1); + transition: 0.7s ease; } @keyframes fadeIn { @@ -128,25 +233,92 @@ code { animation: fadeIn ease-in 2s; } +/* --- Callout ------------------------------------------------------------- */ +p.note { + background: linear-gradient(45deg, rgba(246, 173, 75, 0.2), rgba(90, 62, 253, 0.2)); + border-radius: 1rem; + margin: 2rem 1rem; + padding: 0.5rem 1rem 0.5rem 3rem; +} + +p.note:before { + content: "💡"; + float: left; + margin-left: -2rem; +} + +/* --- Server directory ---------------------------------------------------- + An auto-fill grid rather than fixed-width flex children, so tiles keep even + gutters at any width and reflow to one column on a phone. + + The 240px floor is measured, not guessed: the longest name ("Comunidad Española + de cEDH") renders 211px wide at `.small`, and these names are joined with + ` ` by scripts/update_servers.py so they genuinely cannot wrap. 240px less + the 0.5rem side padding leaves a 224px content box, clearing 211px with slack. + Anything narrower and the label spills outside the tile's hover background. + This lands close to the 230px the original fixed-width tiles used, which was + presumably sized for the same reason. `min(..., 100%)` keeps the floor from + pushing the page wider than the viewport on a narrow screen. */ .where { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: flex-start; - flex-wrap: wrap; - justify-content: center; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); + gap: 0.5rem; + margin-block: 1.5rem; } + .where div { - padding: 10px; - width: 230px; + padding: 0; text-align: center; - vertical-align: middle; + min-width: 0; +} + +/* Two fixed rows rather than a flex column: the logo row is the same height in + every tile, so every name starts at the same y and the labels line up across + the whole grid regardless of how tall each logo is. */ +.where div a { + display: grid; + grid-template-rows: auto 1fr; + justify-items: center; + align-content: start; + gap: 0.6rem; + height: 100%; + min-width: 0; + padding: 0.9rem 0.5rem; + border: 1px solid transparent; + border-radius: 12px; + line-height: 1.3; + color: var(--text-col); + transition: + background-color 0.25s ease, + border-color 0.25s ease, + transform 0.25s ease; } + +/* The generated markup separates logo and name with a
, which would become a + third grid item and push the names out of alignment. The row gap handles the + break now. */ +.where div a br { + display: none; +} + +.where div a:hover { + background-color: var(--sb-surface); + border-color: var(--sb-purple); + transform: translateY(-3px); +} + +.where img { + width: 100%; + max-width: 150px; + height: auto; + aspect-ratio: 1; + object-fit: contain; + border-radius: 10px; +} + +/* Superseded by the tile lift above; the image no longer scales on its own. */ .where img:hover { - -webkit-transform: scale(1.05); - -ms-transform: scale(1.05); - transform: scale(1.05); - transition: 0.7s ease; + transform: none; } .small { @@ -154,31 +326,58 @@ code { text-wrap: nowrap; } -.support { - width: 350px; - margin-left: auto; - margin-right: auto; +/* --- Help steps ---------------------------------------------------------- + Numbered because these genuinely are a sequence the reader works through in + order, not because numerals decorate well. */ +ol.steps { + list-style: none; + padding-left: 0; + margin-block: 1.5rem; + display: grid; + gap: 0.75rem; + counter-reset: step; } -.patreon { - padding: 10px 20px; - border-radius: 20px; - font-size: 15px; +ol.steps li { + position: relative; + counter-increment: step; + padding: 0.9rem 1.1rem 0.9rem 3.25rem; + background-color: var(--sb-surface); + border-radius: 12px; + border-left: 3px solid var(--sb-purple); } -.patreon, -.patreon:active, -.patreon:visited, -.patreon:hover { - background-color: rgb(255, 66, 77); - color: white; +ol.steps li::before { + content: counter(step); + position: absolute; + left: 1rem; + top: 0.9rem; + width: 1.6rem; + height: 1.6rem; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--sb-rule); + color: #1a202c; + font-family: var(--body-font); + font-size: 0.85rem; + font-weight: 700; } -.patreon:hover { - background-color: #e1000d; - color: white; +/* --- Discord embed ------------------------------------------------------- */ +.support { + width: min(350px, 100%); + margin-left: auto; + margin-right: auto; +} + +.support iframe { + width: 100%; + max-width: 100%; + border-radius: 12px; } +/* --- Footer social ------------------------------------------------------- */ .fa-stack .fa-circle { color: rgb(90, 62, 253); } @@ -192,6 +391,17 @@ code { color: white; } +@media (prefers-reduced-motion: reduce) { + .avatar-img { + animation: none; + } + .add-bot:hover, + .patreon:hover, + .where div a:hover { + transform: none; + } +} + /* Live activity widget (see the #live-stats block in index.html). A KPI row of stat tiles rather than a chart: three independent headline counts with no series, no trend and no axis, so there is nothing to plot. diff --git a/docs/assets/img/logos/castlog.svg b/docs/assets/img/logos/castlog.svg new file mode 100644 index 000000000..f0b70cafe --- /dev/null +++ b/docs/assets/img/logos/castlog.svg @@ -0,0 +1 @@ +Castlog Logo diff --git a/docs/assets/img/logos/playgroup-live.svg b/docs/assets/img/logos/playgroup-live.svg new file mode 100644 index 000000000..3ebe7e316 --- /dev/null +++ b/docs/assets/img/logos/playgroup-live.svg @@ -0,0 +1,14 @@ + + logo-svg + + + + + + + + + + \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index 6ef8c6935..f17d1d6d6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -71,7 +71,7 @@ alt="/lfg" />

- Visit live queues, where you + Visit live queues, where you can log in with Discord to browse the games currently queuing across every server, filter them down to just the communities you play in, set up notifications so you're pinged when a game you care about is forming, and @@ -190,7 +190,7 @@

🔭 Where to Play?

SpellBot helps Discord servers to build communities around playing Magic online. Please check out the following servers to find games and connect with other players. You can also - see live games waiting for you + see live queues waiting for you to join!

@@ -245,6 +245,38 @@

+
+

+ + Playgroup Live +

+

+ SpellBot can also seat your games on + Playgroup Live, creating a + table for up to six players with starting life totals that match the format + you queued for. One player in the game needs to connect their account first: + run the /playgroup link command and SpellBot will find your + Playgroup Live account and remember it for future games. +

+
+ +
+

+ + Castlog +

+

+ SpellBot keeps a history of your games on + Castlog. When the result of a game is + reported, SpellBot forwards the match on and Castlog builds a page for it, + then links that page back to the game so you can find it later. Players who + have connected their Castlog account are credited on the match + automatically. Castlog's + SpellBot setup guide + walks through getting started. +

+
+

❓ Help

@@ -253,7 +285,7 @@

❓ Help

DMs that it sends and there are some settings you need to enable for this to work correctly.

-
    +
    1. In your Settings search for embeds and link previews and make sure that it's on. From 6ba07df9951c308b2563ae6b87d90b0948eee248 Mon Sep 17 00:00:00 2001 From: Amy Troschinetz Date: Thu, 10 Sep 2026 19:28:40 -0700 Subject: [PATCH 4/5] Update website --- README.md | 23 +- conf/servers.yaml | 82 ++-- docs/_config.yml | 1 + docs/_includes/discord-widget.html | 16 + docs/_includes/footer.html | 6 + docs/_includes/kofi.html | 15 + docs/_layouts/base.html | 2 + docs/_layouts/home.html | 15 + docs/assets/css/spellbot.css | 304 ++++++++++++--- docs/community.html | 57 +++ docs/features.html | 96 +++++ docs/getting-started.html | 83 ++++ docs/index.html | 598 +++++++++++------------------ scripts/update_servers.py | 41 +- 14 files changed, 854 insertions(+), 485 deletions(-) create mode 100644 docs/_includes/discord-widget.html create mode 100644 docs/_includes/kofi.html create mode 100644 docs/_layouts/home.html create mode 100644 docs/community.html create mode 100644 docs/features.html create mode 100644 docs/getting-started.html diff --git a/README.md b/README.md index 5b64538bd..1ec30f141 100644 --- a/README.md +++ b/README.md @@ -55,34 +55,29 @@ SpellBot helps Discord servers to build communities around playing Magic online. - - - - - - - + + - + - + - - - + + + - - + +
      PlayEDH
      PlayEDH
      Tolarian Community College
      Tolarian Community College
      cEDH
      cEDH
      Champ de Bataille
      Champ de Bataille
      Convoke
      Convoke
      EDH Fight Club
      EDH Fight Club
      Oath of the Gaywatch
      Oath of the Gaywatch
      Women+ In Magic
      Women+ In Magic
      Comunidad Española de cEDH
      Comunidad Española de cEDH
      cEDH UK
      cEDH UK
      PlayEDH
      PlayEDH
      Top Tier Bangers
      Top Tier Bangers
      Champ de Bataille
      Champ de Bataille
      Play to Win
      Play to Win
      EDH Tambayan
      EDH Tambayan
      The Commander Staple
      The Commander Staple
      Playing with Power
      Playing with Power
      The Commander Staple
      The Commander Staple
      Command the Cause
      Command the Cause
      Oath of the Gaywatch
      Oath of the Gaywatch
      EDH Tambayan
      EDH Tambayan
      Women+ In Magic
      Women+ In Magic
      Turbo Commander
      Turbo Commander
      cEDH UK
      cEDH UK
      Convoke
      Convoke
      MTG@Home
      MTG@Home
      Top Tier Bangers
      Top Tier Bangers
      diff --git a/conf/servers.yaml b/conf/servers.yaml index f16433515..6079046d3 100644 --- a/conf/servers.yaml +++ b/conf/servers.yaml @@ -1,7 +1,4 @@ servers: - - name: PlayEDH - logo: https://spellbot.io/assets/img/servers/playedh.png - url: https://www.playedh.com/ - name: Tolarian Community College logo: https://spellbot.io/assets/img/servers/tolarian-community-college.png url: https://www.patreon.com/tolariancommunitycollege @@ -9,62 +6,56 @@ servers: - name: cEDH logo: https://spellbot.io/assets/img/servers/cedh.png url: https://discord.com/invite/cedh - - name: Champ de Bataille - logo: https://spellbot.io/assets/img/servers/champ-de-bataille.png - url: https://discord.gg/3jwqduTkGZ - # - name: CriticalEDH - # logo: https://spellbot.io/assets/img/servers/criticaledh.png - # url: https://linktr.ee/CriticalEDH - - name: Convoke - logo: https://spellbot.io/assets/img/servers/convoke.png - url: https://www.convoke.games/ - name: EDH Fight Club logo: https://spellbot.io/assets/img/servers/edh-fight-club.png url: https://discord.com/invite/9Z7x8dh6Tf - - name: Oath of the Gaywatch - logo: https://spellbot.io/assets/img/servers/oath-of-the-gaywatch.png - url: https://disboard.org/server/757455940009328670 - small: true - - name: Women+ In Magic - logo: https://spellbot.io/assets/img/servers/women-in-magic.png - url: https://linktr.ee/women_in_magic - small: true - name: Comunidad Española de cEDH logo: https://spellbot.io/assets/img/servers/comunidad-espanola-de-cedh.png url: https://linktr.ee/cedhspain small: true - - name: Top Tier Bangers - logo: https://spellbot.io/assets/img/servers/top-tier-bangers.png - url: https://discord.gg/CfCb9fmgCD + - name: cEDH UK + logo: https://spellbot.io/assets/img/servers/cedh-uk.png + url: https://www.cedh.uk/ + - name: PlayEDH + logo: https://spellbot.io/assets/img/servers/playedh.png + url: https://www.playedh.com/ + - name: Champ de Bataille + logo: https://spellbot.io/assets/img/servers/champ-de-bataille.png + url: https://discord.gg/3jwqduTkGZ - name: Play to Win logo: https://spellbot.io/assets/img/servers/play-to-win.png url: https://www.playtowinmtg.com/ - - name: EDH Tambayan - logo: https://spellbot.io/assets/img/servers/edh-tambayan.png - url: https://www.facebook.com/EDHTambayan/ - - name: Playing with Power - logo: https://spellbot.io/assets/img/servers/playing-with-power.png - url: https://www.patreon.com/PlayingWithPowerMTG - name: The Commander Staple logo: https://spellbot.io/assets/img/servers/the-commander-staple.png url: https://discord.gg/commander small: true - - name: Command the Cause - logo: https://spellbot.io/assets/img/servers/command-the-cause.png - url: https://discord.gg/ZmPsjrxe4h + - name: Oath of the Gaywatch + logo: https://spellbot.io/assets/img/servers/oath-of-the-gaywatch.png + url: https://disboard.org/server/757455940009328670 + small: true + - name: EDH Tambayan + logo: https://spellbot.io/assets/img/servers/edh-tambayan.png + url: https://www.facebook.com/EDHTambayan/ + - name: Women+ In Magic + logo: https://spellbot.io/assets/img/servers/women-in-magic.png + url: https://linktr.ee/women_in_magic + small: true + - name: Convoke + logo: https://spellbot.io/assets/img/servers/convoke.png + url: https://www.convoke.games/ + - name: MTG@Home + logo: https://spellbot.io/assets/img/servers/mtg-at-home.png + url: https://discord.com/invite/mtg-home-689674672240984067 + - name: Top Tier Bangers + logo: https://spellbot.io/assets/img/servers/top-tier-bangers.png + url: https://discord.gg/CfCb9fmgCD + # - name: CriticalEDH + # logo: https://spellbot.io/assets/img/servers/criticaledh.png + # url: https://linktr.ee/CriticalEDH # - name: ka0s Tournaments # light_logo: https://github.com/lexicalunit/spellbot/assets/1903876/2f989560-b4c2-42e7-9708-0718913aecec # dark_logo: https://github.com/lexicalunit/spellbot/assets/1903876/104dc2da-4aad-4998-a778-479b54d1c600 # url: https://www.ka0stournaments.com/ - - name: Turbo Commander - logo: https://spellbot.io/assets/img/servers/turbo-commander.png - url: https://twitter.com/TurboDCommander - - name: cEDH UK - logo: https://spellbot.io/assets/img/servers/cedh-uk.png - url: https://www.cedh.uk/ - - name: MTG@Home - logo: https://spellbot.io/assets/img/servers/mtg-at-home.png - url: https://discord.com/invite/mtg-home-689674672240984067 # - name: Combat Step # logo: https://github.com/lexicalunit/spellbot/assets/1903876/cc420426-7d3c-4829-b963-a63c7d36a253 # url: https://discord.gg/Jkn5FpTASv @@ -107,3 +98,12 @@ servers: # - name: Proxy Pirates # logo: https://spellbot.io/assets/img/servers/proxy-pirates.png # url: https://discord.gg/bA5tf3Xc8M + # - name: Turbo Commander + # logo: https://spellbot.io/assets/img/servers/turbo-commander.png + # url: https://twitter.com/TurboDCommander + # - name: Command the Cause + # logo: https://spellbot.io/assets/img/servers/command-the-cause.png + # url: https://discord.gg/ZmPsjrxe4h + # - name: Playing with Power + # logo: https://spellbot.io/assets/img/servers/playing-with-power.png + # url: https://www.patreon.com/PlayingWithPowerMTG diff --git a/docs/_config.yml b/docs/_config.yml index 2f164c213..213a3e78c 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -25,6 +25,7 @@ navbar-links: Live: "https://queues.spellbot.io" Dev: "https://github.com/lexicalunit/spellbot" Status: "https://status.spellbot.io" + Help: "/getting-started/" ################ # --- Logo --- # diff --git a/docs/_includes/discord-widget.html b/docs/_includes/discord-widget.html new file mode 100644 index 000000000..54ceb4ead --- /dev/null +++ b/docs/_includes/discord-widget.html @@ -0,0 +1,16 @@ +{% comment %} + The SpellBot Discord server widget. Shared between the Getting Started and + Community pages so the server id and sandbox flags live in one place. +{% endcomment %} + +
      + +
      diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html index 9bfaf547a..a1e58ca0b 100644 --- a/docs/_includes/footer.html +++ b/docs/_includes/footer.html @@ -3,6 +3,12 @@
      {% include social-networks-links.html %} + {% if page.footer-extra %}
- -
diff --git a/scripts/update_servers.py b/scripts/update_servers.py index 822b395e3..792c32839 100755 --- a/scripts/update_servers.py +++ b/scripts/update_servers.py @@ -16,7 +16,12 @@ SRC_ROOT = Path(realpath(__file__)).parent.parent SERVERS_FILE = SRC_ROOT / "conf" / "servers.yaml" README_FILE = SRC_ROOT / "README.md" -INDEX_FILE = SRC_ROOT / "docs" / "index.html" +COMMUNITY_FILE = SRC_ROOT / "docs" / "community.html" +LANDING_FILE = SRC_ROOT / "docs" / "index.html" + +# The landing page shows a short strip of logos as a taster; the full directory +# lives on the community page. +LANDING_SERVER_COUNT = 6 class Server(TypedDict): @@ -71,12 +76,12 @@ def update_readme(servers: list[Server]) -> None: f.write(rhs) -def update_index(servers: list[Server]) -> None: - with INDEX_FILE.open() as f: +def update_community(servers: list[Server]) -> None: + with COMMUNITY_FILE.open() as f: index_text = f.read() lhs = index_text.split("")[0] rhs = index_text.split("")[1] - with INDEX_FILE.open("w") as f: + with COMMUNITY_FILE.open("w") as f: f.write(lhs) f.write("\n") f.write('
\n') @@ -103,9 +108,35 @@ def update_index(servers: list[Server]) -> None: f.write(rhs) +def update_landing(servers: list[Server]) -> None: + with LANDING_FILE.open() as f: + landing_text = f.read() + lhs = landing_text.split("")[0] + rhs = landing_text.split("")[1] + with LANDING_FILE.open("w") as f: + f.write(lhs) + f.write("\n") + f.write('
\n') + for server in servers[:LANDING_SERVER_COUNT]: + logo = server.get("logo") or server.get("light_logo") + assert logo is not None + name = server["name"] + url = server["url"] + f.write( + " " + f'' + f'{name}' + "\n", + ) + f.write("
\n") + f.write(" ") + f.write(rhs) + + if __name__ == "__main__": with SERVERS_FILE.open() as f: servers_data = yaml.safe_load(f) servers = servers_data["servers"] update_readme(servers) - update_index(servers) + update_community(servers) + update_landing(servers) From 0184297982ce721919f51a7816a74c7e52b20fa7 Mon Sep 17 00:00:00 2001 From: Amy Troschinetz Date: Thu, 10 Sep 2026 19:31:57 -0700 Subject: [PATCH 5/5] Update readme --- COMMUNITY.md | 64 ++++++++++++++++++++++++++++ FEATURES.md | 56 +++++++++++++++++++++++++ GETTING_STARTED.md | 54 ++++++++++++++++++++++++ README.md | 88 ++++++++++----------------------------- scripts/update_servers.py | 20 ++++----- 5 files changed, 205 insertions(+), 77 deletions(-) create mode 100644 COMMUNITY.md create mode 100644 FEATURES.md create mode 100644 GETTING_STARTED.md diff --git a/COMMUNITY.md b/COMMUNITY.md new file mode 100644 index 000000000..ce87a25e6 --- /dev/null +++ b/COMMUNITY.md @@ -0,0 +1,64 @@ +# Community + +Where to play, and how to help. + +## 🔭 Where to Play? + +SpellBot helps Discord servers to build communities around playing Magic online. Please check out the following servers to find games and connect with other players. You can also see [live queues][queues] waiting for you to join! + + + +Want your community to be featured here as well? Please contact me at [spellbot@lexicalunit.com](mailto:spellbot@lexicalunit.com)! + +## 🎤 Feedback + +Thoughts and suggestions? Come join us on the [SpellBot Discord server][discord-invite]! Please also feel free to [directly report any bugs][issues] that you encounter. Or reach out to me on BlueSky at [@spellbot.io][follow]. + +## 🙌 Supported By + +The continued operation of SpellBot is supported by [PlayEDH](https://www.playedh.com/) as well as generous donations from [my patrons on Patreon][patreon] and [Ko-fi][kofi]. If you would like to help support SpellBot, please consider [signing up][patreon] for as little as _one dollar a month_ or [giving me a one-off tip][kofi] for whatever you feel is appropriate. + +## ❤️ Contributing + +If you'd like to become a part of the SpellBot development community please first know that we have a documented [code of conduct](CODE_OF_CONDUCT.md) and then see our [documentation on how to contribute](CONTRIBUTING.md) for details on how to get started. + +--- + +[Getting Started](GETTING_STARTED.md) · [Features](FEATURES.md) · [Back to README](README.md) + +[discord-invite]: https://discord.gg/HuzTQYpYH4 +[follow]: https://bsky.app/profile/spellbot.io +[issues]: https://github.com/lexicalunit/spellbot/issues +[kofi]: https://ko-fi.com/lexicalunit +[patreon]: https://www.patreon.com/lexicalunit +[queues]: https://queues.spellbot.io diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 000000000..c524a24ed --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,56 @@ +# Features + +What SpellBot does for your playgroup. + +## 🤝 Matchmaking + +`/lfg` posts a game other players can join, and SpellBot keeps track of the seats. When the game fills it creates the table on the service your server plays on — [Convoke][convoke], [Playgroup Live][playgroup], [EDHLAB][edhlab], [Girudo][girudo] or [Table Stream][tablestream] — and shares the link with everyone who joined. + +

+ The embed SpellBot posts after running the /lfg command +

+ +Games can be filtered by format and bracket, players can block others they'd rather not be matched with, and admins can set per-channel defaults. See [Administration](ADMINISTRATION.md) for the full list. + +## 📡 Live queues + +[Live queues][queues] shows every game forming across every SpellBot community at once. Log in with Discord to filter down to just the servers you play in, set up notifications so you're pinged when a game you care about is forming, and look back through your own game history and records. + +## 📊 Mythic Track + +SpellBot integrates seamlessly with [Mythic Track](https://www.mythictrack.com/spellbot) which allows you to track games within your Discord server. Visualize and explore your data to reveal interesting trends. To get started run the `/setup_mythic_track` command on your server. Please also consider [supporting Mythic Track](https://www.patreon.com/MythicTrack)! + +

+ Running the /setup_mythic_track command in Discord +

+ +## 🎲 Playgroup Live + +SpellBot can also seat your games on [Playgroup Live](https://playgroup.gg/playgroup-live), creating a table for up to six players with starting life totals that match the format you queued for. One player in the game needs to connect their account first: run the `/playgroup link` command and SpellBot will find your Playgroup Live account and remember it for future games. + +## 🎥 Castlog + +SpellBot keeps a history of your games on [Castlog](https://castlog.gg/). When the result of a game is reported, SpellBot forwards the match on and Castlog builds a page for it, then links that page back to the game so you can find it later. Players who have connected their Castlog account are credited on the match automatically. Castlog's [SpellBot setup guide](https://castlog.gg/features/spellbot) walks through getting started. + +## 🔌 Adding a service + +Want SpellBot to support another play service? [Integrations](INTEGRATIONS.md) documents everything needed to add one. + +--- + +[Getting Started](GETTING_STARTED.md) · [Community](COMMUNITY.md) · [Back to README](README.md) + +[convoke]: https://www.convoke.games/ +[edhlab]: https://edhlab.gg/ +[girudo]: https://www.girudo.com/ +[playgroup]: https://playgroup.gg/ +[queues]: https://queues.spellbot.io +[tablestream]: https://table-stream.com/ diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 000000000..e3f2b6100 --- /dev/null +++ b/GETTING_STARTED.md @@ -0,0 +1,54 @@ +# Getting Started + +Add SpellBot to your Discord server and run your first game. + +## 🤖 Add SpellBot to Discord + +SpellBot is free and works in any Discord server you manage. Adding it takes a minute and needs no configuration to get going. + +
+ +Add to Discord + +
+ +## 🃏 Run your first game + +Run `/lfg` in any channel and SpellBot posts a game other players can join. When the last seat fills, it creates the table on the service your server plays on and shares the link with everyone who joined. + +

+ The embed SpellBot posts after running the /lfg command +

+ +Once a game is forming you can watch it fill from [live queues][queues], where you can log in with Discord to browse games across every server, filter them down to just the communities you play in, set up notifications so you're pinged when a game you care about is forming, and look back through your own game history and records. + +## 📬 Let SpellBot message you + +The most common issue people have is not receiving Direct Messages from the bot. SpellBot uses Discord embeds in the DMs it sends, and there are a few settings you need to enable for that to work. + +1. In your Settings search for **embeds and link previews** and make sure that it's on. +2. For each Server you will want to make sure that you have allowed Direct Messages from those server members. Search your Settings for **Direct Messages** and turn the option on. +3. You may also need to enable message requests from server members you may not know. Search your Settings for **message requests** and turn that option on as well. + +## ⚙️ Configure your server + +Server admins can tune how SpellBot behaves per channel: which service games are created on, default seat counts, formats, motd, and more. See [Administration](ADMINISTRATION.md) for the full set of commands and settings. + +## 🛟 Still stuck? + +Join us on the [SpellBot Discord server][discord-invite] to get answers from our generous community. Wondering whether the bot is online? Check the [status page][status] for current health. + +--- + +[Features](FEATURES.md) · [Community](COMMUNITY.md) · [Back to README](README.md) + +[discord-invite]: https://discord.gg/HuzTQYpYH4 +[queues]: https://queues.spellbot.io +[status]: https://status.spellbot.io diff --git a/README.md b/README.md index 1ec30f141..9458162f4 100644 --- a/README.md +++ b/README.md @@ -45,86 +45,41 @@ SpellBot helps you find _Magic: The Gathering_ games on [Convoke][convoke], [Gir />

-Visit **[queues.spellbot.io][queues]**, where you can log in with Discord to browse the games currently queuing across every server, filter them down to just the communities you play in, set up notifications so you're pinged when a game you care about is forming, and look back through your own game history and records. +Visit [live queues][queues], where you can log in with Discord to browse the games currently queuing across every server, filter them down to just the communities you play in, set up notifications so you're pinged when a game you care about is forming, and look back through your own game history and records. -## 🔭 Where to Play? +## ✨ Everything you need to get started -SpellBot helps Discord servers to build communities around playing Magic online. Please check out the following servers to find games and connect with other players. You can also **[see live games][queues]** waiting for you to join! +- **One command to queue.** `/lfg` puts you in the queue. When the seats fill, SpellBot creates the table on the service your server plays on and posts the link, so nobody is left coordinating in chat. +- **Every server in one place.** [Browse games][queues] forming across every SpellBot community at once, filter to the servers you actually play in, and get pinged when a game you care about starts coming together. +- **Your games, on the record.** Look back through your own [history and records][queues], and keep a fuller picture of every match with the Mythic Track, Castlog and Playgroup Live integrations. - - -Want your community to be featured here as well? Please contact me at [spellbot@lexicalunit.com](mailto:spellbot@lexicalunit.com)! - -## 📊 Mythic Track - -SpellBot integrates seamlessly with [Mythic Track](https://www.mythictrack.com/spellbot) which allows you to track games within your Discord server. Visualize and explore your data to reveal interesting trends. To get started run the `/setup_mythic_track` command on your server. Please also consider [supporting Mythic Track](https://www.patreon.com/MythicTrack)! - -

- Mythic Track Setup -

+## 📚 Documentation -## ❓ Help +| | | +| :------- | :------- | +| [Getting Started](GETTING_STARTED.md) | Add the bot, run your first game, fix Direct Messages | +| [Features](FEATURES.md) | Matchmaking, live queues, and the Mythic Track, Playgroup Live and Castlog integrations | +| [Community](COMMUNITY.md) | Where to play, feedback, supporters, and contributing | +| [Administration](ADMINISTRATION.md) | Per-server and per-channel configuration commands | +| [Contributing](CONTRIBUTING.md) | Development setup and how to submit changes | +| [Integrations](INTEGRATIONS.md) | Adding support for a new play service | +| [API](API.md) | The public REST API | +| [Database](DATABASE.md) | Schema and migrations | +| [Docker](DOCKER.md) | Running SpellBot in a container | +| [Security](SECURITY.md) | Reporting a vulnerability | -The most common issue people have when using SpellBot is related to receiving Direct Messages from the bot. SpellBot uses Discord embeds in the DMs that it sends and there are some settings you need to enable for this to work correctly. - -1. In your Settings search for **embeds and link previews** and make sure that it's on. -2. For each Server you will want to make sure that you have allowed Direct Messages from those server members. Search your Settings for **Direct Messages** and turn the option on. -3. You may also need to enable message requests from server members you may not know. Search your Settings for **message requests** and turn that option on as well. - -If you have more questions, please don't hesitate to join us on the [SpellBot Discord server][discord-invite] to get answers from our generous community. - -Wondering if the bot is online? Check the [status page][status] for current health. - -## 🎤 Feedback +## 🐳 Docker Support -Thoughts and suggestions? Come join us on the [SpellBot Discord server][discord-invite]! Please also feel free to [directly report any bugs][issues] that you encounter. Or reach out to me on BlueSky at [@spellbot.io][follow]. +SpellBot can be run via docker. Our image is published to [lexicalunit/spellbot][docker-hub]. See [our documentation on Docker Support](DOCKER.md) for help with installing and using it. ## 🙌 Supported By -The continued operation of SpellBot is supported by PlayEDH as well as generous donations from [my patrons on Patreon][patreon] and [Ko-fi][kofi]. If you would like to help support SpellBot, please consider [signing up][patreon] for as little as _one dollar a month_ or [giving me a one-off tip][kofi] for whatever you feel is appropriate. +The continued operation of SpellBot is supported by [PlayEDH](https://www.playedh.com/) as well as generous donations from [my patrons on Patreon][patreon] and [Ko-fi][kofi]. If you would like to help support SpellBot, please consider [signing up][patreon] for as little as _one dollar a month_ or [giving me a one-off tip][kofi] for whatever you feel is appropriate. ## ❤️ Contributing If you'd like to become a part of the SpellBot development community please first know that we have a documented [code of conduct](CODE_OF_CONDUCT.md) and then see our [documentation on how to contribute](CONTRIBUTING.md) for details on how to get started. -## 🐳 Docker Support - -SpellBot can be run via docker. Our image is published to [lexicalunit/spellbot][docker-hub]. See [our documentation on Docker Support](DOCKER.md) for help with installing and using it. - ## 🔍 Fine-print Any usage of SpellBot implies that you accept the following policies. @@ -158,7 +113,6 @@ Any usage of SpellBot implies that you accept the following policies. [ganalytics-badge]: https://img.shields.io/badge/analytics-google-orange.svg [ganalytics]: https://analytics.google.com/analytics/web/ [girudo]: https://www.girudo.com/ -[issues]: https://github.com/lexicalunit/spellbot/issues [kofi-button]: https://img.shields.io/badge/Ko--fi-F16061?style=flat&logo=ko-fi&logoColor=white [kofi]: https://ko-fi.com/lexicalunit [lexicalunit]: http://github.com/lexicalunit diff --git a/scripts/update_servers.py b/scripts/update_servers.py index 792c32839..876e7dac7 100755 --- a/scripts/update_servers.py +++ b/scripts/update_servers.py @@ -15,8 +15,8 @@ SRC_ROOT = Path(realpath(__file__)).parent.parent SERVERS_FILE = SRC_ROOT / "conf" / "servers.yaml" -README_FILE = SRC_ROOT / "README.md" -COMMUNITY_FILE = SRC_ROOT / "docs" / "community.html" +COMMUNITY_MD_FILE = SRC_ROOT / "COMMUNITY.md" +COMMUNITY_HTML_FILE = SRC_ROOT / "docs" / "community.html" LANDING_FILE = SRC_ROOT / "docs" / "index.html" # The landing page shows a short strip of logos as a taster; the full directory @@ -43,12 +43,12 @@ def batched[T](iterable: Iterable[T], n: int) -> Generator[Sequence[T]]: yield batch -def update_readme(servers: list[Server]) -> None: - with README_FILE.open() as f: +def update_community_md(servers: list[Server]) -> None: + with COMMUNITY_MD_FILE.open() as f: readme_text = f.read() lhs = readme_text.split("")[0] rhs = readme_text.split("")[1] - with README_FILE.open("w") as f: + with COMMUNITY_MD_FILE.open("w") as f: f.write(lhs) f.write("\n") f.write("\n") @@ -76,12 +76,12 @@ def update_readme(servers: list[Server]) -> None: f.write(rhs) -def update_community(servers: list[Server]) -> None: - with COMMUNITY_FILE.open() as f: +def update_community_html(servers: list[Server]) -> None: + with COMMUNITY_HTML_FILE.open() as f: index_text = f.read() lhs = index_text.split("")[0] rhs = index_text.split("")[1] - with COMMUNITY_FILE.open("w") as f: + with COMMUNITY_HTML_FILE.open("w") as f: f.write(lhs) f.write("\n") f.write('
\n') @@ -137,6 +137,6 @@ def update_landing(servers: list[Server]) -> None: with SERVERS_FILE.open() as f: servers_data = yaml.safe_load(f) servers = servers_data["servers"] - update_readme(servers) - update_community(servers) + update_community_md(servers) + update_community_html(servers) update_landing(servers)