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: "",