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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ 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.9.0 - 2026-10-20

- Level sync: `syncPair` in `server/upgrades.ts` (`SYNC_MARGIN` 10) and `duelStats` in `server/game.ts`, used for
`duel.maxHp`, quick-duel swings and HP-fight blows.
- Every third ranked loss is an upgrade point (`LOSSES_PER_POINT`). `pointsFor`, `effectiveStats`, `validStats` and
`upgradeRules` take one `Earned` object instead of separate wins and bonus arguments.

## 0.8.1 - 2026-10-20

- The changelog splits in two: `/changelog/` keeps only what players and streamers notice, and this file holds code
Expand Down
12 changes: 11 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.8.1</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.9.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.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>
<li><a href="#v0.6.2">0.6.2 Small fixes</a></li>
Expand All @@ -36,6 +37,15 @@ <h1>What's new in PixFray</h1>
</ol></nav>

<div>
<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>
<ul>
<li><span class="badge">Changed</span> Level sync keeps every duel winnable. When one fighter's upgrades cost more than 10 points above the other's, the stronger fighter plays that duel with levels taken off their highest stats until the gap is 10. Saved builds don't change.</li>
<li><span class="badge">New</span> Every third ranked loss gives an upgrade point, counting the losses you already have.</li>
</ul>
</section>

