From 8d1eb7896e3d3fa237a7613fa08b1768647b83d5 Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:53:47 +0700 Subject: [PATCH] Level sync and a point per three losses (0.9.0) In every duel the stronger build drops levels until it costs at most 10 points more than the weaker one (syncPair in server/upgrades.ts, used for max HP, quick-duel swings and HP-fight blows). Saved builds are untouched. Every third ranked loss now gives an upgrade point, counted from the losses total. Rules tab, DUELS.md, CONTRACTS.md and the changelog explain both. --- CHANGELOG.md | 7 ++++ changelog/index.html | 12 +++++- docs/CONTRACTS.md | 2 +- docs/DUELS.md | 6 ++- index.html | 3 +- package.json | 2 +- server/channel.ts | 31 ++++++++------- server/game.ts | 39 ++++++++++--------- server/upgrades.ts | 56 +++++++++++++++++++++++----- src/dashboard.ts | 21 +++++++---- tests/channel.test.ts | 4 +- tests/game.test.ts | 87 +++++++++++++++++++++++++++++++++---------- 12 files changed, 193 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0288d42..bb411e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/changelog/index.html b/changelog/index.html index f46046f..47fdac6 100644 --- a/changelog/index.html +++ b/changelog/index.html @@ -18,11 +18,12 @@
+
+

0.9.0 Fairer duels

+

+
    +
  • Changed 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.
  • +
  • New Every third ranked loss gives an upgrade point, counting the losses you already have.
  • +
+
+

0.8.0 Channel point rewards

diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 42c979e..69894e9 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -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` 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`. diff --git a/docs/DUELS.md b/docs/DUELS.md index fcdd710..3b6d2d8 100644 --- a/docs/DUELS.md +++ b/docs/DUELS.md @@ -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 | |---|---|---| @@ -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. diff --git a/index.html b/index.html index 1950554..e8eee1d 100644 --- a/index.html +++ b/index.html @@ -134,7 +134,7 @@

Your fighter in %SITE_CHANNEL%'s chat

Upgrades -

Each win and each stream check-in (!checkin in chat) gives a point. Move points between stats whenever you're not in a duel.

+

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.

@@ -242,6 +242,7 @@

