diff --git a/android/PLAN.md b/android/PLAN.md index b860761..9ce26f1 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1601,6 +1601,20 @@ push, and Play (M6–M8) follow the designed app. Excluded, as M14 excluded it: the admin Map card, which is operator configuration. +19. **M18 — a Rust server's next wipe** (post-v1; planned 2026-09-25). `module-rust` phase 16. + **Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §32**, D130 and D131. + The phase is mostly Discord commands, which are not the app's concern. The app's part is one + field. + + - **`nextWipe: { at, source } | null`** on the module's public server shape. The operator sets + a recurring rule and an optional one-off date, and the module computes the next wipe on every + read. + - **The server list row and the server screen's header** show it in the phone's own zone, with a + relative day. A one-off date reads *(rescheduled)*. + - **Against an older module the field is absent**, and nothing is drawn. + + Excluded: editing the schedule, which is operator configuration. + ### Deferred (not a milestone) - **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 7508d73..ee7bf1a 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1008,7 +1008,7 @@ Each phase ends with its findings written down, as every workstream here does. | 13b | **Events: the rewards** (§9, R3, R16). ✅ **Built and walked 2026-09-24 on both rigs, without a player — plan in §29 (D98–D105), as built and findings in §29.6–29.9 (D106–D108).** Real core refused the module over one camelCase option-source id, which 310 green tests had not caught (§29.8). The steps that need a person are the rewards walk in `PLAYER_WALK.md`. The participation tally kept by the game (D81–D83), `rust.kit.entitle` through the site mirror's per-run rows (D84) with the kit option source flagging kits with no permission gate, and `rust.announce` with the announce leg (D90). Plan of record in §29, written before its code. Protocol 10 | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it | | 14 | **The live map** (R9). ✅ **Built and walked 2026-09-25 (§30.7), protocol 11, D119–D120 (§30.6); the person-parts are the map walk in `PLAYER_WALK.md`.** 📐 **Plan of record in §30 (D109–D118), 2026-09-24.** A rig probe found that the `.map` file is terrain, not a picture, and that Rust+ already caches the rendered JPEG; the game's own render stalls the main thread 8.5 s (§30.0). Protocol 11. *As first written:* the map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it | | 15 | **Android leg D** (R10). Map and events. ✅ **Built and walked on an emulator 2026-09-25 (§31.5); the counted tally and a real player are in `PLAYER_WALK.md`.** 📐 **Plan of record in §31 (D121–D125), 2026-09-25.** The map is a fifth tab drawn natively in Compose over the module's projected answer, so the app has no gate of its own to get wrong. "Events" is a site-event marker opening the app's own event page, plus M13 walked against a Rust run. That needs one field on core's public calendar (D125) | Android-app + website + docs | The map renders on a phone with the same layer gates | -| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel | +| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral. 📐 **Plan of record in §32 (D126–D132), 2026-09-25.** Five commands. Any answer narrower than public goes to the caller alone (D127), which is the reverse of the case the criterion names and the one core does not cover. Plus an operator-set **next wipe** (a rule and a one-off date) shown on the web, in the app and by `/wipe` | Module-Rust + Android-app + docs | A refusal does not go public in the channel, **and neither does a permitted answer narrower than public** | | 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included | | 18 | **The installer** (R4) **and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a **framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, **the sidecar configured from egg variables** (R22), the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework | | 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change, and Pterodactyl is a hosting claim the site can now make); the Integration-kit question R2 raised; and whether the kit owes a reader anything about **supporting two mod frameworks at once** (R19) — a shape it has no chapter for either | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way | @@ -5699,8 +5699,198 @@ passes them. - **Carbon.** As §31.3 said, the app reads the module, and phase 14 proved the module's answer on both frameworks. +## 32. Phase 16 — Discord slash commands and the next wipe (plan of record, 2026-09-25) + +R11's phase: a small set of read-only commands answered from data the module already holds. The +row's criterion is *a refusal does not go public in the channel*. **Core already meets it** (§32.1). +The work that remains is the **reverse case**, which the row did not name: a *permitted* answer +that is narrower than public, posted where the whole channel reads it (D127). + +The phase grew one feature on the way. R11 promised a "wipe schedule", and the module knows only +past wipes. D128 and D130 make the next wipe something an operator sets, and D131 shows it on +every surface that shows a server. + +Three repositories: [Module-Rust][mr] into `edge`, [Android-app][aa] into `edge`, and this document +into `main`. **Rust-Link and Rust-Plugins do not change, and neither does the protocol.** Every +command reads the module's own tables, and the schedule is site data the game never sees. **Core +does not change and `MODULE_API` does not move.** `registerSlashCommands` has been live since 1.6.0. + +### 32.0 The decisions this phase needed + +All from the org lead, 2026-09-25. + +| # | Decision | +|---|---| +| **D126** | **Five commands: `/status`, `/wipe`, `/top`, `/online`, `/clan`.** R11's four questions, plus `/clan`, which D50 deferred to this phase. No write verbs, and no `/link`: the account link stays on R1's two surfaces. `/top` takes `kills`, `deaths`, `npckills` or `playtime`, reads the current wipe unless asked for all-time, and never prints a Steam id or a `lastSeen`. Rejected: three commands (dropping `/clan` and folding the wipe into `/status`), and one `/rust` command with a `what` choice, whose options stop meaning anything once each question needs different ones. | +| **D127** | **An answer that carries anything narrower than `public` goes to the caller alone** (`ephemeral: true`). An answer the public may see stays public. The online names and a clan's roster are the two cases today. Rejected: answering only what is public in every channel, which leaves staff nothing on Discord. Also rejected: answering at the caller's level in the channel, which is the leak module-uo's `/guild` has (D132). | +| **D128** | **The next wipe is an operator setting**, shown by `/wipe`. This departs from the recommendation, which was to show the last wipe and no forecast, because the module holds no schedule. The org lead chose to hold one. | +| **D130** | **The setting is a recurring rule plus an optional one-off date.** The rule computes the next wipe, so it never goes stale once a wipe has happened. The one-off date replaces the next computed wipe, for a delayed or an extra wipe. Rejected: a single date an operator retypes after every wipe, and the rule alone. | +| **D131** | **Where the next wipe appears.** It is edited in the existing server form (Admin → Rust, `PUT /servers/:id`; D106 found there is no other per-server page). It is shown on the web's server list and server page, **in the Android app** (the server list and the server screen's header), and by `/wipe`. The answer also selected "Discord `/wipe` only", which contradicts the other three. This plan reads it as *and* `/wipe`, and review is the place to correct that. **Android-app joins this phase for it.** | +| **D132** | **module-uo's `/guild` leak is filed, not fixed here:** [Module-uo#46][uo46]. When `guilds` is gated to staff and a moderator runs `/guild` in a public channel, the guild list and one guild's leaders and counts are posted to the channel. The handler checks the caller and then answers publicly. | +| **D129** | **The walk drives the real bot code with a recording fake interaction:** real core, the real module, and the Oxide rig's sidecar, with the bot's `dynamicCommands` run against a fake that records `deferReply`, `editReply`, `deleteReply` and `followUp`. This is the Teams phase 7 recipe, and it needs no Discord credentials. It proves what would be sent and whether each reply is public or private. What Discord draws is the org lead's review. | + +### 32.1 Facts the phase rests on + +**From core** (`website`, `main`): + +- **A refusal is already private.** The bot defers publicly, except for `access: 'linked'`. When a + handler's envelope says `ephemeral: true`, the bot deletes the deferred reply and sends the + answer as an ephemeral follow-up (`bot/src/discord/dynamicCommands.js`, `reply()`). A core-side + refusal (not permitted, handler threw, handler timed out) goes the same way. **There is no reverse + case**: an envelope without `ephemeral` is posted where the deferral put it, which is the channel. + So D127 is the module's job, answer by answer. +- **The handler has 3 seconds** (`HANDLER_TIMEOUT_MS`, under the bot's 4-second dispatch timeout). + **No command may ask a sidecar.** Every answer below reads the module's own tables. +- **`choices` are fixed when the module registers**, which happens once, at load. Servers are added + at runtime, so `server` cannot be a choice. It is a free-text option, matched by the module. +- **`actor` is `{ platform, platformUserId, guildId, userId, role, isLinked, isStaff }`.** A banned + or disabled account resolves as unlinked. The Discord provider is found by `auth_providers.kind`. +- **An envelope is clamped:** text 2 000 characters, 25 fields, 1 024 characters a field value. A + title or fields make an embed, and `url` makes the title a link. +- **The bot's built-in commands** are `announce`, `autorole`, `ban`, `filter`, `filterallow`, + `invite`, `kick`, `modlog`, `mute`, `news`, `ping`, `role`, `rolemenu`, `roles`, `schedule`, + `warn`, `warnings` and `wiki`. None of D126's five collides, and one module per site (website#204) + means `/guild` and these can never share a bot. +- **Core offers modules no time-zone arithmetic.** `events/recurrence.js` does it for core, and §2.7 + forbids importing it. The module does its own, through `Intl` (Node ships full ICU). + +**From the module** (`Module-Rust`, `edge`): + +- **`visibility.viewer(req)` reads `req.user` before a token**, then re-reads the account's row. A + command can therefore hand it `{ user: { id: actor.userId, role: actor.role } }` for a linked + caller, and a banned account still resolves as nobody. An *unlinked* caller must be answered + `public` directly and never handed a synthetic request, because that falls through to + `getUserFromRequest`, which expects real cookies (module-uo's phase 3 bug). +- **The public shapes:** + - A server (`servers.shapePublic`) has `online`, `players`, `maxPlayers`, `worldSize`, `seed`, + `wipeId`, `wipedAt`, `lastSeenAt` and `stale`. A stale row claims neither online nor players. + - The leaderboard's names are public at every setting. Only `lastSeen` sits behind presence. + - The online count is public, and the names sit behind presence (D42, per-server override D45). + - A clan's name, colour, score and member count are public. Its roster sits behind the roster + audience (D48), and inside it, so does who in the clan is online. +- **The server form writes the whole row in one `PUT`** and records it with `core.activity.log` + (`rust.server.save`). New columns ride the same body and the same audit line. +- **Columns are added with `ALTER TABLE … ADD COLUMN IF NOT EXISTS`**, which replays safely at + every boot (the precedent is `presence_audience` and `announce_news` on `rust_servers`). + +**From the world:** + +- **Facepunch's forced wipe is the first Thursday of every month at 19:00 UK time**, whatever a + server's own rule. The UK clock is what stays fixed: that is 18:00 UTC during British Summer Time + and 19:00 UTC otherwise. The next one is Thursday 1 October 2026, 18:00 UTC. + +### 32.2 What the plan builds + +**Module-Rust** (`feat/phase-16-commands`, from `edge`): + +- **`server/commands/`**, one file per command plus a shared `common.js`, registered once in + `index.js` with `api.registerSlashCommands`. Every command is `access: 'everyone'`. The gates + that matter are the module's own, resolved inside the handler, exactly as `/guild` does it: a + `linked` access would hide the commands from the unlinked players they could invite. +- **`common.js`:** + - `levelFor(actor)`: `public` for an unlinked caller, otherwise `visibility.viewer` over + `{ user }`. + - `pickServer(option)`: an exact id, then an exact name (case-insensitive), then a unique prefix + of either. With no option, a fleet of one is that server. Otherwise the command answers for + every server, or asks which one when the question needs one. + - `pageUrl(path)` from `core.site.baseUrl`. + - One refusal helper, which **always** sets `ephemeral`. +- **`/status [server]`.** Without a server, one field per enabled server (at most 25): online or + offline, players out of max, and the last wipe's age. With one: the same plus the map size and + seed, when it was last seen, and the next wipe, with the title linking to its page. Public. +- **`/wipe [server]`.** For each server, or the one named: the next wipe (D130, with what decided + it: the rule, the forced wipe, or the one-off date) and the last. A server with no rule says + *no schedule set* and shows only the last wipe. Public. +- **`/top [stat] [server] [alltime]`.** The top ten by the chosen stat (`kills` by default, as the + web's). `stat` is a `choices` string, since it is fixed. It needs one server: with several and + none named, it asks. The names are public, as on the web. No Steam id, no `lastSeen`. Public. +- **`/online [server]`.** + - Below the server's presence audience: the count only, and public. + - Inside it: the names too, up to 50 and then *and N more*. That answer is **private unless the + audience is `public`** (D127). + - A signed-in-reachable audience gets the link nudge only when linking would actually reach it + (the module-uo rule: linking earns `signed_in` and nothing above it). +- **`/clan name [server]`.** A clan by exact name, then a unique prefix, on the named server or + across the fleet (an ambiguous name answers the candidates, privately). + - Outside the roster audience: name, colour, score and member count, public. + - Inside it: the members and who is on, **private unless the roster audience is `public`** + (D127, D48). + - A server whose clan board cannot be trusted (`board` false) says so rather than answering + "no such clan". +- **The next wipe (D130).** + - `schema.sql`: `wipe_rule` (`none`, `forced`, `weekly` or `biweekly`), `wipe_day` (0–6), + `wipe_time` (`HH:MM`), `wipe_tz` (IANA), `wipe_anchor` (a date, for `biweekly`) and + `wipe_once_at` (DATETIME, UTC). All are nullable, and `wipe_rule` defaults to `none`. + - `model/servers/nextWipe.js`: a pure function `nextWipe(row, now) → { at, source } | null`, with + the zone arithmetic in it, tested at both DST edges of both zones the walk uses. + - The public server shape gains `nextWipe: { at, source } | null`, and the server form and `PUT` + gain the six fields, validated: a known zone (`Intl` throws on an unknown one), `HH:MM`, a day + 0–6, an anchor for `biweekly`, and a one-off date in the future on save. + - The web shows *Next wipe* on the server list and in the server page's header. +- **The module's own docs:** the swagger fragment for the changed shapes, `routes.manifest.json` + regenerated if a route's shape moved, and the README's command list. + +**Android-app** (`feat/rust-phase-16-next-wipe`, from `edge`): + +- `RustServerDto.nextWipe` (nullable: absent against an older module, and then nothing is drawn). +- The server list row and the server screen's header read *Next wipe Thu 1 Oct, 19:00* in the + phone's own zone, with a relative day. A `source` of `once` reads *(rescheduled)*. + +### 32.3 The walk (D129) + +On the Oxide rig with real core on the walk database. The bot's `dynamicCommands` is required with +`SITE_INTERNAL_URL` pointed at core, and it is fed a fake interaction that records every call. +Callers are throwaway users on the walk database, each with a Discord identity under a provider +whose `kind` is `discord` and whose id is **not** `discord`. Nothing existing is repointed (Teams +phase 7's lesson). + +1. **Registration.** `GET /internal/commands` lists the five, and core's `register()` accepted + every definition. Disabling the module removes them from the pull and refuses a dispatch. +2. **Each command's public answer**, for an unlinked caller: `deferReply({ ephemeral: false })` + then `editReply`, and nothing else. +3. **Every refusal is private**: an unknown server, an ambiguous name, a missing clan, `/top` with + no server on a fleet of two. Each is `deferReply`, `deleteReply`, then `followUp({ ephemeral: + true })`. +4. **D127, the case the row did not name.** Presence at `staff`: an unlinked caller gets the count + in public, and a moderator gets the names **privately**. Presence at `public` for that server: + the names go public. The same three for a clan roster. +5. **The next wipe.** One server `forced`, one `weekly` in `America/Chicago`, one `biweekly`. A + one-off date, before and after the computed wipe it replaces. The same `at` appears in the API, + on the web, in `/wipe` and on the phone. The DST edges are unit tests, not waiting. +6. **An offline server.** With the sidecar stopped, `/status` says offline with when it was last + seen, and no command takes longer than its reads. +7. **Android**, on the emulator: the list row and the header show the next wipe, and against a + module without the field they show nothing. + +**Carbon is not walked.** Nothing here reaches the plugin: every command reads tables phase 3 and +phase 9 already proved on both frameworks. + +### 32.4 Readings nobody was asked about + +Each is taken as written unless review says otherwise. + +1. **The command names are bare words** (`/status`, `/top`). Discord scopes commands to the bot + that owns them, and one module per site means no other module competes for them. +2. **`/top` is ten rows, and `/online` fifty names.** An embed takes 25 fields and 2 000 characters + of text. These are summaries a person reads, not tables. +3. **Every rule includes the forced wipe.** Facepunch forces it on every server, so a weekly rule's + next wipe is the earlier of its own next Thursday and the first Thursday of the month. `forced` + is the rule for a server that wipes on nothing else. `none` shows no forecast at all, because the + operator has not said the server follows the game's calendar. +4. **The forced wipe is a constant in the module**: first Thursday, 19:00, `Europe/London`. If + Facepunch moves it, that is one line and a release. +5. **A one-off date, while it is in the future, is the next wipe**, and any computed wipe before it + is skipped. That one reading covers both of D130's cases: a date after the computed wipe delays + it, and a date before it adds an extra wipe. Once the date has passed it is ignored rather than + cleared, and the form shows it as past. +6. **A wipe time that falls in a skipped DST hour moves forward**, and one in a repeated hour takes + the first occurrence. That is what `Intl` offsets give, stated so a test pins it. +7. **The next wipe is computed on every read and never stored.** Nothing has to roll it forward + after a wipe, and nothing can go stale. + --- +[uo46]: https://gitea.whitlocktech.com/RunicGateway/Module-uo/issues/46 [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app [web]: https://gitea.whitlocktech.com/RunicGateway/website