Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion changelog/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
<main class="page">
<header class="page-header">
<h1>What's new in PixFray</h1>
<p class="subtitle">What changed for players and streamers, newest first. PixFray is on version <span class="num" id="current-version">0.9.0</span>. Code changes are listed in <code>CHANGELOG.md</code> in the PixFray source.</p>
<p class="subtitle">What changed for players and streamers, newest first. PixFray is on version <span class="num" id="current-version">0.10.0</span>. Code changes are listed in <code>CHANGELOG.md</code> in the PixFray source.</p>
</header>

<div class="with-index changelog">
<nav class="page-index" aria-label="Releases"><ol>
<li><a href="#v0.10.0">0.10.0 Changelog in chat</a></li>
<li><a href="#v0.9.0">0.9.0 Fairer duels</a></li>
<li><a href="#v0.8.0">0.8.0 Channel point rewards</a></li>
<li><a href="#v0.7.0">0.7.0 Deeper fighter stats</a></li>
Expand All @@ -37,6 +38,14 @@ <h1>What's new in PixFray</h1>
</ol></nav>

<div>
<section class="release" id="v0.10.0" aria-labelledby="v0.10.0-title">
<h2 id="v0.10.0-title"><span class="num">0.10.0</span> Changelog in chat</h2>
<p class="meta"><time datetime="2026-10-20">20 October 2026</time></p>
<ul>
<li><span class="badge">New</span> <code>!changelog</code> in chat names the newest PixFray release and links to this page. It works while duels are paused and needs no fighter.</li>
</ul>
</section>

<section class="release" id="v0.9.0" aria-labelledby="v0.9.0-title">
<h2 id="v0.9.0-title"><span class="num">0.9.0</span> Fairer duels</h2>
<p class="meta"><time datetime="2026-10-20">20 October 2026</time></p>
Expand Down
3 changes: 2 additions & 1 deletion docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<userId>` 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 <channel> fighter" when the viewer has no fighter on this channel | 401, 404 any other subpath or method |
Expand Down Expand Up @@ -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<Tier>` 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: <origin>/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.
Expand Down
1 change: 1 addition & 0 deletions docs/DUELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions docs/STREAMER_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ <h3>Commands</h3>
<tr><td><code>!elo</code></td><td>Posts your Elo, rank and record in chat. Add <code>@viewer</code> to check someone else.</td></tr>
<tr><td><code>!ranks</code></td><td>Posts the top 5 of the leaderboard in chat.</td></tr>
<tr><td><code>!fray</code></td><td>Posts how to join, with a link to this page.</td></tr>
<tr><td><code>!changelog</code></td><td>Posts the newest PixFray version and a link to everything that changed.</td></tr>
</tbody>
</table></div>
<p class="hint" id="hp-mode-note" hidden>Duels started from Twitch chat on this channel use the HP fight right now: after <code>!fight</code>, both fighters trade <code>!attack</code>, <code>!strike</code>, <code>!heavy</code> and <code>!heal</code> until one runs out of health. StreamElements commands always use the die rolls.</p>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mini-chat",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
3 changes: 3 additions & 0 deletions server/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
seReplyText,
seTopText,
seEloText,
seChangelogText,
seHelpText,
seLookText,
seCheckinText,
Expand Down Expand Up @@ -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"]],
Expand Down Expand Up @@ -2438,6 +2440,7 @@ export class ChannelRoom extends DurableObject<Env> {
}
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({
Expand Down
3 changes: 3 additions & 0 deletions server/release.ts
Original file line number Diff line number Diff line change
@@ -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" };
11 changes: 10 additions & 1 deletion server/streamelements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -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!";
Expand Down Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1984,6 +1984,7 @@ const SE_LABELS: Record<string, string | undefined> = {
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",
Expand Down
6 changes: 6 additions & 0 deletions tests/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = /<h2[^>]*><span class="num">([^<]+)<\/span> ([^<]+)<\/h2>/.exec(html);
assert.deepEqual(heading?.slice(1), [LATEST_RELEASE.version, LATEST_RELEASE.name]);
});
Loading
Loading