diff --git a/README.md b/README.md index 0b8e1a9..85686f5 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,21 @@ All commands live under one group, `matr`, invoked with a per-guild prefix each with a random SFW anime GIF via the [Fluxpoint API](https://docs.fluxpoint.dev/api/endpoints/sfw-anime-gifs) (like the original MarriageBot). +- **Drifting ship percentages** — `ship @a @b` is deterministic for a + pair at a given moment, but wanders smoothly over ~24h instead of being + pinned forever, and busy channels nudge it: the score includes the + rolling count of channel messages over the last 10 minutes (a small, + bounded in-memory window fed by every observed message, bots included). - **Bot-directed quips** — aiming an action at Matrimony itself appends a rotating one-liner as Discord subtext (a `-# ` line) under the normal reply, and proposals at the bot are declined outright ("sorry, taken by discord.py"). +- **Unknown-action quips** — `!matr blorp` (or any nonexistent action) + gets a rotating funny quip plus the help embed, never silence. The + bot's own messages never trigger it. +- **Marital wisdom** — `!matr help` (and bare `!matr`) ends with a long + list of quips about memes, Discord culture, programming, and + relationships, below the full command listing. - **Slash commands** — every command is a discord.py hybrid, so `/matr` mirrors `!matr` exactly: `/matr marry`, `/matr fun hug`, `/matr admin forcemarry`, `/matr meta stats`. @@ -97,7 +108,7 @@ explicitly supports bot-authored commands and bot targets: | `!matr forceadopt @parent @child` | Force adoption (Manage Server) | | `!matr forceemancipate @user` | Force-remove a parent (Manage Server) | | `!matr hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`/`tickle`/`highfive`/`handhold`/`slap`/`punch`/`bite`/`stab @user` | Flavour actions (+ GIF) | -| `!matr ship @a [@b]` | Deterministic ship percentage (+ GIF) | +| `!matr ship @a [@b]` | Drifting ship percentage (pair + time + channel chatter; + GIF) | | `!matr flirt @user` (alias `rizz`) | Terrible pickup line (+ GIF) | | `!matr date @user` (alias `dateidea`) | Terrible date idea (+ GIF) | | `!matr anniversary [@partner]` (alias `anniv`) | Time married (+ GIF) | diff --git a/matrimony/bot.py b/matrimony/bot.py index b3a04fc..2aa6702 100644 --- a/matrimony/bot.py +++ b/matrimony/bot.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time import discord from discord.ext import commands @@ -10,6 +11,7 @@ from .config import Config from .family import FamilyView from .gifs import FluxpointClient +from .ship import ChannelActivity from .store import Store log = logging.getLogger("matrimony") @@ -39,6 +41,8 @@ def __init__(self, config: Config, store: Store | None = None) -> None: self.store = store or Store(config.database_path) self.family = FamilyView(self.store) self.gifs = FluxpointClient(config.fluxpoint_api_key) + # Named channel_activity: ``Client.activity`` is the presence field. + self.channel_activity = ChannelActivity() # ------------------------------------------------------------- prefixes @@ -88,6 +92,11 @@ async def process_commands(self, message: discord.Message) -> None: await self.invoke(ctx) async def on_message(self, message: discord.Message) -> None: + # Every observed message feeds the per-channel activity window + # (human and bot authors alike, including our own replies) — it + # powers the channel-busyness nudge in `matr ship`. Dispatching is + # still governed by ``should_process``. + self.channel_activity.note(message.channel.id, time.time()) if not self.should_process(message): return await self.process_commands(message) diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py index 987e473..357f95c 100644 --- a/matrimony/cogs/matr.py +++ b/matrimony/cogs/matr.py @@ -7,7 +7,6 @@ from __future__ import annotations -import hashlib import itertools import random import time @@ -18,6 +17,7 @@ from .. import __version__ from ..bot import MatrimonyBot +from ..ship import ship_score from ..store import Notice, Proposal GREEN = discord.Color(0x2ECC71) @@ -207,13 +207,29 @@ async def _post( ], } +# Replies for `!matr `. +_UNKNOWN_QUIPS = [ + "unknown action - the council of spouses will review it", + "that's not a real command, but points for confidence", + "close, but no matrimony", + "that command is in another castle", + "404: action not found (your marriage is still valid)", + "unknown action; the family tree remains unbothered", + "the action you typed does not spark joy", + "try `help` - it loves you unconditionally", + "that isn't a thing, and honestly? respect for trying", + "you misspelled it so confidently we're letting it slide", +] + +_QUIP_SETS: dict[str, list[str]] = {**_BOT_QUIPS, "unknown": _UNKNOWN_QUIPS} + _QUIP_CYCLES: dict[str, itertools.cycle] = {} def _quip(kind: str) -> str: """Next quip in the rotation for ``kind``.""" if kind not in _QUIP_CYCLES: - _QUIP_CYCLES[kind] = itertools.cycle(_BOT_QUIPS[kind]) + _QUIP_CYCLES[kind] = itertools.cycle(_QUIP_SETS[kind]) return next(_QUIP_CYCLES[kind]) @@ -531,7 +547,43 @@ async def _pick_answerable( return items[0] +# Slightly unhinged wisdom appended to the bottom of the help embed. +_HELP_QUIPS = [ + "it's not a bug, it's an unmarried feature", + "your family tree has merge conflicts", + "touch grass? the tree command *is* grass", + "polygamy is just concurrency with worse consistency guarantees", + "till deadlock do us part", + "my love language is ACID compliance", + "a ship percentage is a metaphor, not a measurement", + "the real family was the friends we adopted along the way", + "marriage: a distributed system with no rollback", + "works on my machine, married on yours", + "have you tried divorcing it and marrying it again?", + "you had me at `if __name__ == '__main__'`", + "single? that's just a null pointer waiting to be dereferenced", + "skill issue? more like ship issue", + "discord kitten not included", + "ratio'd by your own family tree", + "the family tree is technically a DAG - do not ask", + "segfault in my heart, core dumped in yours", + "10/10 would propose again - sponsored by SQLite", + "emotional damage mitigated: spouse buff applied", + "shipping yourself with the bot voids the warranty", + "loading family tree... 99% (the last 1% is a git rebase)", + "you can't spell 'discord' without 'cord' - they're married now", + "in this economy `!matr marry` is still free", + "the matchmaking algorithm is vibes-based and union-approved", + "seven marriages in, zero conflicts - impressively rebased", + "our lawyers recommend `!matr help` twice daily", + "here be dragons (they're in a committed polycule)", + "marriage is just pair programming without the programming", + "pov: you typed `!matr blorp` expecting wisdom - close enough", +] + + def _help_embed(prefix: str, cmd: str) -> discord.Embed: + quips = "\n".join(f"\u00b7 {q}" for q in _HELP_QUIPS) return discord.Embed( title="Matrimony", description=( @@ -565,7 +617,8 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: "Family trees are global across servers, and bot accounts can " "use commands and be married or adopted. Users may have multiple " "partners; existing partners are notified of each new marriage " - "and may acknowledge it or divorce on the spot." + "and may acknowledge it or divorce on the spot.\n\n" + f"**Marital wisdom**\n{quips}" ), colour=GOLD, ) @@ -583,8 +636,23 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: description="Matrimony family-tree commands.", ) async def matr(ctx: commands.Context) -> None: - """Bare ``!matr`` shows help.""" + """Bare ``!matr`` shows help; ``!matr `` earns a quip. + + With ``invoke_without_command=True`` the group's callback runs even + when the first word isn't a real subcommand — the parser rewinds the + view and invokes us as a plain command, so any leftover text means the + user typed an action that doesn't exist (`!matr blorp`).""" bot = _bot(ctx) + view = getattr(ctx, "view", None) + leftover = ( + view.buffer[view.index:].strip() if view is not None else "" + ) + if leftover: + await ctx.reply( + content=_quip("unknown"), + embed=_help_embed(ctx.prefix or "!", bot.config.command_name), + ) + return await ctx.reply( embed=_help_embed(ctx.prefix or "!", bot.config.command_name) ) @@ -1186,11 +1254,17 @@ async def _fun(ctx: commands.Context, user: discord.User) -> None: async def matr_ship( ctx: commands.Context, a: discord.User, b: discord.User | None = None ) -> None: - """Show a (deterministic) ship percentage between two users.""" + """Show the drifting ship percentage between two users.""" + bot = _bot(ctx) b = b or ctx.author - lo, hi = sorted((a.id, b.id)) - pct = int(hashlib.md5(f"{lo}:{hi}".encode()).hexdigest()[:6], 16) % 101 - gif = await _bot(ctx).gifs.fetch_url("handhold") + now = time.time() + pct = ship_score( + a.id, + b.id, + now=now, + message_count=bot.channel_activity.count(ctx.channel.id, now), + ) + gif = await bot.gifs.fetch_url("handhold") text = f"{a.mention} x {b.mention}: **{pct}%** :heart:" if _is_matrimony(ctx, a) or _is_matrimony(ctx, b): text += f"\n-# {_quip('ship')}" @@ -1466,11 +1540,14 @@ async def on_command_error( ) -> None: error = getattr(error, "original", error) if isinstance(error, commands.CommandNotFound): - # Unknown `!matr ` -> show help instead of silence. + # Unknown `!matr ` -> a quip plus the help embed. + # (Usually the group callback answers this first via + # ``subcommand_passed``; this path catches any other + # CommandNotFound raised against the matr group.) content = ctx.message.content.split() if len(content) >= 2 and content[0].endswith(self.bot.config.command_name): await ctx.reply( - content="Unknown action.", + content=_quip("unknown"), embed=_help_embed(ctx.prefix or "!", self.bot.config.command_name), ) return diff --git a/matrimony/ship.py b/matrimony/ship.py new file mode 100644 index 0000000..c7c74d2 --- /dev/null +++ b/matrimony/ship.py @@ -0,0 +1,106 @@ +"""Drifting ship-percentage scoring and per-channel message activity. + +``matr ship`` used to be a fixed hash of the two user ids, so it never +changed. The score is now a pure function of three inputs: + +- the sorted pair of user ids (stable anchor, per-pair drift shape), +- a slowly changing time component (a per-pair sinusoid over ~24h, so the + value drifts smoothly between 5-minute buckets instead of jumping), +- the rolling count of messages in the channel over the last 10 minutes + (busier channels nudge the value a little, direction depends on the pair). + +Everything is deterministic: the same inputs in the same time bucket always +give the same answer, and the function is pure so tests can inject the +clock and the message count. The result is clamped to 0-100. +""" + +from __future__ import annotations + +import hashlib +import math +from collections import deque + +SHIP_BUCKET_SECONDS = 300.0 +"""Length of a time bucket. The score is constant inside one bucket.""" + +SHIP_DRIFT_PERIOD_SECONDS = 24.0 * 3600.0 +"""Period of the sinusoidal drift. Slower = gentler wandering.""" + +SHIP_ACTIVITY_WINDOW_SECONDS = 600.0 +"""How long a message stays in a channel's rolling activity window.""" + +SHIP_ACTIVITY_NUDGE = 10 +"""Maximum absolute nudge applied by channel activity.""" + +SHIP_ACTIVITY_MAX_PER_CHANNEL = 256 +"""Bound on stored timestamps per channel (oldest are dropped).""" + +_TAU = 2.0 * math.pi + + +def ship_score( + user_a: int, user_b: int, *, now: float, message_count: int +) -> int: + """Deterministic, slowly drifting ship percentage in ``[0, 100]``. + + ``now`` is seconds since the epoch (any monotonic epoch works as long + as callers are consistent); ``message_count`` is the number of recent + messages in the channel. Same pair + same bucket + same count always + yields the same score; adjacent buckets differ by at most a point or + two so the percentage wanders rather than jumps. + """ + lo, hi = sorted((int(user_a), int(user_b))) + digest = hashlib.sha256(f"matrimony-ship:{lo}:{hi}".encode()).digest() + base = 15 + digest[0] * 70 // 256 # stable anchor in [15, 84] + amplitude = 5 + digest[1] * 15 // 256 # drift amplitude in [5, 19] + phase = (digest[2] | digest[3] << 8) / 65536.0 * _TAU + bucket = int(now // SHIP_BUCKET_SECONDS) + drift = amplitude * math.sin( + phase + _TAU * bucket * SHIP_BUCKET_SECONDS / SHIP_DRIFT_PERIOD_SECONDS + ) + direction = -1.0 if digest[4] & 1 else 1.0 + nudge = direction * min(max(int(message_count), 0), SHIP_ACTIVITY_NUDGE) + return max(0, min(100, round(base + drift + nudge))) + + +class ChannelActivity: + """In-memory rolling window of per-channel message timestamps. + + One small deque per channel, pruned to the last ``window_seconds`` + (and hard-bounded per channel) — recording and counting are both a + handful of pointer operations, so it stays cheap on busy channels. + """ + + def __init__( + self, + window_seconds: float = SHIP_ACTIVITY_WINDOW_SECONDS, + max_per_channel: int = SHIP_ACTIVITY_MAX_PER_CHANNEL, + ) -> None: + self._window = window_seconds + self._channels: dict[int, deque[float]] = {} + self._max = max_per_channel + + def _prune(self, dq: deque[float], now: float) -> None: + cutoff = now - self._window + while dq and dq[0] <= cutoff: + dq.popleft() + + def note(self, channel_id: int, when: float) -> None: + """Record that a message was sent in ``channel_id`` at ``when``.""" + dq = self._channels.get(channel_id) + if dq is None: + dq = self._channels[channel_id] = deque(maxlen=self._max) + dq.append(when) + self._prune(dq, when) + + def count(self, channel_id: int, now: float) -> int: + """Messages seen in ``channel_id`` within the window ending ``now``.""" + dq = self._channels.get(channel_id) + if not dq: + return 0 + self._prune(dq, now) + if not dq: + # Empty channels are dropped so the map can't grow forever. + del self._channels[channel_id] + return 0 + return len(dq) diff --git a/tests/test_help_quips.py b/tests/test_help_quips.py new file mode 100644 index 0000000..1925a7e --- /dev/null +++ b/tests/test_help_quips.py @@ -0,0 +1,152 @@ +"""Tests for the unknown-subcommand quips and the help embed's quip section.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from discord.ext import commands +from test_dispatch import ( + BOT_USER_ID, + make_bot, + run, + stub_send, + user, + wire_message, +) + +from matrimony.cogs import matr as matr_mod +from matrimony.cogs.matr import ( + _HELP_QUIPS, + _UNKNOWN_QUIPS, + MatrCog, + _help_embed, +) + + +def _sent_payload(send: AsyncMock) -> dict: + """The payload of the last stubbed http.send_message call.""" + params = send.call_args.kwargs["params"] + return params.payload if hasattr(params, "payload") else params + + +# --------------------------------------------------------------------------- +# Help embed quips section +# --------------------------------------------------------------------------- + + +def test_help_embed_under_discord_description_limit() -> None: + emb = _help_embed("!", "matr") + assert len(emb.description) <= 4096 + + +def test_help_embed_has_a_long_quips_section_at_the_bottom() -> None: + emb = _help_embed("!", "matr") + assert "**Marital wisdom**" in emb.description + # The section is at the bottom of the description. + assert emb.description.rindex("**Marital wisdom**") > ( + emb.description.rindex("**Meta**") + ) + # Every quip made it in (it's a long list). + assert len(_HELP_QUIPS) >= 25 + for quip in _HELP_QUIPS: + assert quip in emb.description + + +def test_help_embed_keeps_command_listing() -> None: + emb = _help_embed("!", "matr") + for section in ("**Love**", "**Parentage**", "**Trees**", "**Fun**", "**Meta**"): + assert section in emb.description + + +# --------------------------------------------------------------------------- +# Unknown subcommands -> funny quip +# --------------------------------------------------------------------------- + + +def test_unknown_subcommand_gets_a_quip() -> None: + """`!matr blorp` replies with a rotating quip (plus the help embed).""" + bot = make_bot() + send = stub_send(bot) + run(bot.process_commands(wire_message(bot, user(1), "!matr blorp"))) + send.assert_awaited_once() + payload = _sent_payload(send) + assert payload["content"] in _UNKNOWN_QUIPS + assert "MarriageBot-style" in payload["embeds"][0]["description"] + + +def test_unknown_subcommand_with_extra_words_still_quips() -> None: + bot = make_bot() + send = stub_send(bot) + run( + bot.process_commands( + wire_message(bot, user(1), "!matr blorp and some more words") + ) + ) + assert _sent_payload(send)["content"] in _UNKNOWN_QUIPS + + +def test_unknown_quips_rotate() -> None: + seen = {matr_mod._quip("unknown") for _ in range(len(_UNKNOWN_QUIPS))} + assert seen == set(_UNKNOWN_QUIPS) + + +def test_known_subcommand_does_not_quip() -> None: + """`!matr stats` runs normally - no quip content, no help embed.""" + bot = make_bot() + send = stub_send(bot) + run(bot.process_commands(wire_message(bot, user(1), "!matr stats"))) + payload = _sent_payload(send) + assert payload.get("content") in (None, "") + assert "Guilds" in payload["embeds"][0]["description"] + + +def test_bare_matr_shows_help_without_quip() -> None: + bot = make_bot() + send = stub_send(bot) + run(bot.process_commands(wire_message(bot, user(1), "!matr"))) + payload = _sent_payload(send) + assert payload.get("content") in (None, "") + assert "MarriageBot-style" in payload["embeds"][0]["description"] + + +def test_unknown_subcommand_ignored_from_ourselves() -> None: + """The bot's own messages never trigger the quip (self-loop guard).""" + bot = make_bot() + send = stub_send(bot) + run( + bot.process_commands( + wire_message(bot, user(BOT_USER_ID, bot=True), "!matr blorp") + ) + ) + send.assert_not_awaited() + + +def test_unknown_subcommand_from_bot_author_still_quips() -> None: + """Other bots are first-class users - their typos get quips too.""" + bot = make_bot() + send = stub_send(bot) + run( + bot.process_commands( + wire_message(bot, user(2, bot=True), "!matr blorp") + ) + ) + assert _sent_payload(send)["content"] in _UNKNOWN_QUIPS + + +def test_command_not_found_handler_also_quips() -> None: + """Direct exercise of the CommandNotFound branch (in case discord.py + ever routes `!matr ` there instead of the group callback).""" + bot = make_bot() + ctx = SimpleNamespace( + bot=bot, + prefix="!", + message=SimpleNamespace(content="!matr blorp"), + reply=AsyncMock(), + ) + # The handler only needs self.bot - no need to re-register the cog. + run( + MatrCog.on_command_error( + SimpleNamespace(bot=bot), ctx, commands.CommandNotFound() + ) + ) + ctx.reply.assert_awaited_once() + assert ctx.reply.call_args.kwargs["content"] in _UNKNOWN_QUIPS diff --git a/tests/test_ship.py b/tests/test_ship.py new file mode 100644 index 0000000..9b53de8 --- /dev/null +++ b/tests/test_ship.py @@ -0,0 +1,180 @@ +"""Tests for the drifting ship percentage and the per-channel activity window.""" + +import itertools +import time +from unittest.mock import AsyncMock, patch + +from test_dispatch import ( + BOT_USER_ID, + ctx_for, + make_bot, + message, + run, + user, +) + +from matrimony.cogs import matr as matr_mod +from matrimony.ship import ( + SHIP_ACTIVITY_NUDGE, + SHIP_BUCKET_SECONDS, + ChannelActivity, + ship_score, +) + +NOW = 1_700_000_000.0 + + +# --------------------------------------------------------------------------- +# ship_score +# --------------------------------------------------------------------------- + + +def test_deterministic_at_fixed_time() -> None: + """Same pair, same bucket, same count -> same score, every time.""" + a = ship_score(111, 222, now=NOW, message_count=3) + b = ship_score(111, 222, now=NOW, message_count=3) + assert a == b + + +def test_pair_order_is_symmetric() -> None: + assert ship_score(1, 2, now=NOW, message_count=0) == ship_score( + 2, 1, now=NOW, message_count=0 + ) + + +def test_constant_within_a_bucket() -> None: + """Every instant inside the same 5-minute bucket yields the same score.""" + start = NOW - (NOW % SHIP_BUCKET_SECONDS) + scores = { + ship_score(5, 6, now=start + off, message_count=4) + for off in (0.0, 1.0, 150.0, SHIP_BUCKET_SECONDS - 1.0) + } + assert len(scores) == 1 + + +def test_smooth_drift_across_buckets() -> None: + """Consecutive buckets differ by at most a point, and the score does + actually move over time.""" + scores = [ + ship_score(42, 99, now=NOW + i * SHIP_BUCKET_SECONDS, message_count=0) + for i in range(200) + ] + for prev, cur in itertools.pairwise(scores): + assert abs(cur - prev) <= 1 + assert len(set(scores)) > 1 + + +def test_message_count_nudges_the_score() -> None: + quiet = ship_score(7, 8, now=NOW, message_count=0) + busy = ship_score(7, 8, now=NOW, message_count=50) + assert quiet != busy + assert abs(busy - quiet) <= SHIP_ACTIVITY_NUDGE + + +def test_count_nudge_is_bounded() -> None: + """Thousands of messages nudge no harder than a few dozen.""" + few = ship_score(7, 8, now=NOW, message_count=SHIP_ACTIVITY_NUDGE) + many = ship_score(7, 8, now=NOW, message_count=1_000_000) + assert few == many + + +def test_score_always_clamped() -> None: + """No pair/bucket/count combination escapes [0, 100] — and the clamp + actually engages at both ends for extreme pairs.""" + seen = set() + for a in range(1, 400): + for b in range(a + 1, a + 25): + for count in (0, 1_000_000): + for offset in (0, 33_000_000, 66_000_000): + s = ship_score(a, b, now=NOW + offset, message_count=count) + assert 0 <= s <= 100 + seen.add(s) + assert 0 in seen + assert 100 in seen + + +# --------------------------------------------------------------------------- +# ChannelActivity +# --------------------------------------------------------------------------- + + +def test_activity_counts_recent_messages() -> None: + act = ChannelActivity(window_seconds=600.0) + act.note(1, 0.0) + act.note(1, 30.0) + act.note(2, 0.0) # different channel: isolated + assert act.count(1, 60.0) == 2 + assert act.count(2, 60.0) == 1 + assert act.count(3, 60.0) == 0 + + +def test_activity_prunes_old_messages() -> None: + act = ChannelActivity(window_seconds=600.0) + act.note(1, 0.0) + act.note(1, 30.0) + act.note(1, 100.0) + assert act.count(1, 599.0) == 3 + assert act.count(1, 630.0) == 1 # t=0 and t=30 aged out + assert act.count(1, 701.0) == 0 # t=100 aged out too + + +def test_activity_drops_empty_channels() -> None: + act = ChannelActivity(window_seconds=600.0) + act.note(1, 0.0) + assert act.count(1, 1e9) == 0 + assert 1 not in act._channels + + +def test_activity_is_bounded_per_channel() -> None: + act = ChannelActivity(window_seconds=600.0, max_per_channel=16) + for i in range(100): + act.note(1, float(i)) + assert act.count(1, 100.0) == 16 + + +# --------------------------------------------------------------------------- +# Wiring: on_message feeds the window, ship consumes it +# --------------------------------------------------------------------------- + + +def test_on_message_records_human_and_bot_authors() -> None: + """Bot-authored chatter counts toward channel busyness too.""" + bot = make_bot() + bot.process_commands = AsyncMock() # type: ignore[method-assign] + run(bot.on_message(message(user(1)))) + run(bot.on_message(message(user(2, bot=True)))) + assert bot.channel_activity.count(1, time.time()) == 2 + + +def test_on_message_counts_own_replies_but_skips_dispatch() -> None: + """Our own messages still count as channel activity (they're chatter + too) even though they're never dispatched as commands.""" + bot = make_bot() + bot.process_commands = AsyncMock() # type: ignore[method-assign] + run(bot.on_message(message(user(BOT_USER_ID, bot=True)))) + bot.process_commands.assert_not_awaited() + assert bot.channel_activity.count(1, time.time()) == 1 + + +def test_ship_command_replies_with_percentage() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_ship.callback(ctx, user(2), user(3))) + text = ctx.reply.call_args.kwargs["embed"].description + assert text.startswith("<@2> x <@3>: **") + assert "%** :heart:" in text + + +def test_ship_command_uses_channel_activity() -> None: + """The command feeds the channel's rolling message count into the + score (verified by pinning the clock).""" + bot = make_bot() + ctx = ctx_for(bot, user(1)) + t = time.time() + for _ in range(50): + bot.channel_activity.note(7, t) # ctx channel id is 7 + with patch.object(matr_mod.time, "time", lambda: t): + run(matr_mod.matr_ship.callback(ctx, user(2), user(3))) + text = ctx.reply.call_args.kwargs["embed"].description + shown = int(text.split("**")[1].rstrip("%")) + assert shown == ship_score(2, 3, now=t, message_count=50)