<section class="release" id="v0.8.0" aria-labelledby="v0.8.0-title">
<h2 id="v0.8.0-title"><span class="num">0.8.0</span> Channel point rewards</h2>
<p class="meta"><time datetime="2026-10-20">20 October 2026</time></p>
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ Viewer sprites (server/sprites.ts):
ranked again next stream."
- An active duel with no action for `inactivityMs` is cancelled without scoring. Cancelled duels
return both players to maxHp.
- Upgrades (server/upgrades.ts): every win earns one point and check-ins add more (`bonus`), with no cap. Points buy levels in power (damage dealt), guard (damage taken), luck (the chance a quick-duel miss lands as a hit), crit (the chance a hit becomes a crit; in an HP fight, that a strike or heavy deals +50%), dodge (the chance a quick-duel hit or crit on you misses), counter (quick duels: +counter damage, and the chance a foe's miss becomes your counter) and vitality (+4 max hp a level, ceiling +60, in both duel kinds; the duel keeps each fighter's full hp in `duel.maxHp`, heals cap there, a flawless win needs it, and blows stay sized on the channel's base `maxHp`). Levels 1-8 cost 1 point each and give 4% each (counter: 2% damage and 2% chance); the cost doubles every 8 levels (9-16 cost 2, 17-24 cost 4), and above level 8 the effect closes in on a ceiling (power, guard and counter damage 60%, luck, crit, dodge and counter chance 50%; a 4% stat at level 16 gives about 44.2%). A quick-duel swing checks, in order and each with its own roll (`rolls[k * 24 + i]`, slot k: die 0, luck 1, crit 2, dodge 3, counter 4; the room sends 120 rolls): the attacker's luck, the attacker's crit, the defender's dodge, the defender's counter. Stored levels stop at 200. The channel's `maxStatLevel` caps the levels that count in its duels; levels above it stay saved. A rank reset that leaves too few points takes levels off vitality, then counter, dodge, crit, luck, guard and power. Profiles carry `bonus` (check-in points), `checkins` and `streak`; the profile answer's `upgrades` has `points`, `fromWins`, `fromCheckins` and `maxLevel`. The fighter page offers suggested builds (`PRESETS`) that fill the levels from the points; the viewer still saves. Points can be moved while the fighter is not in a duel. Hats are cosmetic; some unlock at a number of wins. A rank reset clears Elo and wins but keeps `bonus`.
- Upgrades (server/upgrades.ts): every win earns one point, every `LOSSES_PER_POINT` (3) ranked losses earn one, and check-ins add more (`bonus`), with no cap. `pointsFor`, `effectiveStats`, `validStats` and `upgradeRules` take the earned counts as one object (`{wins, bonus, losses}`, a profile or player fits). Points buy levels in power (damage dealt), guard (damage taken), luck (the chance a quick-duel miss lands as a hit), crit (the chance a hit becomes a crit; in an HP fight, that a strike or heavy deals +50%), dodge (the chance a quick-duel hit or crit on you misses), counter (quick duels: +counter damage, and the chance a foe's miss becomes your counter) and vitality (+4 max hp a level, ceiling +60, in both duel kinds; the duel keeps each fighter's full hp in `duel.maxHp`, heals cap there, a flawless win needs it, and blows stay sized on the channel's base `maxHp`). Levels 1-8 cost 1 point each and give 4% each (counter: 2% damage and 2% chance); the cost doubles every 8 levels (9-16 cost 2, 17-24 cost 4), and above level 8 the effect closes in on a ceiling (power, guard and counter damage 60%, luck, crit, dodge and counter chance 50%; a 4% stat at level 16 gives about 44.2%). A quick-duel swing checks, in order and each with its own roll (`rolls[k * 24 + i]`, slot k: die 0, luck 1, crit 2, dodge 3, counter 4; the room sends 120 rolls): the attacker's luck, the attacker's crit, the defender's dodge, the defender's counter. Stored levels stop at 200. The channel's `maxStatLevel` caps the levels that count in its duels; levels above it stay saved. A rank reset that leaves too few points takes levels off vitality, then counter, dodge, crit, luck, guard and power. Profiles carry `bonus` (check-in points), `checkins` and `streak`; the profile answer's `upgrades` has `points`, `fromWins`, `fromCheckins`, `fromLosses`, `lossesPerPoint`, `syncMargin` and `maxLevel`. Level sync (`syncPair`, `SYNC_MARGIN` 10): every duel (quick or HP, ranked or not) takes both fighters' effective stats (after `maxStatLevel` and the pet boost); when one build's `spentOn` is more than 10 above the other's, it drops levels from its highest stat (ties: the last in `STATS`) until it is at most 10 above. The synced stats set `duel.maxHp` and every blow and check; saved builds, `!stats` and the fighter page show the unsynced levels. The fighter page offers suggested builds (`PRESETS`) that fill the levels from the points; the viewer still saves. Points can be moved while the fighter is not in a duel. Hats are cosmetic; some unlock at a number of wins. A rank reset clears Elo and wins but keeps `bonus`.
- Dollars: every finished ranked duel pays `winDollars` to the winner and `lossDollars` to the loser, saved fighters only (a `pair_cap` duel pays half, other unrated duels nothing). game.ts records it as `duel.payout` and the room adds it to `profiles.dollars` once, in the same transaction (`duel.paid`). Like Elo, a payout stays out of `!wallet`, `/api/profile` and the leaderboard until the stream has shown the fight. Profiles carry `dollars` (at most 1,000,000). Check-ins give points, not dollars. A rank reset, a profile save and a re-save keep dollars; removing a viewer deletes them.
- `!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`.
Expand Down
6 changes: 5 additions & 1 deletion docs/DUELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The duel is rolled at once, the moment the challenge is accepted.

## Upgrades, hats and check-ins

- Each win earns an upgrade point and unlocks hats. Points have no cap. Points buy levels in seven stats:
- Each win earns an upgrade point and unlocks hats, and every 3rd ranked loss earns a point too. Points have no cap. Points buy levels in seven stats:

| Stat | Each level (levels 1-8) | Ceiling |
|---|---|---|
Expand All @@ -52,6 +52,10 @@ The duel is rolled at once, the moment the challenge is accepted.
- Levels 1-8 of a stat cost 1 point each; the cost doubles every 8 levels after that, and each level
adds less as the stat nears its ceiling. Mods set the highest level that counts on their channel
(16 by default); levels above it stay saved.
- Level sync: when one fighter's levels (pet boost included) cost more than 10 points above the
other's, the stronger fighter plays that duel with levels taken off their highest stat until the gap
is 10. It applies to every duel, ranked or not, quick or HP, and never changes a saved build. A
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.
- `!pet` shows a fighter's pet. `!stats` (or `!stats @name`) shows the upgrade levels that count, unspent points and the pet's boost.
Expand Down
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ <h1 id="hero-title" data-channel>Your fighter in %SITE_CHANNEL%'s chat</h1>
</fieldset>
<fieldset id="upgrades">
<legend>Upgrades <span class="muted small" id="points-note"></span></legend>
<p class="muted small" id="upgrades-help">Each win and each stream check-in (!checkin in chat) gives a point. Move points between stats whenever you're not in a duel.</p>
<p class="muted small" id="upgrades-help">Each win, each stream check-in (!checkin in chat) and every 3 losses give a point. Move points between stats whenever you're not in a duel.</p>
<div class="upgrade-list" id="upgrade-list"></div>
<div class="filter-buttons" id="upgrade-presets" role="group" aria-label="Suggested builds" hidden></div>
</fieldset>
Expand Down Expand Up @@ -242,6 +242,7 @@ <h3>Duel rules</h3>
<li>After 12 rolls the fighter with more health wins. If health is level, the next hit or counter decides it.</li>
<li>A win without taking any damage is flawless and earns 3 extra Elo.</li>
<li>Upgrades change the numbers: each Power point adds 4% to your blows, each Guard point takes 4% off blows you take, and each Luck point gives a 4% chance that a miss still hits.</li>
<li>Level sync keeps every duel winnable. When one fighter's levels cost more than 10 points above the other's, the stronger fighter plays this duel with levels taken off their highest stats until the gap is 10. Saved builds don't change.</li>
</ul>
<p class="caveat">The loser is knocked out for a few seconds, and the same two viewers wait <span id="cmd-rematch">30 s</span> before a rematch.</p>
</section>
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.8.1",
"version": "0.9.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
31 changes: 17 additions & 14 deletions server/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ export class ChannelRoom extends DurableObject<Env> {
return json({
...profile,
...(active ? { hp: active.hp, lastSeen: active.lastSeen, respawnAt: active.respawnAt } : {}),
upgrades: upgradeRules(profile.wins, profile.bonus, state.config.maxStatLevel),
upgrades: upgradeRules(profile, state.config.maxStatLevel),
owned: this.owned(userId),
builds: this.builds(profile),
});
Expand Down Expand Up @@ -945,7 +945,7 @@ export class ChannelRoom extends DurableObject<Env> {
return json({
profile: {
...shown,
upgrades: upgradeRules(shown.wins, shown.bonus, result.state.config.maxStatLevel),
upgrades: upgradeRules(shown, result.state.config.maxStatLevel),
owned: this.owned(actorId),
builds: this.builds(shown),
},
Expand Down Expand Up @@ -1404,8 +1404,11 @@ export class ChannelRoom extends DurableObject<Env> {
const existing = this.getProfile(userId, state.config);
// Upgrades and hat: optional in the request (left out = keep the saved ones), checked against the wins the stream
// has shown, so a save can't reveal a duel result the overlay hasn't played yet.
const wins = Math.max(0, (existing?.wins || 0) - (hiddenResults(state, now).get(userId)?.wins || 0));
const stats = input.stats === undefined ? existing?.stats : validStats(input.stats, wins, existing?.bonus || 0);
const hidden = hiddenResults(state, now).get(userId);
const wins = Math.max(0, (existing?.wins || 0) - (hidden?.wins || 0)),
losses = Math.max(0, (existing?.losses || 0) - (hidden?.losses || 0));
const stats =
input.stats === undefined ? existing?.stats : validStats(input.stats, { wins, bonus: existing?.bonus, losses });
if (stats === null) return { ok: false, reason: "invalid_upgrades" };
const hat = input.hat === undefined ? existing?.hat || "" : input.hat;
const owns = (kind: string, id: string) =>
Expand Down Expand Up @@ -2573,6 +2576,7 @@ export class ChannelRoom extends DurableObject<Env> {
Pick<
ProfileTable,
| "wins"
| "losses"
| "bonus_points"
| "streak"
| "last_stream"
Expand All @@ -2587,7 +2591,7 @@ export class ChannelRoom extends DurableObject<Env> {
| "vitality"
>
>(
"SELECT wins, bonus_points, streak, last_stream, last_stream_seq, free_miss_at, power, guard, luck, crit, dodge, counter, vitality FROM profiles WHERE user_id = ?",
"SELECT wins, losses, bonus_points, streak, last_stream, last_stream_seq, free_miss_at, power, guard, luck, crit, dodge, counter, vitality FROM profiles WHERE user_id = ?",
userId,
)
.toArray()[0];
Expand All @@ -2612,9 +2616,10 @@ export class ChannelRoom extends DurableObject<Env> {
const points = config.checkinPoints + extra;
const bonus = Math.min(MAX_BONUS, row.bonus_points + points);
// The total counts only wins the stream has shown, like !wallet: a quick duel still playing doesn't give it away.
const wins = row.wins - (hiddenResults(state, now).get(userId)?.wins || 0);
const total = pointsFor(wins, bonus),
unspent = Math.max(0, total - spentOn(effectiveStats(row, wins, bonus)));
const hidden = hiddenResults(state, now).get(userId);
const earned = { wins: row.wins - (hidden?.wins || 0), bonus, losses: row.losses - (hidden?.losses || 0) };
const total = pointsFor(earned),
unspent = Math.max(0, total - spentOn(effectiveStats(row, earned)));
if (test) return { reason: "checked_in", test: true, streak, points, milestone, freeMiss, total, unspent };
sql.exec(
"UPDATE profiles SET bonus_points = ?, checkins = checkins + 1, streak = ?, last_stream = ?, last_stream_seq = ?, free_miss_at = ? WHERE user_id = ?",
Expand All @@ -2640,9 +2645,7 @@ export class ChannelRoom extends DurableObject<Env> {
// shownProfile passes null (no fighter) through
const profile =
userId && shownProfile(this.getProfile(userId, state.config) as Profile, hiddenResults(state, Date.now()));
return profile
? { dollars: profile.dollars, points: pointsFor(profile.wins, profile.bonus), streak: profile.streak }
: null;
return profile ? { dollars: profile.dollars, points: pointsFor(profile), streak: profile.streak } : null;
}

// !give @name amount: dollars from one saved fighter to another, only while the channel is live. Limits (config):
Expand Down Expand Up @@ -2919,11 +2922,11 @@ export class ChannelRoom extends DurableObject<Env> {
const found = userId ? this.getProfile(userId, state.config) : this.getProfileByUsername(username, state.config);
if (!found) return null;
const profile = shownProfile(found, hiddenResults(state, Date.now()));
const own = effectiveStats(profile.stats, profile.wins, profile.bonus);
const own = effectiveStats(profile.stats, profile);
return {
name: profile.displayName || profile.username,
stats: effectiveStats(own, profile.wins, profile.bonus, null, state.config.maxStatLevel),
unspent: Math.max(0, pointsFor(profile.wins, profile.bonus) - spentOn(own)),
stats: effectiveStats(own, profile, null, state.config.maxStatLevel),
unspent: Math.max(0, pointsFor(profile) - spentOn(own)),
petBoost: profile.petBoost,
rankedLeft: asker && asker !== profile.userId ? pairRankedLeft(state, asker, profile.userId) : null,
};
Expand Down
Loading
Loading