From e72d7440b01dcc08e930ba1f1840a9e4c75c8def Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:26:49 +0700 Subject: [PATCH] Add crit, dodge and counter upgrade stats Three new stats bought with upgrade points. Crit turns a hit into a crit (HP fights: +50% on a strike or heavy). Dodge turns a hit or crit on you into a miss. Counter adds counter damage and turns a foe's miss into your counter. Each check reads its own roll slot, so the room sends 120 rolls. Pets keep boosting power, guard and luck only. The overlay shows DODGE and LUCKY CRIT. --- docs/CONTRACTS.md | 11 ++-- docs/DUELS.md | 14 ++++- docs/STREAMER_SETUP.md | 2 + server/channel.ts | 26 +++++--- server/developer.ts | 3 + server/game.ts | 71 +++++++++++++++++---- server/model.ts | 5 ++ server/pets.ts | 30 ++++----- server/upgrades.ts | 56 ++++++++++++----- src/admin.ts | 2 +- src/dashboard.ts | 19 +++++- src/served/overlay.ts | 26 ++++++-- src/served/parse.ts | 4 +- tests/channel.test.ts | 56 ++++++++++------- tests/fakes.ts | 5 ++ tests/game.test.ts | 137 +++++++++++++++++++++++++++++++++++------ tests/ui.ts | 17 ++++- 17 files changed, 374 insertions(+), 110 deletions(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index f5cbd6c..d509b24 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -81,7 +81,7 @@ everything else is revalidated. | 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}, builds:[Loadout\|null]}` or `null`. `slots` is 1 free plus the bought ones; `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 | -| POST | `/api/profile/:channel` | cookie | `{avatar, color:"#rrggbb", defaultAbility:"strike"\|"heavy"\|"heal", stats?:{power,guard,luck}, hat?, pet?, recolor?, petColor?, accessory?, trail?, winEffect?, taunt?, title?, build?}`, max 4000 bytes. `stats` are checked against the points from shown wins (a duel result the stream hasn't played yet doesn't count) plus check-ins, `hat` against the shown wins or a bought hat (server/upgrades.ts), and `pet` (`""` for none, at most 64 chars) against the pets the fighter owns (`pet_locked`); a change of stats or pet boost while the fighter is in a duel is refused (`in_duel`). Leaving `pet` out keeps the saved pet. Each cosmetic field is `""` (none) or an id from `/api/shop` `items` that the fighter bought (`item_locked`; the one already worn is kept); left out keeps the saved one. `build` (an integer 0 to 4, else a plain 400; a slot not bought yet is `invalid_build`) is the slot the loadout is saved to, and it becomes the active one; left out = the active slot. A save never changes Elo, wins, losses or the last opponent: a fighter off the active list (10 quiet minutes) rejoins with the ones in its saved profile. | `{profile, revision}`; `profile` carries `upgrades`, `owned` and `builds` as in the GET (a build saved with an uploaded pet deleted since comes back with `pet:""`) | 400 invalid field or unknown character, 401, 403 "That sprite belongs to another viewer" (a viewer sprite with another `owner`), 413 | +| POST | `/api/profile/:channel` | cookie | `{avatar, color:"#rrggbb", defaultAbility:"strike"\|"heavy"\|"heal", stats?:{power,guard,luck,crit,dodge,counter}, hat?, pet?, recolor?, petColor?, accessory?, trail?, winEffect?, taunt?, title?, build?}`, max 4000 bytes. `stats` are checked against the points from shown wins (a duel result the stream hasn't played yet doesn't count) plus check-ins, `hat` against the shown wins or a bought hat (server/upgrades.ts), and `pet` (`""` for none, at most 64 chars) against the pets the fighter owns (`pet_locked`); a change of stats or pet boost while the fighter is in a duel is refused (`in_duel`). Leaving `pet` out keeps the saved pet. Each cosmetic field is `""` (none) or an id from `/api/shop` `items` that the fighter bought (`item_locked`; the one already worn is kept); left out keeps the saved one. `build` (an integer 0 to 4, else a plain 400; a slot not bought yet is `invalid_build`) is the slot the loadout is saved to, and it becomes the active one; left out = the active slot. A save never changes Elo, wins, losses or the last opponent: a fighter off the active list (10 quiet minutes) rejoins with the ones in its saved profile. | `{profile, revision}`; `profile` carries `upgrades`, `owned` and `builds` as in the GET (a build saved with an uploaded pet deleted since comes back with `pet:""`) | 400 invalid field or unknown character, 401, 403 "That sprite belongs to another viewer" (a viewer sprite with another `owner`), 413 | | GET | `/api/sprite/:channel` | cookie | none | The viewer's own sprite: `{live, pending, rejected, submitsLeft, aiLeft, ai:{available, left}, limits}`. `live` has `url`; each is `{id, label, status, createdAt, ...}` or `null`. `ai.available` is false where the Worker has no `AI` binding (local dev, tests) | 401, 403 channel off | | GET | `/api/sprite/:channel/pending` | cookie | none | The viewer's waiting sprite PNG (`private, no-store`) | 401, 404 | | POST | `/api/sprite/:channel` | cookie | `{label:1-24 chars, image:, ai?:bool}`; at most 128 by 128 px and 64 KB. The page makes the PNG (src/spritify.ts). The Worker forwards the session's id and name, never ones from the body | 201 `{ok:true, pending}`. Replaces the viewer's waiting or turned-down sprite | 400 `invalid_label` (also a label that is not text), `invalid_image` (also an image that is not text), `invalid_upload` (`ai` not a boolean), `image_dimensions`, 413 `image_too_large`, 415 `not_png`, 401, 409 `queue_full` (40 waiting per channel), 429 `daily_limit` (6 sends per viewer per UTC day) | @@ -249,7 +249,7 @@ Event types and their fields: | `challenge_declined` | duelId, declinedBy | | `challenge_expired` | duelId, a, b | | `duel_started` | duelId, a, b, round, hp | -| `duel_action` | duelId, userId, targetId (the actor for heal), ability, amount, hp. Also sent for the final blow. A quick-duel roll adds die (1-6) and one of miss, crit or counter (userId is then the defender who counters), and finisher on the last blow. | +| `duel_action` | duelId, userId, targetId (the actor for heal), ability, amount, hp. Also sent for the final blow. A quick-duel roll adds die (1-6) and one of miss, crit or counter (userId is then the defender who counters), and finisher on the last blow; upgrades add lucky (luck turned a miss into a hit), luckyCrit (crit turned a hit into a crit) and dodged (the defender's dodge turned a hit or crit into a miss, which a counter may follow). An HP-fight strike or heavy that crits adds crit. | | `duel_completed` | duelId, winnerId, loserId, round, respawnAt, hp, ratings. Quick duels may add flawless (ratings of the winner then include bonus) and decision (`hp` or `sudden_death`); an unrated duel adds `unrated` (`new_account` or `pair_cap`). | | `duel_cancelled` | duelId, a, b, reason (`inactivity`, `chat_disconnected`, `duels_disabled`, `moderator_cancelled`, `moderator_reset`, `player_removed`), wasActive | | `player_respawned` | userId | @@ -433,7 +433,8 @@ Viewer sprites (server/sprites.ts): challenge expires. Either side may `!decline`. Challenging someone who already challenged you accepts their challenge. - A player can be in at most 1 open duel, and the channel holds at most `maxDuels` open duels. - Quick duels are the default (`quickDuel:true`): accepting settles the duel at once (see the quick duel rule below). The HP fight described next runs only when a config sets `quickDuel:false`; no screen does that, so it takes an admin config call. StreamElements has no attack commands, so its duels are always quick. -- HP fight abilities: `strike` and `heavy` deal `damage`; `heal` restores `amount`, capped at maxHp. Each +- HP fight abilities: `strike` and `heavy` deal `damage` (+50% on a crit, rolled with the actor's crit chance); `heal` + restores `amount`, capped at maxHp. Each ability has its own `cooldownMs`, plus a shared cooldown of `sharedCooldownMs` after any action. - The first player to reach 0 hp loses. The winner returns to maxHp. The loser is KO'd until `respawnAt = now + respawnMs`. Elo uses K=`eloK` from `initialElo`. The same pair cannot duel again @@ -449,10 +450,10 @@ Viewer sprites (server/sprites.ts): between them in 24 h already): no Elo or dollars." - 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) and luck (the chance a quick-duel miss lands as a hit). Levels 1-8 cost 1 point each and give 4% each; 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 and guard 60%, luck 50%; level 16 gives about 44.2%). 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 luck, then guard, then 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 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) and counter (quick duels: +counter damage, and the chance a foe's miss becomes your counter). 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 counter, then 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`. - Dollars: every finished ranked duel pays `winDollars` to the winner and `lossDollars` to the loser, saved fighters only. 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, legendary +1 to every stat. 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`. +- 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, legendary +1 to power, guard and luck. Pet boosts use only those three stats. 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. - `!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`. - `!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 b1a497b..375cc6c 100644 --- a/docs/DUELS.md +++ b/docs/DUELS.md @@ -35,7 +35,19 @@ The duel is rolled at once, the moment the challenge is accepted. ## Upgrades, hats and check-ins -- Each win earns an upgrade point (power, guard, luck) and unlocks hats. Points have no cap. +- Each win earns an upgrade point and unlocks hats. Points have no cap. Points buy levels in six stats: + + | Stat | Each level (levels 1-8) | Ceiling | + |---|---|---| + | Power | +4% damage dealt | 60% | + | Guard | -4% damage taken | 60% | + | Luck | 4% of your misses still hit | 50% | + | Crit | 4% of your hits become crits (HP fights: a strike or heavy deals +50%) | 50% | + | Dodge | 4% of hits and crits on you miss | 50% | + | Counter | +2% counter damage, and 2% of a foe's misses become your counter | 60% damage, 50% chance | + +- Luck, dodge and counter work in quick duels only. A swing checks luck, then crit, then dodge, then + counter, so a dodged hit can still turn into your counter. - 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. diff --git a/docs/STREAMER_SETUP.md b/docs/STREAMER_SETUP.md index db33165..ef8f610 100644 --- a/docs/STREAMER_SETUP.md +++ b/docs/STREAMER_SETUP.md @@ -61,6 +61,8 @@ it, click **Connect mod access** and approve the permission while signed in to T Moderators can edit chat commands, use Clear arena, change the balance and review viewer sprites. The Rules tab's Check-ins group has **Highest stat level that counts** (16 by default): fighters' upgrade levels above it stay saved but don't count in your channel's duels. +Viewers spend their points on six stats (power, guard, luck, crit, dodge and counter) on their +fighter page; docs/DUELS.md lists what each one does. Only you (or the site owner) can click **Reset all ranks**, **Turn PixFray off** or move chat from another site. diff --git a/server/channel.ts b/server/channel.ts index 69f5173..1ccd5b7 100644 --- a/server/channel.ts +++ b/server/channel.ts @@ -28,6 +28,7 @@ import { effectiveStats, spentOn, HATS, + STATS, } from "./upgrades.ts"; import { ensurePetSchema, handleRoomPets, petCatalog, petOf, hatPrice } from "./pets.ts"; import { ensureSpriteSchema, handleRoomSprites, spriteCatalog } from "./sprites.ts"; @@ -128,6 +129,9 @@ type ProfileTable = { power: number; guard: number; luck: number; + crit: number; + dodge: number; + counter: number; hat: string; last_opponent: string; bonus_points: number; @@ -363,7 +367,7 @@ const MAX_DOLLARS = 1_000_000; // a wallet never holds more const MAX_GIFT = 10_000; // the most a mod can add or take back in one gift // Every query that builds a profile with normalizeProfileRow reads these columns. const PROFILE_COLUMNS = - "user_id, username, display_name, avatar, color, default_ability, elo, wins, losses, last_seen, power, guard, luck, hat, bonus_points, checkins, streak, dollars, pet, recolor, pet_color, accessory, trail, win_effect, taunt, title, build, (SELECT tier || ':' || stat || ':' || stat2 FROM custom_pets WHERE custom_pets.id = profiles.pet) AS pet_custom"; + "user_id, username, display_name, avatar, color, default_ability, elo, wins, losses, last_seen, power, guard, luck, crit, dodge, counter, hat, bonus_points, checkins, streak, dollars, pet, recolor, pet_color, accessory, trail, win_effect, taunt, title, build, (SELECT tier || ':' || stat || ':' || stat2 FROM custom_pets WHERE custom_pets.id = profiles.pet) AS pet_custom"; function json(data: unknown, status = 200, headers: { [name: string]: string } = {}): Response { return new Response(JSON.stringify(data), { @@ -452,7 +456,7 @@ function normalizeProfileRow(row: ProfileRow | null | undefined, config: Config) lastSeen: Number.isInteger(row.last_seen) ? row.last_seen : 0, registered: true, respawnAt: 0, - stats: cleanStats({ power: row.power, guard: row.guard, luck: row.luck }), + stats: cleanStats(row), hat: knownHat(row.hat) ? row.hat : "", bonus: Number.isInteger(row.bonus_points) ? row.bonus_points : 0, // check-in points (checkin below) checkins: Number.isInteger(row.checkins) ? row.checkins : 0, @@ -545,7 +549,8 @@ export class ChannelRoom extends DurableObject { .exec<{ name: string }>("PRAGMA table_info(profiles)") .toArray() .map((c) => c.name); - for (const column of ["power", "guard", "luck"]) + // v2.10: crit, dodge and counter join the upgrade stats. + for (const column of STATS) if (!profileColumns.includes(column)) sql.exec(`ALTER TABLE profiles ADD COLUMN ${column} INTEGER NOT NULL DEFAULT 0`); if (!profileColumns.includes("hat")) sql.exec("ALTER TABLE profiles ADD COLUMN hat TEXT NOT NULL DEFAULT ''"); @@ -1496,9 +1501,9 @@ export class ChannelRoom extends DurableObject { profile.userId, ); this.ctx.storage.sql.exec( - "INSERT INTO profiles (user_id, username, display_name, avatar, color, default_ability, elo, wins, losses, last_seen, power, guard, luck, hat, last_opponent, pet) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, '')) " + + "INSERT INTO profiles (user_id, username, display_name, avatar, color, default_ability, elo, wins, losses, last_seen, power, guard, luck, crit, dodge, counter, hat, last_opponent, pet) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, '')) " + "ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, display_name = excluded.display_name, avatar = excluded.avatar, color = excluded.color, default_ability = excluded.default_ability, elo = excluded.elo, wins = excluded.wins, losses = excluded.losses, last_seen = excluded.last_seen, " + - "power = excluded.power, guard = excluded.guard, luck = excluded.luck, hat = excluded.hat, " + + "power = excluded.power, guard = excluded.guard, luck = excluded.luck, crit = excluded.crit, dodge = excluded.dodge, counter = excluded.counter, hat = excluded.hat, " + // A game state player from before pets has no pet field; keep the stored one then. "pet = CASE WHEN ? IS NULL THEN profiles.pet ELSE excluded.pet END, " + // A website save carries no opponent; keep the stored one. @@ -1513,7 +1518,9 @@ export class ChannelRoom extends DurableObject { profile.wins, profile.losses, Number.isInteger(profile.lastSeen) ? profile.lastSeen : 0, - ...(({ power, guard, luck }) => [power, guard, luck])(cleanStats(profile.stats)), + ...(({ power, guard, luck, crit, dodge, counter }) => [power, guard, luck, crit, dodge, counter])( + cleanStats(profile.stats), + ), knownHat(profile.hat) ? profile.hat : "", String(profile.lastOpponentId || "").slice(0, 32), typeof profile.pet === "string" ? profile.pet : null, @@ -2420,9 +2427,12 @@ export class ChannelRoom extends DurableObject { | "power" | "guard" | "luck" + | "crit" + | "dodge" + | "counter" > >( - "SELECT wins, bonus_points, streak, last_stream, last_stream_seq, free_miss_at, power, guard, luck FROM profiles WHERE user_id = ?", + "SELECT wins, bonus_points, streak, last_stream, last_stream_seq, free_miss_at, power, guard, luck, crit, dodge, counter FROM profiles WHERE user_id = ?", userId, ) .toArray()[0]; @@ -3083,7 +3093,7 @@ export class ChannelRoom extends DurableObject { targetProfile, newAccounts, quick: subscriptionId === SE_SUBSCRIPTION_ID || msg.quick === true, - rolls: Array.from({ length: 48 }, () => Math.random()), + rolls: Array.from({ length: 120 }, () => Math.random()), // 5 slots of 24 (settleQuickDuel) }); if (!main.result.ok && main.result.reason !== "duplicate") step({ diff --git a/server/developer.ts b/server/developer.ts index ed8604d..bf59225 100644 --- a/server/developer.ts +++ b/server/developer.ts @@ -1269,6 +1269,9 @@ function roomExport(room: DevRoom, channel: string) { power: p.power, guard: p.guard, luck: p.luck, + crit: p.crit, + dodge: p.dodge, + counter: p.counter, hat: p.hat, lastOpponentId: p.last_opponent, bonus: p.bonus_points, diff --git a/server/game.ts b/server/game.ts index 90f8b13..00f879e 100644 --- a/server/game.ts +++ b/server/game.ts @@ -1,5 +1,14 @@ import site from "../site.config.js"; -import { cleanStats, effectiveStats, emptyStats, knownHat, scaledDamage, statValue } from "./upgrades.ts"; +import { + cleanStats, + counterChance, + effectiveStats, + emptyStats, + knownHat, + scaledDamage, + statValue, + HP_CRIT_BONUS, +} from "./upgrades.ts"; import { cleanBoost, TIERS } from "./pets.ts"; import { cleanCosmetics } from "./cosmetics.ts"; import type { Stats } from "./upgrades.ts"; @@ -517,8 +526,10 @@ function beginDuel(state: GameState, duel: Duel, now: number, event: { newAccoun // 6 is a crit (50% of max HP), 5 a hit (34%), 3-4 a miss, 1-2 the other fighter counters (34%). // First to 0 HP loses. After QUICK_MAX_ROLLS rolls the fighter with more HP wins; equal HP goes to // sudden death, where the next hit or counter knocks out. A winner who took no damage is flawless (+3 Elo). -// Upgrades (server/upgrades.ts): power and guard scale each blow; luck can turn a miss into a hit, using a -// second roll at LUCK_ROLLS + i. +// Upgrades (server/upgrades.ts) then change the swing in this order, each check with its own roll (slot k reads +// rolls[k * SLOT_ROLLS + i]): the attacker's luck turns a miss into a hit, their crit turns a hit into a crit, the +// defender's dodge turns a hit or crit into a miss, and the defender's counter turns a miss into their counter. +// Power and guard scale each blow, and counter adds its damage bonus to the defender's counters. // rolls are numbers in [0, 1) from the room; missing ones come from a hash so the reducer stays pure. const QUICK_MAX_ROLLS = 12; // A quick duel is settled at once, but the overlay plays it out over seconds and viewers see the stream a few @@ -532,11 +543,14 @@ const REPLAY_WALK_MS = 2200, export const replayMs = (rolls: number): number => REPLAY_WALK_MS + REPLAY_ROLL_MS * Math.max(1, rolls) + REPLAY_SLACK_MS; const QUICK_FLAWLESS_BONUS = 3; -const LUCK_ROLLS = 24; +const SLOT_ROLLS = 24; +const SLOT = { die: 0, luck: 1, crit: 2, dodge: 3, counter: 4 }; function settleQuickDuel(state: GameState, duel: Duel, rolls: unknown, now: number): GameResult { const list = Array.isArray(rolls) ? rolls : []; const rollAt = (i: number): number => Number.isFinite(list[i]) && list[i] >= 0 && list[i] < 1 ? list[i] : hashRoll(duel.id + ":" + now + ":" + i); + const check = (slot: number, i: number, chance: number): boolean => + chance > 0 && rollAt(slot * SLOT_ROLLS + i) < chance; // the duel has started, so beginDuel set its rules 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) }; @@ -571,11 +585,19 @@ function settleQuickDuel(state: GameState, duel: Duel, rolls: unknown, now: numb } decision = "sudden_death"; } - const die = 1 + Math.floor(rollAt(i) * 6); + const die = 1 + Math.floor(rollAt(SLOT.die * SLOT_ROLLS + i) * 6); let outcome: Swing["outcome"] = die === 6 ? "crit" : die === 5 ? "hit" : die <= 2 ? "counter" : "miss"; // stats has an entry for both fighters (built from [duel.a, duel.b] above) - const lucky = outcome === "miss" && rollAt(LUCK_ROLLS + i) < statValue("luck", stats[attacker]!.luck); + const mine = stats[attacker]!, + theirs = stats[defender]!; + const lucky = outcome === "miss" && check(SLOT.luck, i, statValue("luck", mine.luck)); if (lucky) outcome = "hit"; + const luckyCrit = outcome === "hit" && check(SLOT.crit, i, statValue("crit", mine.crit)); + if (luckyCrit) outcome = "crit"; + const dodged = (outcome === "hit" || outcome === "crit") && check(SLOT.dodge, i, statValue("dodge", theirs.dodge)); + if (dodged) outcome = "miss"; + const countered = outcome === "miss" && check(SLOT.counter, i, counterChance(theirs.counter)); + if (countered) outcome = "counter"; const swing: Swing = { attackerId: attacker, defenderId: defender, @@ -583,6 +605,8 @@ function settleQuickDuel(state: GameState, duel: Duel, rolls: unknown, now: numb outcome, damage: 0, ...(lucky ? { lucky: true } : {}), + ...(luckyCrit ? { luckyCrit: true } : {}), + ...(dodged ? { dodged: true } : {}), }; swings.push(swing); if (outcome === "miss") { @@ -595,14 +619,17 @@ function settleQuickDuel(state: GameState, duel: Duel, rolls: unknown, now: numb hp: clone(duel.hp), miss: true, die, + ...(dodged ? { dodged: true } : {}), }); } else { const dealer = outcome === "counter" ? defender : attacker, target = outcome === "counter" ? attacker : defender; // hp[target]! (four times, to `finisher`): beginDuel gave both fighters an hp entry + // a counter adds the dealer's counter bonus on top of power and guard + const bonus = outcome === "counter" ? 1 + statValue("counter", stats[dealer]!.counter) : 1; const amount = suddenDeath ? duel.hp[target]! - : Math.min(duel.hp[target]!, scaledDamage(blow[outcome], stats[dealer], stats[target])); + : Math.min(duel.hp[target]!, scaledDamage(Math.round(blow[outcome] * bonus), stats[dealer], stats[target])); duel.hp[target]! -= amount; swing.damage = amount; const finisher = duel.hp[target]! <= 0; @@ -617,6 +644,8 @@ function settleQuickDuel(state: GameState, duel: Duel, rolls: unknown, now: numb ...(outcome === "counter" ? { counter: true } : {}), ...(outcome === "crit" ? { crit: true } : {}), ...(lucky ? { lucky: true } : {}), + ...(luckyCrit ? { luckyCrit: true } : {}), + ...(dodged ? { dodged: true } : {}), ...(finisher ? { finisher: true } : {}), }); if (finisher) { @@ -728,7 +757,16 @@ function finishDuel( }); } -function applyAbility(state: GameState, duel: Duel, actor: Player, abilityName: string, now: number): GameResult { +// An HP-fight strike or heavy crits (+HP_CRIT_BONUS damage) with the actor's crit chance, from the command's first +// roll (or a hash, so the reducer stays pure). +function applyAbility( + state: GameState, + duel: Duel, + actor: Player, + abilityName: string, + now: number, + rolls: unknown = [], +): GameResult { if (!ABILITIES.has(abilityName)) return { ok: false, reason: "invalid_ability" }; // rules! and cooldowns! : applyCommand only picks an active duel, and beginDuel set both // abilityName passed ABILITIES above @@ -744,7 +782,8 @@ function applyAbility(state: GameState, duel: Duel, actor: Player, abilityName: if (duel.hp[actor.userId]! <= 0) return { ok: false, reason: "respawning" }; const targetId = actor.userId === duel.a ? duel.b : duel.a; - let amount = 0; + let amount = 0, + crit = false; if (abilityName === "heal") { const before = duel.hp[actor.userId]!; // the heal branch, so ability is abilities.heal @@ -753,10 +792,17 @@ function applyAbility(state: GameState, duel: Duel, actor: Player, abilityName: } 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 first: unknown = Array.isArray(rolls) ? rolls[0] : undefined; + const roll = + typeof first === "number" && first >= 0 && first < 1 + ? first + : hashRoll(duel.id + ":" + now + ":" + actor.userId + ":crit"); + crit = roll < statValue("crit", mine.crit); const damage = scaledDamage( // not the heal branch, so ability is abilities.strike or abilities.heavy - (ability as Abilities["strike"]).damage, - effectiveStats(actor.stats, actor.wins, actor.bonus, actor.petBoost, state.config.maxStatLevel), + 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), ); duel.hp[targetId] = Math.max(0, before - damage); @@ -778,6 +824,7 @@ function applyAbility(state: GameState, duel: Duel, actor: Player, abilityName: ability: abilityName, amount, hp: clone(duel.hp), + ...(crit ? { crit: true } : {}), }); if (duel.hp[targetId]! <= 0) { finishDuel(state, duel, actor.userId, now); @@ -862,7 +909,7 @@ function applyCommand(state: GameState, event: CommandEvent, now: number): GameR const opponentId = duel.a === actor.userId ? duel.b : duel.a; if (player(state, opponentId)?.username !== targetName) return { ok: false, reason: "wrong_opponent" }; } - return applyAbility(state, duel, actor, abilityName, now); + return applyAbility(state, duel, actor, abilityName, now, event.rolls); } /** A config change as validateConfigPatch passes it on: known keys only, each in range. */ diff --git a/server/model.ts b/server/model.ts index 91cb5bc..13ee16d 100644 --- a/server/model.ts +++ b/server/model.ts @@ -266,7 +266,12 @@ export type Swing = { die: number; outcome: "crit" | "hit" | "counter" | "miss"; damage: number; + /** Luck turned a miss into a hit. */ lucky?: true; + /** The crit stat turned a hit into a crit. */ + luckyCrit?: true; + /** The defender's dodge turned a hit or crit into a miss (a counter may follow). */ + dodged?: true; }; export type RankSet = { userId: string; elo: number; wins: number; losses: number }; diff --git a/server/pets.ts b/server/pets.ts index bdc064e..fb8f847 100644 --- a/server/pets.ts +++ b/server/pets.ts @@ -6,7 +6,7 @@ // GET /api/pets/:channel/:id public: an uploaded pet's PNG // POST /api/pets/:channel canManage: { label, tier, stat, stat2?, image:"" } -> 201 {ok, item} // DELETE /api/pets/:channel/:id canManage -> {ok, id}; owners lose it and fighters using it go back to no pet -import { STATS, emptyStats } from "./upgrades.ts"; +import { emptyStats } from "./upgrades.ts"; import { readPng } from "./uploads.ts"; import type { Stat, Stats } from "./upgrades.ts"; import type { Config } from "./model.ts"; @@ -104,12 +104,14 @@ export const PRICE_KEYS: Record = { epic: "petPriceEpic", legendary: "petPriceLegendary", }; +// Pets boost power, guard and luck only; crit, dodge and counter come from upgrade points. +export const PET_STATS = ["power", "guard", "luck"] as const satisfies readonly Stat[]; export const MAX_BOOST = 2; // per stat, on top of the upgrade points (it may go past the channel's maxStatLevel) export const CUSTOM_PET_ID = /^p-[a-z0-9-]{1,40}$/; export const PET_LIMITS = Object.freeze({ maxPets: 24, maxBytes: 65_536, maxSide: 64, maxLabel: 24 }); const BODY_LIMIT = Math.ceil(PET_LIMITS.maxBytes / 3) * 4 + 4_000; -// Built-in pets: the stat (and second stat for epic) the tier boost goes to. Legendary boosts every stat. +// Built-in pets: the stat (and second stat for epic) the tier boost goes to. Legendary boosts each of PET_STATS. export const PETS: BuiltinPet[] = [ { id: "mouse", label: "Mouse", tier: "common", stat: "power" }, { id: "chick", label: "Chick", tier: "common", stat: "luck" }, @@ -134,21 +136,21 @@ const BUILTIN = new Map(PETS.map((p) => [p.id, p])); export function tierBoost(tier: string, stat: string | undefined, stat2?: string): Stats { const out = emptyStats(); if (tier === "legendary") { - for (const key of STATS) out[key] = 1; + for (const key of PET_STATS) out[key] = 1; return out; } // Widened so any string can be looked up - if (!(STATS as readonly unknown[]).includes(stat)) return out; - // A Stat: STATS.includes(stat) above + if (!(PET_STATS as readonly unknown[]).includes(stat)) return out; + // A Stat: PET_STATS.includes(stat) above out[stat as Stat] = tier === "rare" || tier === "epic" ? 2 : TIERS.includes(tier) ? 1 : 0; - // Widened for the lookup; the index is a Stat because STATS includes it - if (tier === "epic" && (STATS as readonly unknown[]).includes(stat2) && stat2 !== stat) out[stat2 as Stat] = 1; + // Widened for the lookup; the index is a Stat because PET_STATS includes it + if (tier === "epic" && (PET_STATS as readonly unknown[]).includes(stat2) && stat2 !== stat) out[stat2 as Stat] = 1; return out; } export function cleanBoost(input?: unknown): Stats { const out = emptyStats(); - for (const key of STATS) { + for (const key of PET_STATS) { // Any value: ?. reads undefined from null or undefined, and Number() turns whatever it reads into a number const v = Number((input as Partial> | null | undefined)?.[key]); out[key] = Number.isInteger(v) ? Math.max(0, Math.min(MAX_BOOST, v)) : 0; @@ -170,12 +172,12 @@ export function petOf(id: string, custom: unknown = ""): PetRef | null { return TIERS.includes(tier!) ? { id, tier: tier!, boost: tierBoost(tier!, stat, stat2) } : null; } -// "+2 power" / "+2 power, +1 guard" / "+1 to all stats" (same wording as the viewer page) +// "+2 power" / "+2 power, +1 guard" / "+1 power, guard and luck" (same wording as the viewer page) // Callers pass a pet's boost from petOf (always set); the ?. reads stay as in the JS. export const boostText = (boost: Stats): string => - STATS.every((k) => boost?.[k] > 0 && boost[k] === boost.power) - ? `+${boost.power} to all stats` - : STATS.filter((k) => boost?.[k] > 0) + PET_STATS.every((k) => boost?.[k] > 0 && boost[k] === boost.power) + ? `+${boost.power} power, guard and luck` + : PET_STATS.filter((k) => boost?.[k] > 0) .map((k) => `+${boost[k]} ${k}`) .join(", "); @@ -244,11 +246,11 @@ export function validatePetUpload( if (!TIERS.includes(tier)) return { status: 400, reason: "invalid_tier", error: "Pick a tier" }; const stat = tier === "legendary" ? "" : String(body?.stat || ""); // Widened so any string can be looked up - if (tier !== "legendary" && !(STATS as readonly string[]).includes(stat)) + if (tier !== "legendary" && !(PET_STATS as readonly string[]).includes(stat)) return { status: 400, reason: "invalid_stat", error: "Pick the stat it boosts" }; const stat2 = tier === "epic" ? String(body?.stat2 || "") : ""; // Widened so any string can be looked up - if (tier === "epic" && (!(STATS as readonly string[]).includes(stat2) || stat2 === stat)) + if (tier === "epic" && (!(PET_STATS as readonly string[]).includes(stat2) || stat2 === stat)) return { status: 400, reason: "invalid_stat", error: "Epic pets boost two different stats" }; const b64 = String(body?.image || "").replace(/^data:[^,]*;base64,/, ""); if (!b64) return { status: 400, reason: "invalid_image", error: "Choose a PNG image" }; diff --git a/server/upgrades.ts b/server/upgrades.ts index 2b56551..72480d7 100644 --- a/server/upgrades.ts +++ b/server/upgrades.ts @@ -4,11 +4,14 @@ // cost 2, 17-24 cost 4, ...). Points can be moved around (respec) for free whenever the fighter is not in a duel. // power: +4% damage dealt per level guard: -4% damage taken per level // luck: 4% per level that a miss still lands as a hit +// crit: 4% per level that a hit becomes a crit; in an HP fight, 4% that a strike or heavy deals +50% +// dodge: 4% per level that a hit or crit on you misses (quick duels) +// counter: +2% counter damage per level, and 2% per level that a foe's miss becomes your counter (quick duels) // Above level 8 each level adds half a step at first and the effect closes in on the stat's ceiling (statValue). // A channel's maxStatLevel (config) caps the levels that count there; levels above it stay saved. // Hats are cosmetic; some unlock at a number of wins. The overlay draws them (src/served/hats.ts). -export const STATS = ["power", "guard", "luck"] as const; // const: each stat a literal, so Stat names them +export const STATS = ["power", "guard", "luck", "crit", "dodge", "counter"] as const; // const: each stat a literal, so Stat names them export type Stat = (typeof STATS)[number]; /** Levels in each stat. */ export type Stats = Record; @@ -19,8 +22,28 @@ export const FULL_STEP_LEVELS = 8; /** A sanity bound on stored levels; reaching it would cost over 268 million points. */ export const LEVEL_LIMIT = 200; export const STAT_STEP = 0.04; +/** What one of the first 8 levels adds to each stat's effect, as a fraction. */ +export const STAT_STEPS: Record = { + power: STAT_STEP, + guard: STAT_STEP, + luck: STAT_STEP, + crit: STAT_STEP, + dodge: STAT_STEP, + counter: 0.02, +}; /** The most each stat's effect can reach, as a fraction (0.6 is 60%). */ -export const STAT_CEILING: Record = { power: 0.6, guard: 0.6, luck: 0.5 }; +export const STAT_CEILING: Record = { + power: 0.6, + guard: 0.6, + luck: 0.5, + crit: 0.5, + dodge: 0.5, + counter: 0.6, +}; +/** Counter's second effect: the chance a foe's miss becomes your counter. */ +const COUNTER_CHANCE = { step: 0.02, ceiling: 0.5 }; +/** An HP-fight crit adds this much to a strike or heavy. */ +export const HP_CRIT_BONUS = 0.5; export type Hat = { id: string; label: string; wins: number }; export const HATS: Hat[] = [ @@ -51,18 +74,22 @@ export function levelSpend(level: number): number { // Points a build spends. export const spentOn = (stats: Stats): number => STATS.reduce((sum, key) => sum + levelSpend(stats[key]), 0); -// What a stat's levels do, as a fraction: 4% a level up to level 8 (so earlier builds keep their strength), then -// half that step at first, closing in on STAT_CEILING without reaching it. -export function statValue(stat: Stat, level: number): number { +// An effect at a level: the full step a level up to level 8 (so earlier builds keep their strength), then half that +// step at first, closing in on the ceiling without reaching it. +function effectAt(step: number, ceiling: number, level: number): number { const n = Math.max(0, level || 0); - if (n <= FULL_STEP_LEVELS) return STAT_STEP * n; - const base = STAT_STEP * FULL_STEP_LEVELS, - room = STAT_CEILING[stat] - base; - return base + room * (1 - Math.exp(-((STAT_STEP / 2) * (n - FULL_STEP_LEVELS)) / room)); + if (n <= FULL_STEP_LEVELS) return step * n; + const base = step * FULL_STEP_LEVELS, + room = ceiling - base; + return base + room * (1 - Math.exp(-((step / 2) * (n - FULL_STEP_LEVELS)) / room)); } +// What a stat's levels do, as a fraction (for counter, the extra counter damage). +export const statValue = (stat: Stat, level: number): number => effectAt(STAT_STEPS[stat], STAT_CEILING[stat], level); +// The chance a foe's miss becomes this fighter's counter. +export const counterChance = (level: number): number => effectAt(COUNTER_CHANCE.step, COUNTER_CHANCE.ceiling, level); export function emptyStats(): Stats { - return { power: 0, guard: 0, luck: 0 }; + return { power: 0, guard: 0, luck: 0, crit: 0, dodge: 0, counter: 0 }; } // Any input -> whole numbers within 0..LEVEL_LIMIT for each stat. @@ -76,8 +103,9 @@ export function cleanStats(input?: unknown): Stats { return out; } -// Levels the fighter can use with these points. A rank reset lowers the points; levels come off luck first, then -// guard, then power, until the build fits, so a stored build never counts for more than the points allow. +// Levels the fighter can use with these points. A rank reset lowers the points; levels come off the last stat in +// STATS first (counter, dodge, crit, luck, guard, then power) until the build fits, so a stored build never counts +// for more than the points allow. // maxLevel (the channel's maxStatLevel) then caps each stat; levels above it stay saved but don't count. // boost (the active pet's, server/pets.ts) is added after both, so it may take a stat past the cap. export function effectiveStats( @@ -90,9 +118,7 @@ export function effectiveStats( const out = cleanStats(stats), points = pointsFor(wins, bonus); let spent = spentOn(out); - // const: each key a Stat, so out[key] type-checks - for (const key of ["luck", "guard", "power"] as const) - while (spent > points && out[key] > 0) spent -= levelCost(out[key]--); + 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)); if (boost) for (const key of STATS) out[key] += Number.isInteger(boost[key]) && boost[key] > 0 ? boost[key] : 0; return out; diff --git a/src/admin.ts b/src/admin.ts index befc304..1e338a5 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -1425,7 +1425,7 @@ const TIER_LABELS: Record = { }; const boostLabel = (b: Record | undefined) => b?.power && b.power === b.guard && b.power === b.luck - ? "+" + b.power + " to all stats" + ? "+" + b.power + " power, guard and luck" : ["power", "guard", "luck"] .filter((k) => b?.[k]) // b!: the filter kept only stats b has diff --git a/src/dashboard.ts b/src/dashboard.ts index b50911a..320d6f8 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -24,13 +24,16 @@ import { } from "./ui.ts"; import { upgradeRules, + counterChance, effectiveStats, + emptyStats, levelCost, presetReady, presetStats, spentOn, statValue, PRESETS, + STATS, } from "../server/upgrades.ts"; import { CHARACTER_GROUPS } from "./character-groups.ts"; import { skyBackdrop } from "./scrub.ts"; @@ -239,6 +242,17 @@ const STAT_TEXT = { power: { label: "Power", effect: (n: number) => "+" + pct("power", n) + " damage dealt" }, guard: { label: "Guard", effect: (n: number) => "−" + pct("guard", n) + " damage taken" }, luck: { label: "Luck", effect: (n: number) => pct("luck", n) + " of misses still hit" }, + crit: { label: "Crit", effect: (n: number) => pct("crit", n) + " of hits become crits" }, + dodge: { label: "Dodge", effect: (n: number) => pct("dodge", n) + " of hits on you miss" }, + counter: { + label: "Counter", + effect: (n: number) => + "+" + + pct("counter", n) + + " counter damage, " + + Math.round(counterChance(n) * 1000) / 10 + + "% of foe misses countered", + }, }; const REASONS: Record = { in_duel: "finish your open challenge or duel before moving upgrade points.", @@ -256,7 +270,6 @@ const REASONS: Record = { max_slots: "you already have the most builds (5).", }; -const STATS: Stat[] = ["power", "guard", "luck"]; const str = (v: unknown) => (typeof v === "string" ? v : ""); const rules = () => upgradeRules(state.profile?.wins || 0, state.profile?.bonus || 0, state.config?.maxStatLevel); // A saved build (or profile) as an editable loadout, with only ids this page knows. @@ -294,7 +307,7 @@ const entryOf = (id: string | undefined) => state.catalog.find((x) => x.id === i const looks = (d = state.d) => Object.fromEntries(LOOK_FIELDS.map((f) => [f, d[f]])); const boostText = (b: Partial | undefined) => b?.power && b.power === b.guard && b.power === b.luck - ? "+" + b.power + " to all stats" + ? "+" + b.power + " power, guard and luck" : STATS.filter((k) => b?.[k]) .map((k) => "+" + b![k] + " " + k) // b!: filter() kept only stats b has .join(", "); @@ -877,7 +890,7 @@ function renderUpgrades() { 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."; - const shown = canEdit ? state.d.stats : { power: 0, guard: 0, luck: 0 }; + const shown = canEdit ? state.d.stats : emptyStats(); const link = $("#points-link") as HTMLAnchorElement; // element type from the page HTML link.hidden = !canEdit || left <= 0; link.textContent = left + (left === 1 ? " upgrade point" : " upgrade points") + " to spend"; diff --git a/src/served/overlay.ts b/src/served/overlay.ts index 50101a9..f785c73 100644 --- a/src/served/overlay.ts +++ b/src/served/overlay.ts @@ -1054,16 +1054,22 @@ async function start() { roller.whoosh = { start: t + 80, until: t + 480 }; } if (other) other.anim = { kind: "dodge", start: t + 60, until: t + 600 }; - floatText(other, "MISS", "#e2e8f0"); + floatText(other, event.dodged ? "DODGE" : "MISS", "#e2e8f0"); showHp(); playSound("miss"); return; } let swing = t; if (event.counter && dealer) { - dealer.parry = { start: t, until: t + 380 }; - floatText(dealer, "COUNTER", "#fbbf24"); - swing = t + 300; + // a dodge that turned into a counter shows the dodge first + const parry = event.dodged ? t + 300 : t; + if (event.dodged) { + dealer.anim = { kind: "dodge", start: t, until: t + 300 }; + floatText(dealer, "DODGE", "#e2e8f0"); + } + dealer.parry = { start: parry, until: parry + 380 }; + setTimeout(() => floatText(dealer, "COUNTER", "#fbbf24"), parry - t); + swing = parry + 300; playSound("parry"); } const heavy = look === "smash" || event.crit === true, @@ -1086,7 +1092,11 @@ async function start() { const color = event.crit ? "#fde047" : look === "smash" ? "#f97316" : look === "glow" ? "#4ade80" : "#fb7185"; burst(target, color, event.crit || event.finisher ? 22 : 10, false); landBlow(target, dealer, color, event.finisher ? 3 : heavy ? 2 : 1); - floatText(target, (event.crit ? "CRIT! " : "") + "-" + amount, event.crit ? "#fde047" : "#fb7185"); + floatText( + target, + (event.luckyCrit ? "LUCKY CRIT " : event.crit ? "CRIT! " : "") + "-" + amount, + event.crit ? "#fde047" : "#fb7185", + ); if (event.finisher && target) { knockout(target, at); if (!(target.koUntil > at)) target.koStart = at; @@ -1218,7 +1228,11 @@ async function start() { } burst(target, event.ability === "heavy" ? "#f97316" : "#fb7185", event.ability === "heavy" ? 16 : 8, false); landBlow(target, actor, event.ability === "heavy" ? "#f97316" : "#fb7185", event.ability === "heavy" ? 2 : 1); - floatText(target, event.counter ? "COUNTER" : "-" + (Number(event.amount) || 0), "#fb7185"); + floatText( + target, + event.counter ? "COUNTER" : (event.crit ? "CRIT! " : "") + "-" + (Number(event.amount) || 0), + event.crit ? "#fde047" : "#fb7185", + ); } break; } diff --git a/src/served/parse.ts b/src/served/parse.ts index 8f45465..e441ad1 100644 --- a/src/served/parse.ts +++ b/src/served/parse.ts @@ -184,6 +184,8 @@ export type ArenaEvent = { ability?: string | undefined; amount?: number | undefined; crit?: boolean | undefined; + luckyCrit?: boolean | undefined; + dodged?: boolean | undefined; finisher?: boolean | undefined; winnerId?: string | undefined; loserId?: string | undefined; @@ -212,7 +214,7 @@ const EVENT_STRINGS = [ "reason", ] as const; const EVENT_NUMBERS = ["at", "round", "die", "amount", "respawnAt"] as const; -const EVENT_BOOLEANS = ["counter", "miss", "crit", "finisher", "flawless"] as const; +const EVENT_BOOLEANS = ["counter", "miss", "crit", "luckyCrit", "dodged", "finisher", "flawless"] as const; const isEvent = (v: unknown): v is ArenaEvent => isObject(v) && typeof v.type === "string" && diff --git a/tests/channel.test.ts b/tests/channel.test.ts index 9c5da49..d4e575b 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -9,7 +9,7 @@ import { createInitialState, defaultConfig } from "../server/game.ts"; import { makePng, b64 } from "./upload-helpers.ts"; import { effectiveStats } from "../server/upgrades.ts"; import { tierBoost, petOf, PETS, boostText } from "../server/pets.ts"; -import { asEnv, fakeCtx, must } from "./fakes.ts"; +import { asEnv, fakeCtx, fullStats, must } from "./fakes.ts"; import type { FakeSocketLike, FakeState, Loose } from "./fakes.ts"; import type { Env } from "../server/env.ts"; import type { Duel } from "../server/model.ts"; @@ -1697,17 +1697,21 @@ test("profiles save upgrades and hats within the points and unlocks the saved wi const live = r.readState("nesszerra"); must(live.players.find((p) => p.userId === "u1")).wins = 6; r.writeState(live); - const saved = await r.save("u1", "alice", { stats: { power: 3, luck: 2 }, hat: "tophat" }); + const build = { power: 2, luck: 1, crit: 1, dodge: 1, counter: 1 }; // 6 points, every new stat stored + const saved = await r.save("u1", "alice", { stats: build, hat: "tophat" }); assert.equal(saved.status, 200, JSON.stringify(saved.body)); - assert.deepEqual(saved.body.profile.stats, { power: 3, guard: 0, luck: 2 }); + assert.deepEqual(saved.body.profile.stats, fullStats(build)); const got = (await r.call("/profile?userId=u1")).body; assert.equal(got.hat, "tophat"); - assert.deepEqual(got.stats, { power: 3, guard: 0, luck: 2 }); + assert.deepEqual(got.stats, fullStats(build)); + assert.deepEqual(r.ctx.storage.sql.exec("SELECT crit, dodge, counter FROM profiles WHERE user_id = 'u1'").toArray(), [ + { crit: 1, dodge: 1, counter: 1 }, + ]); assert.equal(got.upgrades.points, 6); assert.equal(got.upgrades.hats.find((h: { id: string }) => h.id === "tophat").unlocked, true); // Leaving stats and hat out keeps them. assert.equal((await r.save("u1", "alice", { color: "#654321" })).body.profile.hat, "tophat"); - assert.equal((await r.call("/profile?userId=u1")).body.stats.power, 3); + assert.equal((await r.call("/profile?userId=u1")).body.stats.counter, 1); // Overlays get the hat with the player. const state = (await r.call("/state")).body; assert.equal(state.players.find((p: { userId: string }) => p.userId === "u1")?.hat, "tophat"); @@ -1722,23 +1726,26 @@ test("snapshots carry the deploy build id and the announce setting so open overl }); test("pets: tier boosts, added after the upgrade cap", () => { - assert.deepEqual(tierBoost("common", "power"), { power: 1, guard: 0, luck: 0 }); - assert.deepEqual(tierBoost("rare", "luck"), { power: 0, guard: 0, luck: 2 }); - assert.deepEqual(tierBoost("epic", "guard", "power"), { power: 1, guard: 2, luck: 0 }); + assert.deepEqual(tierBoost("common", "power"), fullStats({ power: 1, guard: 0, luck: 0 })); + assert.deepEqual(tierBoost("rare", "luck"), fullStats({ power: 0, guard: 0, luck: 2 })); + assert.deepEqual(tierBoost("epic", "guard", "power"), fullStats({ power: 1, guard: 2, luck: 0 })); assert.deepEqual( tierBoost("epic", "guard", "guard"), - { power: 0, guard: 2, luck: 0 }, + fullStats({ power: 0, guard: 2, luck: 0 }), "an epic second stat must differ", ); // as: tierBoost types its stat as required; the test checks the tier-only call (stat reads as undefined) - assert.deepEqual((tierBoost as TierBoostOneArg)("legendary"), { power: 1, guard: 1, luck: 1 }); - assert.deepEqual(tierBoost("mythic", "power"), { power: 0, guard: 0, luck: 0 }); + assert.deepEqual((tierBoost as TierBoostOneArg)("legendary"), fullStats({ power: 1, guard: 1, luck: 1 })); + assert.deepEqual(tierBoost("mythic", "power"), fullStats({ power: 0, guard: 0, luck: 0 })); assert.equal(PETS.length, 14); assert.equal(petOf("p-gone", ""), null, "a deleted upload is no pet"); - assert.deepEqual(must(petOf("p-blob-1a2b3c", "rare:power:")).boost, { power: 2, guard: 0, luck: 0 }); + 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")), { power: 10, guard: 0, luck: 0 }); - assert.deepEqual(effectiveStats({ power: 8 }, 3, 0, null), { power: 3, guard: 0, luck: 0 }); + assert.deepEqual( + effectiveStats({ power: 8 }, 8, 0, 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 })); }); test("shop: buy pets and hats with shown dollars, equip on save, !pet, custom pet uploads and deletes", async () => { @@ -1766,7 +1773,14 @@ test("shop: buy pets and hats with shown dollars, equip on save, !pet, custom pe assert.equal(catalog.pets.length, 14); assert.deepEqual( catalog.pets.find((p: { id: string }) => p.id === "fox"), - { id: "fox", label: "Fox", tier: "rare", boost: { power: 2, guard: 0, luck: 0 }, price: 60, custom: false }, + { + id: "fox", + label: "Fox", + tier: "rare", + boost: fullStats({ power: 2, guard: 0, luck: 0 }), + price: 60, + custom: false, + }, ); assert.equal(catalog.hatPricePerWin, 3); // Buying: identity, unknown items, money. @@ -1811,7 +1825,7 @@ test("shop: buy pets and hats with shown dollars, equip on save, !pet, custom pe const saved = (await r.save("u1", "alice", { pet: "fox" })).body.profile; assert.deepEqual( [saved.pet, saved.petTier, saved.petBoost, saved.dollars], - ["fox", "rare", { power: 2, guard: 0, luck: 0 }, 181], + ["fox", "rare", fullStats({ power: 2, guard: 0, luck: 0 }), 181], ); assert.deepEqual((({ pets, hats }) => ({ pets, hats }))((await r.call("/profile?userId=u1")).body.owned), { pets: ["fox"], @@ -1887,7 +1901,7 @@ test("shop: buy pets and hats with shown dollars, equip on save, !pet, custom pe assert.match(item.id, /^p-blob-[0-9a-f]{6}$/); assert.deepEqual( [item.tier, item.boost, item.price, item.url], - ["epic", { power: 0, guard: 1, luck: 2 }, 140, "/api/pets/nesszerra/" + item.id], + ["epic", fullStats({ power: 0, guard: 1, luck: 2 }), 140, "/api/pets/nesszerra/" + item.id], ); const png = await r.fetch( new Request("https://room/pets/" + item.id, { @@ -2106,11 +2120,11 @@ test("profile saves land in the owner log: a save as a command row, a refusal wi }); }); -test('pet boost text: legendary pets read "+1 to all stats", like the viewer page', () => { +test('pet boost text: legendary pets read "+1 power, guard and luck", like the viewer page', () => { // as: tierBoost types its stat as required; the test checks the tier-only call (stat reads as undefined) - assert.equal(boostText((tierBoost as TierBoostOneArg)("legendary")), "+1 to all stats"); - assert.equal(boostText({ power: 2, guard: 1, luck: 0 }), "+2 power, +1 guard"); - assert.equal(boostText({ power: 0, guard: 0, luck: 1 }), "+1 luck"); + assert.equal(boostText((tierBoost as TierBoostOneArg)("legendary")), "+1 power, guard and luck"); + assert.equal(boostText(fullStats({ power: 2, guard: 1, luck: 0 })), "+2 power, +1 guard"); + assert.equal(boostText(fullStats({ power: 0, guard: 0, luck: 1 })), "+1 luck"); }); test("chat bot: commands by the channel names get a reply; chat, other commands and other subscriptions get none", async () => { diff --git a/tests/fakes.ts b/tests/fakes.ts index cb12cf2..856f41f 100644 --- a/tests/fakes.ts +++ b/tests/fakes.ts @@ -4,6 +4,8 @@ import assert from "node:assert/strict"; import { DatabaseSync } from "node:sqlite"; import type { SQLInputValue } from "node:sqlite"; import type { Env } from "../server/env.ts"; +import { emptyStats } from "../server/upgrades.ts"; +import type { Stats } from "../server/upgrades.ts"; // any: data a test reads by property and asserts on (JSON bodies, SQL rows, stub call records); the assertions check // the values, so a declared shape would only repeat them. @@ -113,3 +115,6 @@ export function must(value: T | null | undefined, message?: string): T { assert.ok(value !== undefined && value !== null, message ?? "expected a value"); return value; } + +/** A whole build from the stats a test names; the rest are 0. */ +export const fullStats = (stats: Partial): Stats => ({ ...emptyStats(), ...stats }); diff --git a/tests/game.test.ts b/tests/game.test.ts index 1a7ce83..7d0f073 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -23,7 +23,7 @@ import type { Swing, } from "../server/model.ts"; import type { Stats } from "../server/upgrades.ts"; -import { asEnv, must } from "./fakes.ts"; +import { asEnv, fullStats, must } from "./fakes.ts"; import type { Loose } from "./fakes.ts"; const T0 = 1_800_000_000_000; @@ -1209,8 +1209,9 @@ 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 luck, then guard, then power", async () => { +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)), [0, 3, 20, 400], @@ -1225,27 +1226,30 @@ test("upgrade points: one per win plus check-in points with no cap; a rank reset ].map(([w, b]) => u.pointsFor(w, b)), [5, 24, 25, 4, 2], ); - assert.deepEqual(u.validStats({ power: 2, guard: 1 }, 3), { power: 2, guard: 1, luck: 0 }); + 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: 2 }, 3, 1), - { power: 2, guard: 2, luck: 0 }, + fullStats({ power: 2, guard: 2, luck: 0 }), "a check-in point covers the fourth", ); - assert.deepEqual(u.validStats({ power: 8 }, 8), { power: 8, guard: 0, luck: 0 }); + 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), { power: 9, guard: 0, luck: 0 }); + 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.effectiveStats({ power: 3, guard: 3, luck: 3 }, 5), { power: 3, guard: 2, luck: 0 }); + assert.deepEqual( + u.effectiveStats(fullStats({ power: 3, guard: 3, luck: 3 }), 5), + fullStats({ power: 3, guard: 2, luck: 0 }), + ); assert.deepEqual( u.effectiveStats({ power: 3, guard: 3, luck: 3 }, 0, 7), - { power: 3, guard: 3, luck: 1 }, + 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), - { power: 9, guard: 0, luck: 0 }, + fullStats({ power: 9, guard: 0, luck: 0 }), "trimming refunds level 10 at 2 points", ); const r = u.upgradeRules(4, 3, 16); @@ -1266,7 +1270,7 @@ test("upgrade levels: cost doubles every 8 levels and the effect closes in on ea [0, 5, 8, 9, 16, 24, 32].map((l) => u.levelSpend(l)), [0, 5, 8, 10, 24, 56, 120], ); - assert.equal(u.spentOn({ power: 16, guard: 8, luck: 1 }), 33); + assert.equal(u.spentOn(fullStats({ power: 16, guard: 8, luck: 1 })), 33); assert.deepEqual( [0, 1, 8].map((l) => u.statValue("power", l)), [0, 0.04, 0.32], @@ -1285,13 +1289,16 @@ 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), { power: 16, guard: 2, luck: 0 }); + assert.deepEqual( + u.effectiveStats({ power: 20, guard: 2 }, 1000, 0, null, 16), + fullStats({ power: 16, guard: 2, luck: 0 }), + ); assert.deepEqual( u.effectiveStats({ power: 20 }, 1000, 0, { power: 2, luck: 1 }, 16), - { power: 18, guard: 0, luck: 1 }, + fullStats({ power: 18, guard: 0, luck: 1 }), "the boost lands after the cap", ); - assert.deepEqual(u.effectiveStats({ power: 20 }, 1000), { power: 20, guard: 0, luck: 0 }, "no cap given"); + assert.deepEqual(u.effectiveStats({ power: 20 }, 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 () => { @@ -1300,13 +1307,30 @@ test("upgrade presets spread the points by weight within the channel's level cap assert.equal(u.PRESETS.length, 12); assert.deepEqual( u.PRESETS.filter(u.presetReady).map((p) => p.id), - ["balanced", "brawler", "lucky-striker", "iron-wall"], + [ + "balanced", + "brawler", + "lucky-striker", + "iron-wall", + "glass-cannon", + "counter-puncher", + "duelist", + "gambler", + "ghost", + "sharpshooter", + ], + "tank and bruiser wait for vitality", + ); + assert.deepEqual(u.presetStats(preset("glass-cannon"), 6), fullStats({ power: 3, crit: 2, luck: 1 })); + assert.deepEqual(u.presetStats(preset("balanced"), 6), fullStats({ power: 2, guard: 2, luck: 2 })); + assert.deepEqual(u.presetStats(preset("balanced"), 7), fullStats({ power: 3, guard: 2, luck: 2 })); + assert.deepEqual(u.presetStats(preset("iron-wall"), 8), fullStats({ power: 2, guard: 6, luck: 0 })); + assert.deepEqual( + u.presetStats(preset("brawler"), 1000, 16), + fullStats({ power: 16, guard: 16, luck: 0 }), + "stops at the cap", ); - assert.deepEqual(u.presetStats(preset("balanced"), 6), { power: 2, guard: 2, luck: 2 }); - assert.deepEqual(u.presetStats(preset("balanced"), 7), { power: 3, guard: 2, luck: 2 }); - assert.deepEqual(u.presetStats(preset("iron-wall"), 8), { power: 2, guard: 6, luck: 0 }); - assert.deepEqual(u.presetStats(preset("brawler"), 1000, 16), { power: 16, guard: 16, luck: 0 }, "stops at the cap"); - assert.deepEqual(u.presetStats(preset("brawler"), 0), { power: 0, guard: 0, luck: 0 }); + 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); }); @@ -1361,9 +1385,82 @@ test("quick duels: luck turns a miss into a hit with 4% per point, from a second assert.equal(must(quickWith(upgraded({ alice: { luck: 5 } }), rolls).swings[0]).outcome, "miss"); }); +// Each check reads its own slot of 24 rolls: die 0, luck 24, crit 48, dodge 72, counter 96 (plus the swing index). +const slots = (set: Record) => Object.assign(Array(120).fill(0.9), set); + +test("quick duels: crit turns a hit into a crit with 4% per level, from the third roll slot", () => { + const rolls = slots({ 0: 0.75, 48: 0.1 }); // alice rolls a 5 (a hit); 0.1 < 5 x 4% + const r = must(quickWith(upgraded({ alice: { crit: 5 } }), rolls).swings[0]); + assert.deepEqual([r.outcome, r.luckyCrit, r.damage], ["crit", true, 50]); + rolls[48] = 0.25; + const plain = must(quickWith(upgraded({ alice: { crit: 5 } }), rolls).swings[0]); + assert.deepEqual([plain.outcome, plain.luckyCrit, plain.damage], ["hit", undefined, 34]); +}); + +test("quick duels: luck then crit can turn a miss into a crit", () => { + const r = must(quickWith(upgraded({ alice: { luck: 5, crit: 5 } }), slots({ 0: 0.5, 24: 0.1, 48: 0.1 })).swings[0]); + assert.deepEqual([r.outcome, r.lucky, r.luckyCrit, r.damage], ["crit", true, true, 50]); +}); + +test("quick duels: dodge turns a hit or crit on you into a miss with 4% per level", () => { + const hit = must(quickWith(upgraded({ bob: { dodge: 5 } }), slots({ 0: 0.75, 72: 0.1 })).swings[0]); + assert.deepEqual([hit.outcome, hit.dodged, hit.damage], ["miss", true, 0]); + const crit = must(quickWith(upgraded({ bob: { dodge: 5 } }), slots({ 72: 0.1 })).swings[0]); // 0.9 rolls a 6 + assert.deepEqual([crit.outcome, crit.dodged], ["miss", true]); + const missed = must(quickWith(upgraded({ bob: { dodge: 5 } }), slots({ 0: 0.75, 72: 0.25 })).swings[0]); + assert.deepEqual([missed.outcome, missed.dodged, missed.damage], ["hit", undefined, 34]); +}); + +test("quick duels: counter adds 2% counter damage per level and turns a foe's miss into a counter at 2% per level", () => { + // bob counters on alice's 1: 34 base, +10% from 5 levels + assert.equal(must(quickWith(upgraded({ bob: { counter: 5 } }), slots({ 0: 0.1 })).swings[0]).damage, 37); + assert.equal(must(quickWith(upgraded({}), slots({ 0: 0.1 })).swings[0]).damage, 34); + const turned = must(quickWith(upgraded({ bob: { counter: 5 } }), slots({ 0: 0.5, 96: 0.05 })).swings[0]); + assert.deepEqual([turned.outcome, turned.attackerId, turned.damage], ["counter", "id-alice", 37]); + const kept = must(quickWith(upgraded({ bob: { counter: 5 } }), slots({ 0: 0.5, 96: 0.15 })).swings[0]); + assert.equal(kept.outcome, "miss"); +}); + +test("quick duels: a dodged hit can then become the defender's counter", () => { + const r = must( + quickWith(upgraded({ bob: { dodge: 5, counter: 5 } }), slots({ 0: 0.75, 72: 0.1, 96: 0.05 })).swings[0], + ); + assert.deepEqual([r.outcome, r.dodged, r.damage], ["counter", true, 37]); +}); + +test("quick duels with the new stats stay pure: the same rolls give the same swings", () => { + const rolls = Array.from({ length: 120 }, (_, i) => ((i * 37) % 100) / 100); + const run = () => quickWith(upgraded({ alice: { crit: 4, luck: 2 }, bob: { dodge: 3, counter: 3 } }), rolls).swings; + assert.deepEqual(run(), run()); +}); + +test("HP fights: crit makes a strike or heavy deal 50% more, from the command's first roll", () => { + const w = arena(); + w.register("alice", { stats: { crit: 5 } }); + w.player("alice").wins = 10; + w.fight("alice", "bob"); + const strike = (roll: number) => + w.apply({ + type: "command", + messageId: "c" + ++seq, + userId: "id-alice", + username: "alice", + displayName: "alice", + text: "!strike", + timestamp: w.now, + rolls: [roll], + }); + assert.equal(strike(0.1).amount, 30); + const lastHit = () => w.state.events.findLast((e) => e.type === "duel_action"); + assert.equal(lastHit()?.crit, true); + w.now += 2000; + assert.equal(strike(0.5).amount, 20); + assert.equal(lastHit()?.crit, undefined); +}); + test("upgrades and hat come from the saved profile; no respec while a challenge is open", () => { const w = upgraded({ alice: { power: 2 } }); - assert.deepEqual(w.player("alice").stats, { power: 2, guard: 0, luck: 0 }); + assert.deepEqual(w.player("alice").stats, fullStats({ power: 2, guard: 0, luck: 0 })); // A chat command carrying the stored profile keeps them. w.apply({ type: "command", diff --git a/tests/ui.ts b/tests/ui.ts index 8bd7064..b1dab09 100644 --- a/tests/ui.ts +++ b/tests/ui.ts @@ -569,8 +569,19 @@ try { await page.locator("#tab-fighter").click(); assert.deepEqual( await page.locator("#upgrade-presets button").allTextContents(), - ["Balanced", "Brawler", "Lucky striker", "Iron wall"], - "only presets for the stats this channel has", + [ + "Balanced", + "Brawler", + "Lucky striker", + "Iron wall", + "Glass cannon", + "Counter-puncher", + "Duelist", + "Gambler", + "Ghost", + "Sharpshooter", + ], + "only presets whose stats exist (Tank and Bruiser wait for vitality)", ); assert.match((await page.locator("#upgrade-list").textContent())!, /Next level costs 1 point/); // element text, never null await page.getByRole("button", { name: "Put a level into Power for 1 point" }).click(); @@ -590,7 +601,7 @@ try { avatar: "adventurer", color: "#34d399", defaultAbility: "heal", - stats: { power: 2, guard: 0, luck: 1 }, + stats: { power: 2, guard: 0, luck: 1, crit: 0, dodge: 0, counter: 0 }, hat: "cap", pet: "mouse", recolor: "",