Duel rules

  • After 12 rolls the fighter with more health wins. If health is level, the next hit or counter decides it.
  • A win without taking any damage is flawless and earns 3 extra Elo.
  • 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.
  • +
  • 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.
  • The loser is knocked out for a few seconds, and the same two viewers wait 30 s before a rematch.

    diff --git a/package.json b/package.json index 54da053..f75fde2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mini-chat", - "version": "0.8.1", + "version": "0.9.0", "private": true, "license": "MIT", "type": "module", diff --git a/server/channel.ts b/server/channel.ts index 3534c45..06b4765 100644 --- a/server/channel.ts +++ b/server/channel.ts @@ -907,7 +907,7 @@ export class ChannelRoom extends DurableObject { 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), }); @@ -945,7 +945,7 @@ export class ChannelRoom extends DurableObject { 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), }, @@ -1404,8 +1404,11 @@ export class ChannelRoom extends DurableObject { 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) => @@ -2573,6 +2576,7 @@ export class ChannelRoom extends DurableObject { Pick< ProfileTable, | "wins" + | "losses" | "bonus_points" | "streak" | "last_stream" @@ -2587,7 +2591,7 @@ export class ChannelRoom extends DurableObject { | "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]; @@ -2612,9 +2616,10 @@ export class ChannelRoom extends DurableObject { 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 = ?", @@ -2640,9 +2645,7 @@ export class ChannelRoom extends DurableObject { // 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): @@ -2919,11 +2922,11 @@ export class ChannelRoom extends DurableObject { 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, }; diff --git a/server/game.ts b/server/game.ts index c77a157..315ad09 100644 --- a/server/game.ts +++ b/server/game.ts @@ -8,6 +8,7 @@ import { knownHat, scaledDamage, statValue, + syncPair, HP_CRIT_BONUS, } from "./upgrades.ts"; import { cleanBoost, TIERS } from "./pets.ts"; @@ -528,12 +529,11 @@ function beginDuel(state: GameState, duel: Duel, now: number, event: { newAccoun duel.status = "active"; duel.startedAt = now; duel.lastActionAt = now; - // Each fighter's max HP: the channel's plus their vitality. - const vitality = (p: Player): number => - effectiveStats(p.stats, p.wins, p.bonus, p.petBoost, state.config.maxStatLevel).vitality; + // Each fighter's max HP: the channel's plus their vitality after level sync. + const synced = duelStats(state, duel); duel.maxHp = { - [duel.a]: state.config.maxHp + bonusHp(vitality(a)), - [duel.b]: state.config.maxHp + bonusHp(vitality(b)), + [duel.a]: state.config.maxHp + bonusHp(synced[duel.a]!.vitality), + [duel.b]: state.config.maxHp + bonusHp(synced[duel.b]!.vitality), }; duel.hp = clone(duel.maxHp); duel.rules = { @@ -563,6 +563,16 @@ function beginDuel(state: GameState, duel: Duel, now: number, event: { newAccoun return { ok: true, duelId: duel.id }; } +// Both fighters' stats in this duel: their effective stats, then level sync (server/upgrades.ts syncPair). +function duelStats(state: GameState, duel: Duel): Record { + const of = (id: string): Stats => { + const p = player(state, id); + return effectiveStats(p?.stats, p ?? {}, p?.petBoost, state.config.maxStatLevel); + }; + const [a, b] = syncPair(of(duel.a), of(duel.b)); + return { [duel.a]: a, [duel.b]: b }; +} + // A fighter's full health in a started duel: their own max HP, or the rules' for a duel started before vitality. // rules!: only started duels reach here, and beginDuel sets rules const fullHp = (duel: Duel, userId: string): number => duel.maxHp?.[userId] ?? duel.rules!.maxHp; @@ -600,18 +610,7 @@ function settleQuickDuel(state: GameState, duel: Duel, rolls: unknown, now: numb const maxHp = duel.rules!.maxHp; const blow = { hit: Math.round(maxHp * 0.34), crit: Math.round(maxHp * 0.5), counter: Math.round(maxHp * 0.34) }; const look = (id: string): string => player(state, id)?.defaultAbility || "strike"; // cosmetic: picks the attack effect on the overlay - const stats = Object.fromEntries( - [duel.a, duel.b].map((id) => [ - id, - effectiveStats( - player(state, id)?.stats, - player(state, id)?.wins, - player(state, id)?.bonus, - player(state, id)?.petBoost, - state.config.maxStatLevel, - ), - ]), - ); + const stats = duelStats(state, duel); const swings: Swing[] = []; let attacker = duel.a, defender = duel.b, @@ -842,8 +841,8 @@ function applyAbility( amount = duel.hp[actor.userId]! - before; } else { const before = duel.hp[targetId]!; - const foe = player(state, targetId); - const mine = effectiveStats(actor.stats, actor.wins, actor.bonus, actor.petBoost, state.config.maxStatLevel); + const synced = duelStats(state, duel); + const mine = synced[actor.userId]!; const first: unknown = Array.isArray(rolls) ? rolls[0] : undefined; const roll = typeof first === "number" && first >= 0 && first < 1 @@ -854,7 +853,7 @@ function applyAbility( // not the heal branch, so ability is abilities.strike or abilities.heavy Math.round((ability as Abilities["strike"]).damage * (crit ? 1 + HP_CRIT_BONUS : 1)), mine, - effectiveStats(foe?.stats, foe?.wins, foe?.bonus, foe?.petBoost, state.config.maxStatLevel), + synced[targetId], ); duel.hp[targetId] = Math.max(0, before - damage); amount = before - duel.hp[targetId]; diff --git a/server/upgrades.ts b/server/upgrades.ts index ea4cc52..fe1dc96 100644 --- a/server/upgrades.ts +++ b/server/upgrades.ts @@ -63,8 +63,14 @@ export const HATS: Hat[] = [ const HAT_WINS = new Map(HATS.map((h) => [h.id, h.wins])); const whole = (n: unknown): number => (Number.isInteger(n) && n > 0 ? n : 0); -// Points from wins plus check-in (bonus) points. -export const pointsFor = (wins: unknown, bonus: unknown = 0): number => whole(wins) + whole(bonus); +/** What a fighter has earned upgrade points from: a profile, a player, or the numbers as the stream has shown them. */ +export type Earned = { wins?: unknown; bonus?: unknown; losses?: unknown }; +// Every LOSSES_PER_POINT-th ranked loss gives a point, so a fighter who keeps losing still grows. +export const LOSSES_PER_POINT = 3; +const fromLosses = (losses: unknown): number => Math.floor(whole(losses) / LOSSES_PER_POINT); +// Points from wins, check-in (bonus) points and losses. +export const pointsFor = (earned: Earned): number => + whole(earned.wins) + whole(earned.bonus) + fromLosses(earned.losses); // Points the level-th level of a stat costs: 1 for levels 1-8, then doubling every 8 levels. export const levelCost = (level: number): number => 2 ** Math.floor((Math.max(1, level) - 1) / FULL_STEP_LEVELS); @@ -115,13 +121,12 @@ export function cleanStats(input?: unknown): Stats { // boost (the active pet's, server/pets.ts) is added after both, so it may take a stat past the cap. export function effectiveStats( stats: unknown, - wins: unknown, - bonus: unknown = 0, + earned: Earned, boost: LooseStats | null = null, maxLevel = LEVEL_LIMIT, ): Stats { const out = cleanStats(stats), - points = pointsFor(wins, bonus); + points = pointsFor(earned); let spent = spentOn(out); for (const key of [...STATS].reverse()) while (spent > points && out[key] > 0) spent -= levelCost(out[key]--); for (const key of STATS) out[key] = Math.min(out[key], Math.max(0, maxLevel)); @@ -129,8 +134,30 @@ export function effectiveStats( return out; } +// Level sync: in a duel the stronger build drops levels until it costs at most SYNC_MARGIN points more than the +// weaker one, so every duel stays winnable. Both builds are the duel's effective stats, pet boost included. Levels +// come off the highest stat first (ties: the last in STATS). It only changes the fight; saved builds stay as they are. +export const SYNC_MARGIN = 10; +export function syncStats(stats: Stats, budget: number): Stats { + const out = { ...stats }; + let spent = spentOn(out); + while (spent > budget) { + const top = STATS.reduce((best, key) => (out[key] >= out[best] ? key : best), STATS[0]); + if (out[top] === 0) break; + spent -= levelCost(out[top]--); + } + return out; +} +export function syncPair(a: Stats, b: Stats): [Stats, Stats] { + const costA = spentOn(a), + costB = spentOn(b); + if (costA > costB + SYNC_MARGIN) return [syncStats(a, costB + SYNC_MARGIN), b]; + if (costB > costA + SYNC_MARGIN) return [a, syncStats(b, costA + SYNC_MARGIN)]; + return [a, b]; +} + // For saving: null when the build spends more points than the fighter has or isn't whole levels. -export function validStats(input: unknown, wins: unknown, bonus: unknown = 0): Stats | null { +export function validStats(input: unknown, earned: Earned): Stats | null { if (input === undefined || input === null) return emptyStats(); if (typeof input !== "object" || Array.isArray(input)) return null; const out = emptyStats(); @@ -140,7 +167,7 @@ export function validStats(input: unknown, wins: unknown, bonus: unknown = 0): S if (!Number.isInteger(v) || v < 0 || v > LEVEL_LIMIT) return null; out[key] = v; } - return spentOn(out) <= pointsFor(wins, bonus) ? out : null; + return spentOn(out) <= pointsFor(earned) ? out : null; } /** A suggested build: how much each stat weighs. */ @@ -205,18 +232,27 @@ export type UpgradeRules = { points: number; fromWins: number; fromCheckins: number; + /** Points from losses: one per LOSSES_PER_POINT ranked losses. */ + fromLosses: number; + lossesPerPoint: number; + /** Level sync: how many points' worth of levels the stronger fighter keeps above the weaker one. */ + syncMargin: number; step: number; hats: (Hat & { unlocked: boolean })[]; }; // Rules for the dashboard, sent with the profile so the page never hard-codes them. -export function upgradeRules(wins: unknown, bonus: unknown = 0, maxLevel = LEVEL_LIMIT): UpgradeRules { +export function upgradeRules(earned: Earned, maxLevel = LEVEL_LIMIT): UpgradeRules { + const wins = earned.wins; return { stats: STATS, maxLevel, - points: pointsFor(wins, bonus), + points: pointsFor(earned), fromWins: whole(wins), - fromCheckins: whole(bonus), + fromCheckins: whole(earned.bonus), + fromLosses: fromLosses(earned.losses), + lossesPerPoint: LOSSES_PER_POINT, + syncMargin: SYNC_MARGIN, step: STAT_STEP, hats: HATS.map((h) => ({ ...h, unlocked: (Number.isInteger(wins) ? wins : 0) >= h.wins })), }; diff --git a/src/dashboard.ts b/src/dashboard.ts index 8b409a5..a621432 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -276,7 +276,7 @@ const REASONS: Record = { }; const str = (v: unknown) => (typeof v === "string" ? v : ""); -const rules = () => upgradeRules(state.profile?.wins || 0, state.profile?.bonus || 0, state.config?.maxStatLevel); +const rules = () => upgradeRules(state.profile ?? {}, state.config?.maxStatLevel); // A saved build (or profile) as an editable loadout, with only ids this page knows. function loadoutOf(b: Build | null | undefined): Loadout { const p = state.profile; @@ -286,7 +286,7 @@ function loadoutOf(b: Build | null | undefined): Loadout { // b!.color!: the test matched b?.color, so b and its color are set color: /^#[0-9a-f]{6}$/i.test(b?.color || "") ? b!.color!.toLowerCase() : DEFAULT_COLOR, defaultAbility: str(b?.defaultAbility) || p?.defaultAbility || "strike", // only used by the HP fight - stats: effectiveStats(b?.stats, p?.wins || 0, p?.bonus || 0), + stats: effectiveStats(b?.stats, p ?? {}), hat: str(b?.hat), pet: str(b?.pet), // as: Object.fromEntries types its keys as string; they are LOOK_FIELDS @@ -969,16 +969,23 @@ function renderUpgrades() { const left = r.points - spentOn(state.d.stats); // element type from the page HTML ($("#points-note") as HTMLSpanElement).textContent = canEdit ? "(" + left + " of " + r.points + " points free)" : ""; - const from = r.fromCheckins - ? " You have " + r.fromWins + " from wins and " + r.fromCheckins + " from check-ins." - : ""; + const parts = [ + r.fromWins + " from wins", + r.fromCheckins ? r.fromCheckins + " from check-ins" : "", + r.fromLosses ? r.fromLosses + " from losses" : "", + ].filter(Boolean); + const from = parts.length > 1 ? " You have " + parts.slice(0, -1).join(", ") + " and " + parts.at(-1) + "." : ""; ($("#upgrades-help") as HTMLParagraphElement).textContent = canEdit // element type from the page HTML - ? "Each win and each stream check-in (!checkin in chat) gives a point. Levels 1 to 8 cost 1 point each, and the cost doubles every 8 levels after that. Levels above " + + ? "Each win, each stream check-in (!checkin in chat) and every " + + r.lossesPerPoint + + " losses give a point. Levels 1 to 8 cost 1 point each, and the cost doubles every 8 levels after that. Levels above " + r.maxLevel + " don't count on this channel." + from + " Move points between stats whenever you're not in a duel." - : "Save your fighter, then each win and each stream check-in (!checkin in chat) gives an upgrade point."; + : "Save your fighter, then each win, each stream check-in (!checkin in chat) and every " + + r.lossesPerPoint + + " losses give an upgrade point."; const shown = canEdit ? state.d.stats : emptyStats(); const link = $("#points-link") as HTMLAnchorElement; // element type from the page HTML link.hidden = !canEdit || left <= 0; diff --git a/tests/channel.test.ts b/tests/channel.test.ts index 261ed54..a1093ae 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -1786,10 +1786,10 @@ test("pets: tier boosts, added after the upgrade cap", () => { assert.deepEqual(must(petOf("p-blob-1a2b3c", "rare:power:")).boost, fullStats({ power: 2, guard: 0, luck: 0 })); // 8 power at 8 points stays 8 before the pet, then the fox's +2 goes past the per-stat cap. assert.deepEqual( - effectiveStats({ power: 8 }, 8, 0, tierBoost("rare", "power")), + effectiveStats({ power: 8 }, { wins: 8 }, tierBoost("rare", "power")), fullStats({ power: 10, guard: 0, luck: 0 }), ); - assert.deepEqual(effectiveStats({ power: 8 }, 3, 0, null), fullStats({ power: 3, guard: 0, luck: 0 })); + assert.deepEqual(effectiveStats({ power: 8 }, { wins: 3 }, null), fullStats({ power: 3, guard: 0, luck: 0 })); }); test("shop: buy pets and hats with shown dollars, equip on save, !pet, custom pet uploads and deletes", async () => { diff --git a/tests/game.test.ts b/tests/game.test.ts index f274126..c545593 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -1229,9 +1229,12 @@ test("the bot result line says how the duel was won", async () => { // ---------- upgrades (server/upgrades.ts) ---------- test("upgrade points: one per win plus check-in points with no cap; a rank reset trims the last stats first", async () => { const u = await import("../server/upgrades.ts"); - assert.deepEqual(u.effectiveStats({ power: 1, dodge: 1, counter: 2 }, 2), fullStats({ power: 1, dodge: 1 })); assert.deepEqual( - [0, 3, 20, 400].map((w) => u.pointsFor(w)), + u.effectiveStats({ power: 1, dodge: 1, counter: 2 }, { wins: 2 }), + fullStats({ power: 1, dodge: 1 }), + ); + assert.deepEqual( + [0, 3, 20, 400].map((w) => u.pointsFor({ wins: w })), [0, 3, 20, 400], ); assert.deepEqual( @@ -1241,36 +1244,36 @@ test("upgrade points: one per win plus check-in points with no cap; a rank reset [0, 25], [-1, 4], [2, 1.5], - ].map(([w, b]) => u.pointsFor(w, b)), + ].map(([w, b]) => u.pointsFor({ wins: w, bonus: b })), [5, 24, 25, 4, 2], ); - assert.deepEqual(u.validStats({ power: 2, guard: 1 }, 3), fullStats({ power: 2, guard: 1, luck: 0 })); - assert.equal(u.validStats({ power: 2, guard: 2 }, 3), null, "more points than wins"); + assert.deepEqual(u.validStats({ power: 2, guard: 1 }, { wins: 3 }), fullStats({ power: 2, guard: 1, luck: 0 })); + assert.equal(u.validStats({ power: 2, guard: 2 }, { wins: 3 }), null, "more points than wins"); assert.deepEqual( - u.validStats({ power: 2, guard: 2 }, 3, 1), + u.validStats({ power: 2, guard: 2 }, { wins: 3, bonus: 1 }), fullStats({ power: 2, guard: 2, luck: 0 }), "a check-in point covers the fourth", ); - assert.deepEqual(u.validStats({ power: 8 }, 8), fullStats({ power: 8, guard: 0, luck: 0 })); - assert.equal(u.validStats({ power: 9 }, 9), null, "level 9 costs 2 points"); - assert.deepEqual(u.validStats({ power: 9 }, 10), fullStats({ power: 9, guard: 0, luck: 0 })); - assert.equal(u.validStats({ power: 201 }, 1e12), null, "past the level limit"); - assert.equal(u.validStats({ power: 1.5 }, 10), null); + assert.deepEqual(u.validStats({ power: 8 }, { wins: 8 }), fullStats({ power: 8, guard: 0, luck: 0 })); + assert.equal(u.validStats({ power: 9 }, { wins: 9 }), null, "level 9 costs 2 points"); + assert.deepEqual(u.validStats({ power: 9 }, { wins: 10 }), fullStats({ power: 9, guard: 0, luck: 0 })); + assert.equal(u.validStats({ power: 201 }, { wins: 1e12 }), null, "past the level limit"); + assert.equal(u.validStats({ power: 1.5 }, { wins: 10 }), null); assert.deepEqual( - u.effectiveStats(fullStats({ power: 3, guard: 3, luck: 3 }), 5), + u.effectiveStats(fullStats({ power: 3, guard: 3, luck: 3 }), { wins: 5 }), fullStats({ power: 3, guard: 2, luck: 0 }), ); assert.deepEqual( - u.effectiveStats({ power: 3, guard: 3, luck: 3 }, 0, 7), + u.effectiveStats({ power: 3, guard: 3, luck: 3 }, { wins: 0, bonus: 7 }), fullStats({ power: 3, guard: 3, luck: 1 }), "check-in points survive a rank reset", ); assert.deepEqual( - u.effectiveStats({ power: 10, guard: 0, luck: 0 }, 11), + u.effectiveStats({ power: 10, guard: 0, luck: 0 }, { wins: 11 }), fullStats({ power: 9, guard: 0, luck: 0 }), "trimming refunds level 10 at 2 points", ); - const r = u.upgradeRules(4, 3, 16); + const r = u.upgradeRules({ wins: 4, bonus: 3 }, 16); assert.deepEqual([r.points, r.fromWins, r.fromCheckins, r.maxLevel], [7, 4, 3, 16]); assert.equal(u.hatUnlocked("crown", 19), false); assert.equal(u.hatUnlocked("crown", 20), true); @@ -1278,6 +1281,33 @@ test("upgrade points: one per win plus check-in points with no cap; a rank reset assert.equal(u.hatUnlocked("sombrero", 99), false); }); +test("upgrade points: every third loss gives a point", async () => { + const u = await import("../server/upgrades.ts"); + assert.deepEqual( + [0, 1, 2, 3, 4, 5, 6, 7].map((losses) => u.pointsFor({ wins: 2, losses })), + [2, 2, 2, 3, 3, 3, 4, 4], + ); + const r = u.upgradeRules({ wins: 4, bonus: 3, losses: 7 }, 16); + assert.deepEqual([r.points, r.fromWins, r.fromCheckins, r.fromLosses, r.lossesPerPoint], [9, 4, 3, 2, 3]); + assert.deepEqual(u.validStats({ power: 3 }, { wins: 0, losses: 9 }), fullStats({ power: 3, guard: 0, luck: 0 })); + assert.equal(u.validStats({ power: 3 }, { wins: 0, losses: 8 }), null, "8 losses are 2 points"); +}); + +test("level sync: the stronger build drops levels from its highest stat until it costs at most 10 points more", async () => { + const u = await import("../server/upgrades.ts"); + const strong = fullStats({ power: 8, guard: 8, luck: 4 }); + assert.deepEqual(u.syncPair(strong, u.emptyStats()), [fullStats({ power: 4, guard: 3, luck: 3 }), u.emptyStats()]); + assert.deepEqual(u.syncPair(fullStats({ luck: 2 }), strong), [ + fullStats({ power: 0, guard: 0, luck: 2 }), + fullStats({ power: 4, guard: 4, luck: 4 }), + ]); + // Levels past 8 cost 2: 6 points against power 16 (24 points) leaves power 12 (16 points). + assert.deepEqual(u.syncPair(fullStats({ power: 6 }), fullStats({ power: 16 }))[1], fullStats({ power: 12 })); + const near = fullStats({ power: 6, guard: 4 }); + assert.deepEqual(u.syncPair(near, u.emptyStats()), [near, u.emptyStats()], "10 points apart stays as is"); + assert.deepEqual(strong, fullStats({ power: 8, guard: 8, luck: 4 }), "syncPair copies, never changes its input"); +}); + test("upgrade levels: cost doubles every 8 levels and the effect closes in on each stat's ceiling", async () => { const u = await import("../server/upgrades.ts"); assert.deepEqual( @@ -1308,15 +1338,19 @@ test("upgrade levels: cost doubles every 8 levels and the effect closes in on ea test("upgrade levels: a channel's maxStatLevel caps what counts, and a pet's boost goes on top", async () => { const u = await import("../server/upgrades.ts"); assert.deepEqual( - u.effectiveStats({ power: 20, guard: 2 }, 1000, 0, null, 16), + u.effectiveStats({ power: 20, guard: 2 }, { wins: 1000 }, null, 16), fullStats({ power: 16, guard: 2, luck: 0 }), ); assert.deepEqual( - u.effectiveStats({ power: 20 }, 1000, 0, { power: 2, luck: 1 }, 16), + u.effectiveStats({ power: 20 }, { wins: 1000 }, { power: 2, luck: 1 }, 16), fullStats({ power: 18, guard: 0, luck: 1 }), "the boost lands after the cap", ); - assert.deepEqual(u.effectiveStats({ power: 20 }, 1000), fullStats({ power: 20, guard: 0, luck: 0 }), "no cap given"); + assert.deepEqual( + u.effectiveStats({ power: 20 }, { wins: 1000 }), + fullStats({ power: 20, guard: 0, luck: 0 }), + "no cap given", + ); }); test("upgrade presets spread the points by weight within the channel's level cap", async () => { @@ -1352,7 +1386,7 @@ test("upgrade presets spread the points by weight within the channel's level cap ); assert.deepEqual(u.presetStats(preset("brawler"), 0), fullStats({ power: 0, guard: 0, luck: 0 })); const built = u.presetStats(preset("balanced"), 100, 32); - assert.ok(u.spentOn(built) <= 100 && u.validStats(built, 100) !== null); + assert.ok(u.spentOn(built) <= 100 && u.validStats(built, { wins: 100 }) !== null); }); function upgraded(stats: Record>) { @@ -1393,6 +1427,21 @@ test("quick duels: power adds 4% damage per point and guard takes 4% off per poi assert.equal(must(quickWith(w, hit).swings[0]).damage, 34); }); +test("quick duels: level sync plays the stronger build at most 10 points above the weaker one, and saves nothing", () => { + const w = upgraded({ alice: { power: 8, guard: 8, luck: 4 } }); + const alice = must(w.state.players.find((p) => p.username === "alice")); + alice.wins = 20; + // 20 points against 0: alice fights as power 4, guard 3, luck 3, so her hit is 34 x 1.16 and bob's is 34 x 0.88. + const r = quickWith(w, [0.75, 0.75]); + assert.deepEqual( + r.swings.slice(0, 2).map((s) => s.damage), + [39, 30], + ); + assert.deepEqual(alice.stats, fullStats({ power: 8, guard: 8, luck: 4 }), "the saved build is untouched"); + // Within 10 points nothing changes: 5 power against 0 still hits for 41. + assert.equal(must(quickWith(upgraded({ alice: { power: 5 } }), [0.75]).swings[0]).damage, 41); +}); + test("quick duels: luck turns a miss into a hit with 4% per point, from a second roll", () => { const rolls = Array(48).fill(0.9); rolls[0] = 0.5; // alice rolls a 4: a miss