diff --git a/CHANGELOG.md b/CHANGELOG.md index bb411e5..8fddf67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ Rules (checked by `tests/changelog.test.ts`): and behind-the-scenes work. 0.1.0 to 0.7.1 were cut after the fact from git history. +## 0.10.0 - 2026-10-20 + +- `!changelog` bot and StreamElements command (`changelog` action, a read action). Its line comes from `LATEST_RELEASE` + in `server/release.ts`; `tests/changelog.test.ts` keeps it equal to the page's newest release. + ## 0.9.0 - 2026-10-20 - Level sync: `syncPair` in `server/upgrades.ts` (`SYNC_MARGIN` 10) and `duelStats` in `server/game.ts`, used for diff --git a/changelog/index.html b/changelog/index.html index 47fdac6..08e41f9 100644 --- a/changelog/index.html +++ b/changelog/index.html @@ -18,11 +18,12 @@
+
+

0.10.0 Changelog in chat

+

+
    +
  • New !changelog in chat names the newest PixFray release and links to this page. It works while duels are paused and needs no fighter.
  • +
+
+

0.9.0 Fairer duels

diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 69894e9..bf391ce 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -80,7 +80,7 @@ everything else is revalidated. | GET | `/api/state/:channel` | none | none | Snapshot (section 3) | 403, 405 | | GET | `/api/leaderboard/:channel` | none | none | Up to 100 `Profile` rows without `dollars` (`?private=1`, canManage only, keeps them; the mod page uses it), ordered by elo desc, then wins desc, then username (quick-duel results still on stream are not shown yet; see Game rules) | 403 | | GET | `/api/looks/:channel?u=login1,login2` | none | at most 20 logins | `{login:{avatar, color, hat, pet, petTier, recolor, petColor, accessory, trail, winEffect, taunt, title, displayName, elo}}` for viewers with a saved fighter only. The overlay uses it for chat-only viewers, batched every 2 s; a saved look is cached 5 min and asked for again when its viewer next chats after that, "no saved fighter" is cached 1 min, and a failed lookup (such as a 429) is retried after 10 s, not cached. Every registered profile in a live snapshot replaces that viewer's cached look, so a fighter changed on the website or a new Elo stays on stream after the server drops the viewer from its active list (10 quiet minutes). Changes to viewers off that list arrive as a looks push (section 3), and every minute the overlay asks again for everyone on stage the server doesn't list. | 403 | -| GET | `/api/se/:channel/:action?k=..&id=..&u=..&d=..&t=..&m=..` | the channel's StreamElements key `k` | `action` is `challenge`, `accept`, `decline`, `rematch` (challenges the sender's last finished-duel opponent), `top` (default name `!ranks`), `elo`, `help` (default name `!fray`), `look` (default name `!look`: a link to the Fighter tab, "pick your fighter" or "change your look"; also while duels are paused), `checkin` (once per stream while the channel is live; see Game rules), `wallet`, `pet` (default name `!pet`, also while duels are paused), `stats` (default name `!stats`, also while duels are paused) or `give` (default name `!pay`, because StreamElements' built-in `!givepoints` already answers to `!give`; the amount comes as `a=`; see Game rules). The first command with the right key makes StreamElements the chat source (and deletes a Twitch EventSub subscription), unless a mod used Disconnect chat. Each action's last arrival is recorded (at most once a minute) for the admin badges; New key clears them. The Worker checks the request before any Durable Object call ("StreamElements route checks" below). | always 200 `text/plain`: the one-line chat reply for the bot to post (empty for a repeated message id) | 405 not GET, 404 path doesn't match or a well-formed key on a channel that isn't set up, 429 from the edge rate limit, 503 `INTERNAL_SECRET` missing | +| GET | `/api/se/:channel/:action?k=..&id=..&u=..&d=..&t=..&m=..` | the channel's StreamElements key `k` | `action` is `challenge`, `accept`, `decline`, `rematch` (challenges the sender's last finished-duel opponent), `top` (default name `!ranks`), `elo`, `help` (default name `!fray`), `look` (default name `!look`: a link to the Fighter tab, "pick your fighter" or "change your look"; also while duels are paused), `checkin` (once per stream while the channel is live; see Game rules), `wallet`, `pet` (default name `!pet`, also while duels are paused), `stats` (default name `!stats`, also while duels are paused), `changelog` (default name `!changelog`: the newest release and the `/changelog/` link, also while duels are paused and without a fighter) or `give` (default name `!pay`, because StreamElements' built-in `!givepoints` already answers to `!give`; the amount comes as `a=`; see Game rules). The first command with the right key makes StreamElements the chat source (and deletes a Twitch EventSub subscription), unless a mod used Disconnect chat. Each action's last arrival is recorded (at most once a minute) for the admin badges; New key clears them. The Worker checks the request before any Durable Object call ("StreamElements route checks" below). | always 200 `text/plain`: the one-line chat reply for the bot to post (empty for a repeated message id) | 405 not GET, 404 path doesn't match or a well-formed key on a channel that isn't set up, 429 from the edge rate limit, 503 `INTERNAL_SECRET` missing | | GET | `/api/catalog/:channel` | none | none | `[...static characters.json, ...custom entries]` (section 5) | 403 | | GET | `/api/profile/:channel` | cookie | none | `Profile & {owned:{pets:[id], hats:[id], recolor:[id], petcolor:[id], accessory:[id], trail:[id], effect:[id], taunt:[id], title:[id], slots:n, rolls:{petId:[stat]}, rerolled:[petId]}, builds:[Loadout\|null]}` or `null`. `slots` is 1 free plus the bought ones; `rolls` holds the 4 stats each owned legendary pet rolled, and `rerolled` the legendaries whose free reroll is used; `builds[i]` is slot i's saved loadout (`avatar, color, defaultAbility, stats, hat, pet` and the 7 cosmetic fields), `null` if never saved; the active slot is always the profile itself | 401 | | GET | `/api/profile/:channel/others` | cookie | none | `{fighters:[{channel, avatar, color, defaultAbility, lastSeen}]}`: the viewer's saved fighters on other channels that are on or paused, newest `lastSeen` first. It checks the channels in AuthStore `fighters:` plus the built-in ones, at most 5. Only the base look; gear stays on its channel. The fighter page offers these as "Use my fighter" when the viewer has no fighter on this channel | 401, 404 any other subpath or method | @@ -504,6 +504,7 @@ Viewer sprites (server/sprites.ts): - `!wallet` (the StreamElements `wallet` action, also while duels are paused): "@x: $N PixFray dollars, P upgrade points, S-stream streak." Reasons: `wallet`, `no_fighter`. - Pets and the shop (server/pets.ts): 14 built-in pets in 5 tiers, plus up to 24 uploaded per channel. A fighter owns any number of pets (`owned_items`) and brings one (`profiles.pet`); hats can be bought too. A pet's boost is added after the channel's `maxStatLevel` cap: common and uncommon +1 to one stat, rare +2, epic +2 and +1 to a second stat (those use power, guard or luck), legendary +1 to 4 different stats out of all 7, rolled for each owner at purchase (`owned_items.roll`; legendaries bought before rolls existed get one when the room starts). Each owner may reroll a legendary once for free (`owned_items.rerolled`); the new set always differs, and it's refused while the fighter is in a challenge or duel. Pets cost `petPrice` dollars; a hat whose wins aren't reached costs `hatPricePerWin` times the wins it needs. The overlay draws the pet behind its fighter at about 40% of its height; epic and legendary pets sparkle, legendary pets hover. Snapshot players and `/api/looks` carry `pet` and `petTier`; players also carry `petBoost`. - Cosmetics and builds (server/cosmetics.ts, drawn by src/served/cosmetics.ts): recolor (a CSS filter on the fighter), pet color (the same 8 tints on the pet), accessories, walking trails, win effects (about 3 s over the winner when the overlay shows the result), win taunts (a speech bubble then) and titles (under the nameplate). Each kind has one config price. Owned cosmetics are `owned_items` rows; the worn ones are profile columns. Build slots: 1 free, the 2nd costs `buildSlotPrice`, the 3rd to 5th `buildSlotPriceMore` each. Each slot keeps its own loadout in the `builds` table; saving to a slot makes it the one on stream, and every build can spend the full point pool. Removing a viewer deletes their builds; a rank reset keeps them. +- `!changelog` (the `changelog` action): "PixFray 0.10.0: Changelog in chat. Every change: /changelog/", from `LATEST_RELEASE` in server/release.ts (`tests/changelog.test.ts` keeps it equal to the page's newest release). Reason: `changelog`. - `!pet [@name]` (the `pet` action): "@x's pet: Fox (rare), +2 power." Reasons: `pet`, `no_pet` (with the site link for the sender's own), `no_fighter`. - `!stats [@name]` (the `stats` action): "@x: Power 10, Guard 8, Crit 4. 12 points unspent. Pet: +2 power." The levels are those that count in duels (capped by `maxStatLevel`), from shown results only; "no upgrades yet" with none. With @name and a pair limit, it adds "N ranked duels left between you this stream." Reasons: `stats`, `no_fighter`. - `!pay @name amount` (the `give` action; on a channel the PixFray bot reads, it's `!give` and `!pay` stays an alias; `a=` is `20` or `$20`): moves dollars between two saved fighters while the channel is live (the same Twitch check as check-ins). In order it refuses with `give_off` (config `giveEnabled` false), `no_fighter`, `give_usage` (no target or no whole amount of at least 1), `target_not_found`, `self_give`, `twitch_error`, `not_live`, `too_few_duels` (fewer than `giveMinDuels` finished duels, `{duels, need}`), `give_cap` (already gave `giveMaxPerStream` this stream), `over_cap` (`{left}`), `not_enough` (`{dollars}`) and `target_full` (`{to, room}`: the receiver's wallet can't hold the amount, so nothing moves). Hidden duel results and payouts don't count yet. The limit is per Twitch stream id (`profiles.give_stream`, `given_in_stream`). Success is `given` with `{amount, to, dollars, left}`; the command log stores the amount. diff --git a/docs/DUELS.md b/docs/DUELS.md index 3b6d2d8..6b6f67b 100644 --- a/docs/DUELS.md +++ b/docs/DUELS.md @@ -58,6 +58,7 @@ The duel is rolled at once, the moment the challenge is accepted. fresh fighter against a top build wins about 2 in 5 instead of about 1 in 40. - `!checkin`, once per live stream, adds a point too, with streak bonuses: +1 at 3, 7, 14 and 30 streams, +2 at 60, +3 at 100, +5 at 200 and +10 at 365. +- `!changelog` names the newest PixFray release and links to the changelog page. - `!pet` shows a fighter's pet. `!stats` (or `!stats @name`) shows the upgrade levels that count, unspent points and the pet's boost. - A legendary pet gives +1 to 4 different stats, rolled at random when you buy it. You can reroll them once for free on the Fighter tab; the new set is always different. Not during a challenge or duel. diff --git a/docs/STREAMER_SETUP.md b/docs/STREAMER_SETUP.md index 5834d8b..3a68285 100644 --- a/docs/STREAMER_SETUP.md +++ b/docs/STREAMER_SETUP.md @@ -44,8 +44,8 @@ with chat connected. - Type `/mod pixfray` in your chat. Twitch lets a bot that isn't a moderator answer only about one command a second. -- Type `!fray` to test. The bot answers 13 commands: `!challenge`, `!fight`, `!decline`, - `!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!stats`, `!elo`, `!ranks`, `!fray` and `!look`. The +- Type `!fray` to test. The bot answers 14 commands: `!challenge`, `!fight`, `!decline`, + `!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!stats`, `!elo`, `!ranks`, `!fray`, `!look` and `!changelog`. The **Chat commands** section below the checklist adds your own text commands. - If StreamElements is also in your chat, turn off its Duel module (Chat bot, then Modules), or its `!duel` and `!accept` answer next to PixFray. @@ -96,8 +96,8 @@ In StreamElements go to Chat bot, then Modules, and switch off Duel, or type ### Add the commands to StreamElements -The table in **Add the chat commands to StreamElements** lists 13 commands: `!challenge`, `!fight`, `!decline`, -`!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!stats`, `!elo`, `!ranks`, `!fray` and `!look`. You can rename them first and click **Save names**. +The table in **Add the chat commands to StreamElements** lists 14 commands: `!challenge`, `!fight`, `!decline`, +`!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!stats`, `!elo`, `!ranks`, `!fray`, `!look` and `!changelog`. You can rename them first and click **Save names**. Keep `!pay` off `!give`: StreamElements' built-in `!givepoints` already answers to `!give`. The commands run as StreamElements custom commands; Nightbot and Fossabot can't run them. diff --git a/index.html b/index.html index e8eee1d..cac8083 100644 --- a/index.html +++ b/index.html @@ -231,6 +231,7 @@

Commands

!eloPosts your Elo, rank and record in chat. Add @viewer to check someone else. !ranksPosts the top 5 of the leaderboard in chat. !frayPosts how to join, with a link to this page. + !changelogPosts the newest PixFray version and a link to everything that changed.
diff --git a/package.json b/package.json index f75fde2..c3c4068 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mini-chat", - "version": "0.9.0", + "version": "0.10.0", "private": true, "license": "MIT", "type": "module", diff --git a/server/channel.ts b/server/channel.ts index 06b4765..833d7e1 100644 --- a/server/channel.ts +++ b/server/channel.ts @@ -71,6 +71,7 @@ import { seReplyText, seTopText, seEloText, + seChangelogText, seHelpText, seLookText, seCheckinText, @@ -311,6 +312,7 @@ const BOT_TARGET_ACTIONS: { [action: string]: (names: SeNames) => string } = { const E2E_STEPS: [who: "B" | "O", action: string, target: "" | "O", want: string[], gate?: boolean][] = [ ["B", "help", "", ["help"]], ["B", "look", "", ["look"]], + ["B", "changelog", "", ["changelog"]], ["B", "top", "", ["top"]], ["B", "elo", "", ["elo"]], ["B", "elo", "O", ["elo"]], @@ -2438,6 +2440,7 @@ export class ChannelRoom extends DurableObject { } if (SE_READ_ACTIONS.includes(action)) { if (action === "help") return done(seHelpText({ names, origin, channel }), "help"); + if (action === "changelog") return done(seChangelogText({ origin }), "changelog"); if (action === "look") return done( seLookText({ diff --git a/server/release.ts b/server/release.ts new file mode 100644 index 0000000..803e803 --- /dev/null +++ b/server/release.ts @@ -0,0 +1,3 @@ +// The newest release on the /changelog/ page (changelog/index.html), for the !changelog chat line. +// tests/changelog.test.ts keeps it equal to the page's newest release. +export const LATEST_RELEASE = { version: "0.10.0", name: "Changelog in chat" }; diff --git a/server/streamelements.ts b/server/streamelements.ts index 55e8203..21fbfbd 100644 --- a/server/streamelements.ts +++ b/server/streamelements.ts @@ -3,6 +3,7 @@ // The trigger word lives only in StreamElements, so commands can be renamed there freely. import { hiddenResults } from "./game.ts"; import { boostText } from "./pets.ts"; +import { LATEST_RELEASE } from "./release.ts"; import type { Env } from "./env.ts"; import type { GameResult, GameState, Player } from "./model.ts"; import { STATS } from "./upgrades.ts"; @@ -28,8 +29,9 @@ export const SE_ACTIONS = [ "give", "pet", "stats", + "changelog", ]; // quick duels need no attack commands -export const SE_READ_ACTIONS = ["top", "elo", "help", "look"]; // no game state, so they work while duels are paused +export const SE_READ_ACTIONS = ["top", "elo", "help", "look", "changelog"]; // no game state, so they work while duels are paused // StreamElements has a built-in !top that can't be edited, so the leaderboard command is !ranks. export const DEFAULT_SE_NAMES = { challenge: "!challenge", @@ -45,6 +47,7 @@ export const DEFAULT_SE_NAMES = { give: "!pay", pet: "!pet", stats: "!stats", + changelog: "!changelog", }; // not !give: StreamElements' built-in !givepoints answers to that export const LOST_TEXT = "Lost in the arena? Type !fray"; export const MISSED_TEXT = "That move didn't land. Try again in a moment!"; @@ -233,6 +236,12 @@ export function seHelpText({ names = {}, origin = "", channel = "" }: SeLinkOpti return `PixFray duels: name your rival with ${n("challenge")} @name. They answer ${n("accept")}. Again? ${n("rematch")}. More: ${n("checkin")} ${n("wallet")} ${n("top")} ${n("look")}. Gear up ${link ? "at " + link : "on the PixFray site"}`; } +// !changelog: the newest release and a link to every change. The link goes last, as in !fray. +export function seChangelogText({ origin = "" }: SeLinkOptions = {}): string { + const { version, name } = LATEST_RELEASE; + return `PixFray ${version}: ${name}. Every change: ${origin ? origin + "/changelog/" : "the changelog on the PixFray site"}`; +} + // The bot's timed reminder (config.reminderMin): the !fray line, which already lists the other commands. export function seReminderText({ names = {}, origin = "", channel = "" }: SeLinkOptions = {}): string { return seHelpText({ names, origin, channel }); diff --git a/src/admin.ts b/src/admin.ts index 5e27bfb..c528152 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -1984,6 +1984,7 @@ const SE_LABELS: Record = { look: "Link to change your look", pet: "Own pet, or @viewer's", stats: "Own stats, or @viewer's", + changelog: "What changed in PixFray", checkin: "Daily check-in", wallet: "Wallet", give: "Give dollars", diff --git a/tests/changelog.test.ts b/tests/changelog.test.ts index 6dc251e..67c4780 100644 --- a/tests/changelog.test.ts +++ b/tests/changelog.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { URL as NodeURL } from "node:url"; +import { LATEST_RELEASE } from "../server/release.ts"; const read = (file: string) => readFileSync(new NodeURL("../" + file, import.meta.url), "utf8"); // Comments are dropped so the format note at the top of the page isn't read as a release. @@ -73,3 +74,8 @@ test("every release on the page is in CHANGELOG.md with the same date", () => { assert.equal(md.find((m) => m.version === r.version)?.date, r.date, r.version); } }); + +test("!changelog's release (server/release.ts) is the page's newest release, named as its heading names it", () => { + const heading = /]*>([^<]+)<\/span> ([^<]+)<\/h2>/.exec(html); + assert.deepEqual(heading?.slice(1), [LATEST_RELEASE.version, LATEST_RELEASE.name]); +}); diff --git a/tests/channel.test.ts b/tests/channel.test.ts index a1093ae..5814969 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -8,6 +8,7 @@ import { logRoomError } from "../server/developer.ts"; import { createInitialState, defaultConfig } from "../server/game.ts"; import { makePng, b64 } from "./upload-helpers.ts"; import { effectiveStats } from "../server/upgrades.ts"; +import { LATEST_RELEASE } from "../server/release.ts"; import { tierBoost, petOf, PETS, boostText, cleanBoost, parseRoll, rollLegendary } from "../server/pets.ts"; import { asEnv, fakeCtx, fullStats, must } from "./fakes.ts"; import type { FakeSocketLike, FakeState, Loose } from "./fakes.ts"; @@ -2736,7 +2737,7 @@ test("debug bot: the real bot login (pixfray, a known chat bot) still plays ever await r.connectChat("sub-bot"); await r.call("/dev-live", { method: "POST", body: { live: false } }); const run = await r.say("u1", "alice", "!fray e2e", { mod: true }); - assert.match(run.replies[0], /^PixFray e2e, pixfray vs alice: 18 of 18 steps passed\.$/); + assert.match(run.replies[0], /^PixFray e2e, pixfray vs alice: 19 of 19 steps passed\.$/); // Outside the bot's own commands, a chat line from pixfray is still ignored. const open = () => r.readState("nesszerra").duels.filter((d) => d.status === "pending").length; const before = open(), @@ -2758,7 +2759,7 @@ test("debug bot: !fray e2e plays every command once against the asker and posts await r.call("/dev-live", { method: "POST", body: { live: false } }); assert.match((await r.say("u1", "alice", "!fray e2e")).reply, /only the broadcaster or a mod/); const run = await r.say("u1", "alice", "!fray e2e", { mod: true }); - assert.match(run.replies[0], /^PixFray e2e, pixbot vs alice: 18 of 18 steps passed\.$/); + assert.match(run.replies[0], /^PixFray e2e, pixbot vs alice: 19 of 19 steps passed\.$/); const detail = run.replies.slice(1).join(" ยท "); assert.doesNotMatch(detail, /FAIL/); assert.match(detail, /checkin ok \(not_live\)/); @@ -3141,6 +3142,37 @@ test("!stats: own levels that count, unspent points and the pet; @name for someo assert.match(await stats("u1", "alice", "nobody"), /nobody/); }); +test("!changelog: the newest release and the changelog link, from the bot and StreamElements, even while paused", async () => { + const line = (origin: string) => + `PixFray ${LATEST_RELEASE.version}: ${LATEST_RELEASE.name}. Every change: ${origin}/changelog/`; + const bot = botRoom({}); + await bot.connectChat("sub-bot"); + assert.equal((await bot.say("u1", "alice", "!changelog")).reply, line("https://staging.example")); + const r = room({}, { quick: true }); + const se = (await r.call("/admin")).body.streamelements; + assert.equal(se.names.changelog, "!changelog"); + const paused = r.readState("nesszerra"); + paused.config.enabled = false; + r.writeState(paused); + const answer = await r.call("/se?origin=https%3A%2F%2Ftest.example", { + method: "POST", + body: { + key: se.secret, + action: "changelog", + userId: "u9", + username: "nofighter", + displayName: "nofighter", + target: "", + messageId: "c1", + }, + }); + assert.equal( + answer.body.reply, + line("https://test.example"), + "no fighter needed, and it works while duels are paused", + ); +}); + test("!resetpair @a @b: mods start a pair's ranked count for this stream over; the mod page does the same", async () => { const r = botRoom({}); await r.save("u1", "alice"); diff --git a/tests/dev-api.test.ts b/tests/dev-api.test.ts index 838a787..691b3e8 100644 --- a/tests/dev-api.test.ts +++ b/tests/dev-api.test.ts @@ -360,7 +360,7 @@ test("channels lists the registry without reading any room, and progress answers ); assert.equal(p.overlays, 0); assert.equal(p.source, ""); - assert.equal(p.commands, 13); + assert.equal(p.commands, 14); assert.equal(p.commandsWorking, 0); }); @@ -404,7 +404,7 @@ test("progress covers more than 40 channels across batched calls, 40 room reads Object.assign(seen, r.body.progress); } assert.deepEqual(Object.keys(seen).sort(), [...on].sort()); - assert.equal(seen.streamer_050.commands, 13); + assert.equal(seen.streamer_050.commands, 14); }); test("progress validates logins: required, at most 40, only channels that are on", async () => {