diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index d509b24..fc7afb8 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -77,11 +77,11 @@ everything else is revalidated. | GET | `/api/state/:channel` | none | none | Snapshot (section 3) | 403, 405 | | GET | `/api/leaderboard/:channel` | none | none | Up to 100 `Profile` rows without `dollars` (`?private=1`, canManage only, keeps them; the mod page uses it), ordered by elo desc, then wins desc, then username (quick-duel results still on stream are not shown yet; see Game rules) | 403 | | GET | `/api/looks/:channel?u=login1,login2` | none | at most 20 logins | `{login:{avatar, color, hat, pet, petTier, recolor, petColor, accessory, trail, winEffect, taunt, title, displayName, elo}}` for viewers with a saved fighter only. The overlay uses it for chat-only viewers, batched every 2 s; a saved look is cached 5 min and asked for again when its viewer next chats after that, "no saved fighter" is cached 1 min, and a failed lookup (such as a 429) is retried after 10 s, not cached. Every registered profile in a live snapshot replaces that viewer's cached look, so a fighter changed on the website or a new Elo stays on stream after the server drops the viewer from its active list (10 quiet minutes). Changes to viewers off that list arrive as a looks push (section 3), and every minute the overlay asks again for everyone on stage the server doesn't list. | 403 | -| GET | `/api/se/:channel/:action?k=..&id=..&u=..&d=..&t=..&m=..` | the channel's StreamElements key `k` | `action` is `challenge`, `accept`, `decline`, `rematch` (challenges the sender's last finished-duel opponent), `top` (default name `!ranks`), `elo`, `help` (default name `!fray`), `look` (default name `!look`: a link to the Fighter tab, "pick your fighter" or "change your look"; also while duels are paused), `checkin` (once per stream while the channel is live; see Game rules), `wallet`, `pet` (default name `!pet`, also while duels are paused) or `give` (default name `!pay`, because StreamElements' built-in `!givepoints` already answers to `!give`; the amount comes as `a=`; see Game rules). The first command with the right key makes StreamElements the chat source (and deletes a Twitch EventSub subscription), unless a mod used Disconnect chat. Each action's last arrival is recorded (at most once a minute) for the admin badges; New key clears them. The Worker checks the request before any Durable Object call ("StreamElements route checks" below). | always 200 `text/plain`: the one-line chat reply for the bot to post (empty for a repeated message id) | 405 not GET, 404 path doesn't match or a well-formed key on a channel that isn't set up, 429 from the edge rate limit, 503 `INTERNAL_SECRET` missing | +| GET | `/api/se/:channel/:action?k=..&id=..&u=..&d=..&t=..&m=..` | the channel's StreamElements key `k` | `action` is `challenge`, `accept`, `decline`, `rematch` (challenges the sender's last finished-duel opponent), `top` (default name `!ranks`), `elo`, `help` (default name `!fray`), `look` (default name `!look`: a link to the Fighter tab, "pick your fighter" or "change your look"; also while duels are paused), `checkin` (once per stream while the channel is live; see Game rules), `wallet`, `pet` (default name `!pet`, also while duels are paused), `stats` (default name `!stats`, also while duels are paused) or `give` (default name `!pay`, because StreamElements' built-in `!givepoints` already answers to `!give`; the amount comes as `a=`; see Game rules). The first command with the right key makes StreamElements the chat source (and deletes a Twitch EventSub subscription), unless a mod used Disconnect chat. Each action's last arrival is recorded (at most once a minute) for the admin badges; New key clears them. The Worker checks the request before any Durable Object call ("StreamElements route checks" below). | always 200 `text/plain`: the one-line chat reply for the bot to post (empty for a repeated message id) | 405 not GET, 404 path doesn't match or a well-formed key on a channel that isn't set up, 429 from the edge rate limit, 503 `INTERNAL_SECRET` missing | | GET | `/api/catalog/:channel` | none | none | `[...static characters.json, ...custom entries]` (section 5) | 403 | -| GET | `/api/profile/:channel` | cookie | none | `Profile & {owned:{pets:[id], hats:[id], recolor:[id], petcolor:[id], accessory:[id], trail:[id], effect:[id], taunt:[id], title:[id], slots:n}, 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` | cookie | none | `Profile & {owned:{pets:[id], hats:[id], recolor:[id], petcolor:[id], accessory:[id], trail:[id], effect:[id], taunt:[id], title:[id], slots:n, rolls:{petId:[stat]}, rerolled:[petId]}, builds:[Loadout\|null]}` or `null`. `slots` is 1 free plus the bought ones; `rolls` holds the 4 stats each owned legendary pet rolled, and `rerolled` the legendaries whose free reroll is used; `builds[i]` is slot i's saved loadout (`avatar, color, defaultAbility, stats, hat, pet` and the 7 cosmetic fields), `null` if never saved; the active slot is always the profile itself | 401 | | GET | `/api/profile/:channel/others` | cookie | none | `{fighters:[{channel, avatar, color, defaultAbility, lastSeen}]}`: the viewer's saved fighters on other channels that are on or paused, newest `lastSeen` first. It checks the channels in AuthStore `fighters:` 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,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 | +| POST | `/api/profile/:channel` | cookie | `{avatar, color:"#rrggbb", defaultAbility:"strike"\|"heavy"\|"heal", stats?:{power,guard,luck,crit,dodge,counter,vitality}, 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) | @@ -95,7 +95,7 @@ everything else is revalidated. | POST | `/api/pets/:channel` | canManage | `{label:1-24 chars, tier, stat, stat2?, image:}`; at most 64 by 64 px and 64 KB. `stat` is power, guard or luck (not used for legendary); epic needs a different `stat2` | 201 `{ok:true, item}` | 400 `invalid_label`, `invalid_tier`, `invalid_stat`, `invalid_image`, `image_dimensions`; 409 `pet_limit_reached` (24 per channel); 413 `image_too_large`; 415 `not_png` | | DELETE | `/api/pets/:channel/:id` | canManage | none | `{ok:true}`. Removes the pet from every owner (no refund) and from fighters who bring it | 404 | | GET | `/api/shop/:channel` | none | none | `{pets (as /api/pets), hatPricePerWin, items:{recolor, petcolor, accessory, trail, effect, taunt, title:[{id, label, price}]}, slots:{max:5, prices:[2nd, 3rd, 4th, 5th]}}` with this channel's prices. Taunts and titles are fixed preset lines; nothing a viewer types reaches the stream. Open while the channel is paused | 403 | -| POST | `/api/shop/:channel` | cookie | `{kind:"pet"\|"hat"\|"slot"\|"recolor"\|"petcolor"\|"accessory"\|"trail"\|"effect"\|"taunt"\|"title", id, price?}` (`slot` needs no id: it buys the next build slot; `price` is the integer price the page showed, checked against the current one) | `{ok:true, reason:"bought", kind, id, price, dollars, owned}`. The dollars spent are the shown balance (hidden duel payouts don't count yet). Logged as a command | 400 `unknown_item`, `already_unlocked` (the hat's wins are reached), `hats_not_for_sale` (`hatPricePerWin` 0); 401; 403 while paused; 404 `no_fighter`; 409 `owned`, `not_enough` (`{price, dollars}`), `max_slots` (5 slots already), `price_changed` (`{price}`: a mod changed it since the page loaded) | +| POST | `/api/shop/:channel` | cookie | `{kind:"pet"\|"reroll"\|"hat"\|"slot"\|"recolor"\|"petcolor"\|"accessory"\|"trail"\|"effect"\|"taunt"\|"title", id, price?}` (`slot` needs no id: it buys the next build slot; `reroll` takes an owned legendary pet's id and rolls its 4 stats again for free, once, always to a different set; `price` is the integer price the page showed, checked against the current one) | `{ok:true, reason:"bought", kind, id, price, dollars, owned}`. The dollars spent are the shown balance (hidden duel payouts don't count yet). Logged as a command | 400 `unknown_item`, `already_unlocked` (the hat's wins are reached), `hats_not_for_sale` (`hatPricePerWin` 0); 401; 403 while paused; 404 `no_fighter`; 409 `owned`, `not_enough` (`{price, dollars}`), `max_slots` (5 slots already), `price_changed` (`{price}`: a mod changed it since the page loaded), `rerolled` (that pet's reroll is used), `in_duel` (a reroll while the fighter is in a challenge or duel); a reroll of a pet not owned or not legendary is 400 `unknown_item` | | GET | `/api/admin/:channel` | canManage | none | Snapshot plus `{chatStatus (section 4), history:[{version,config,actorId,actorName,at,note}] (newest first, 50 max; actorName is the Twitch display name at save time, empty for older rows), customUsage:{count,limit,bytes}, botStatus (section 4, bot replies), access:{owner,moderator,canManage,reason}, overlays (open role=overlay sockets), modsReady (broadcaster token stored), modsLapsed (see 2a), seOnly (only where the `SE_ONLY` text binding is set by hand; no configured site sets it), chatBot (`{login, debug}` where the PixFray bot is configured, else `null`; `login` is empty until the bot account has signed in), channelState ("builtin"\|"on"\|"paused"), streamelements:{key,names,commands,seen:{action:ms},duelModuleOff,lastCommandAt,rejectedAt,timerText}}` | 401 signed out, 403 not a mod | | POST | `/api/admin/:channel` | canManage | `{action, payload?}`, max 12000 bytes. Any `actorId` you send is replaced by the session user. | `{ok:true, reason, revision, ...}` | 400 / 403 / 404 / 409 with `{ok:false, reason, error}`; 400 `invalid_body` when `action` is not a non-empty string, `payload` is not an object, or a field the action reads has the wrong type (`takeover`, `value`, `names`, `note`) | | WS | `/api/live/:channel[?role=overlay]` | none | Upgrade | Read-only overlay socket (section 3). OBS overlays send `role=overlay` so the admin setup checklist can count them. At 200 sockets in a room, the oldest viewer-page socket is closed (1013 "room full") for the new one; overlay sockets are never dropped. | 426 without an upgrade, 429 over the per-network limit, 503 when all 200 are overlays | @@ -121,7 +121,7 @@ everything else is revalidated. `handleStreamElements` (server/streamelements.ts) answers these before any room is called, in order: 1. Method must be GET (405). 2. The path must match `/api/se//` (404 "Unknown command"). -3. The action must be one of the twelve above. Anything else (an old `!attack` command, say) answers 200 "Lost in the arena? Type !fray". +3. The action must be one of the thirteen above. Anything else (an old `!attack` command, say) answers 200 "Lost in the arena? Type !fray". 4. The key `k` must be present and at most 128 characters, or the reply is 200 "PixFray: missing key. Copy the commands again from the admin page." 5. The key must match `/^[a-f0-9]{48}$/`, or 200 "PixFray: wrong key. Copy the commands again from the admin page." 6. A refused (channel, key) pair is answered with the same wrong-key text from a per-isolate cache for 60 s, with no Durable Object request. The first refusal still reaches the room, which records `rejected_at` for the admin badge. A pair enters the cache when the room answers 403 with a reply. The cache holds 2000 pairs. The room's `/se` answer carries `X-Se-Key`, a hash of the channel's current key; the Worker keeps it per channel for 60 s and refuses other keys without a Durable Object request, letting one through every 10 s so a rotated key is picked up. @@ -248,7 +248,7 @@ Event types and their fields: | `challenge_created` | duelId, a, b, expiresAt | | `challenge_declined` | duelId, declinedBy | | `challenge_expired` | duelId, a, b | -| `duel_started` | duelId, a, b, round, hp | +| `duel_started` | duelId, a, b, round, hp, maxHp (`{userId: full hp}`, base `maxHp` plus vitality) | | `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 | @@ -450,12 +450,13 @@ 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), 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`. +- Upgrades (server/upgrades.ts): every win earns one point and check-ins add more (`bonus`), with no cap. Points buy levels in power (damage dealt), guard (damage taken), luck (the chance a quick-duel miss lands as a hit), crit (the chance a hit becomes a crit; in an HP fight, that a strike or heavy deals +50%), dodge (the chance a quick-duel hit or crit on you misses), counter (quick duels: +counter damage, and the chance a foe's miss becomes your counter) and vitality (+4 max hp a level, ceiling +60, in both duel kinds; the duel keeps each fighter's full hp in `duel.maxHp`, heals cap there, a flawless win needs it, and blows stay sized on the channel's base `maxHp`). Levels 1-8 cost 1 point each and give 4% each (counter: 2% damage and 2% chance); the cost doubles every 8 levels (9-16 cost 2, 17-24 cost 4), and above level 8 the effect closes in on a ceiling (power, guard and counter damage 60%, luck, crit, dodge and counter chance 50%; a 4% stat at level 16 gives about 44.2%). A quick-duel swing checks, in order and each with its own roll (`rolls[k * 24 + i]`, slot k: die 0, luck 1, crit 2, dodge 3, counter 4; the room sends 120 rolls): the attacker's luck, the attacker's crit, the defender's dodge, the defender's counter. Stored levels stop at 200. The channel's `maxStatLevel` caps the levels that count in its duels; levels above it stay saved. A rank reset that leaves too few points takes levels off vitality, then counter, dodge, crit, luck, guard and power. Profiles carry `bonus` (check-in points), `checkins` and `streak`; the profile answer's `upgrades` has `points`, `fromWins`, `fromCheckins` and `maxLevel`. The fighter page offers suggested builds (`PRESETS`) that fill the levels from the points; the viewer still saves. Points can be moved while the fighter is not in a duel. Hats are cosmetic; some unlock at a number of wins. A rank reset clears Elo and wins but keeps `bonus`. - 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 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`. +- Pets and the shop (server/pets.ts): 14 built-in pets in 5 tiers, plus up to 24 uploaded per channel. A fighter owns any number of pets (`owned_items`) and brings one (`profiles.pet`); hats can be bought too. A pet's boost is added after the channel's `maxStatLevel` cap: common and uncommon +1 to one stat, rare +2, epic +2 and +1 to a second stat (those use power, guard or luck), legendary +1 to 4 different stats out of all 7, rolled for each owner at purchase (`owned_items.roll`; legendaries bought before rolls existed get one when the room starts). Each owner may reroll a legendary once for free (`owned_items.rerolled`); the new set always differs, and it's refused while the fighter is in a challenge or duel. Pets cost `petPrice` dollars; a hat whose wins aren't reached costs `hatPricePerWin` times the wins it needs. The overlay draws the pet behind its fighter at about 40% of its height; epic and legendary pets sparkle, legendary pets hover. Snapshot players and `/api/looks` carry `pet` and `petTier`; players also carry `petBoost`. - 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`. +- `!stats [@name]` (the `stats` action): "@x: Power 10, Guard 8, Crit 4. 12 points unspent. Pet: +2 power." The levels are those that count in duels (capped by `maxStatLevel`), from shown results only; "no upgrades yet" with none. Reasons: `stats`, `no_fighter`. - `!pay @name amount` (the `give` action; on a channel the PixFray bot reads, it's `!give` and `!pay` stays an alias; `a=` is `20` or `$20`): moves dollars between two saved fighters while the channel is live (the same Twitch check as check-ins). In order it refuses with `give_off` (config `giveEnabled` false), `no_fighter`, `give_usage` (no target or no whole amount of at least 1), `target_not_found`, `self_give`, `twitch_error`, `not_live`, `too_few_duels` (fewer than `giveMinDuels` finished duels, `{duels, need}`), `give_cap` (already gave `giveMaxPerStream` this stream), `over_cap` (`{left}`), `not_enough` (`{dollars}`) and `target_full` (`{to, room}`: the receiver's wallet can't hold the amount, so nothing moves). Hidden duel results and payouts don't count yet. The limit is per Twitch stream id (`profiles.give_stream`, `given_in_stream`). Success is `given` with `{amount, to, dollars, left}`; the command log stores the amount. - Check-ins (the StreamElements `checkin` action; the test site's dev chat also takes `!checkin`, Twitch EventSub chat does not): once per stream, only while Twitch's Helix `/streams` says the channel is live (cached 60 s; a Twitch error answers "try again" and is not cached). Gives `checkinPoints` (bonus capped at 1,000,000). The streak counts the channel's streams in a row that had any check-in; one missed stream is forgiven once every 7 days. With `streakBonus`, a streak of 3, 7, 14 or 30 gives +1 more, 60 gives +2, 100 gives +3, 200 gives +5 and 365 gives +10. The reply names the total and what is unspent: "@x checked in: +2 points (7-stream streak, streak bonus). 18 points, 3 unspent. Spend them at ", or "…, all spent." with no link. Reasons: `checked_in`, `already_checked_in`, `not_live`, `no_fighter`, `twitch_error`. On the test Worker, `POST /api/devtools//live` with `{live, streamId?}` (DEV_TOOLS_TOKEN only) replaces the Twitch answer: `true` is live, `false` is offline, and `null` goes back to asking Twitch. - Ranked play needs a saved profile (`ranked_sign_in_required`). diff --git a/docs/DUELS.md b/docs/DUELS.md index 375cc6c..33b2086 100644 --- a/docs/DUELS.md +++ b/docs/DUELS.md @@ -35,7 +35,7 @@ The duel is rolled at once, the moment the challenge is accepted. ## Upgrades, hats and check-ins -- Each win earns an upgrade point and unlocks hats. Points have no cap. Points buy levels in six stats: +- Each win earns an upgrade point and unlocks hats. Points have no cap. Points buy levels in seven stats: | Stat | Each level (levels 1-8) | Ceiling | |---|---|---| @@ -45,15 +45,18 @@ The duel is rolled at once, the moment the challenge is accepted. | 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 | + | Vitality | +4 max HP | +60 HP | -- Luck, dodge and counter work in quick duels only. A swing checks luck, then crit, then dodge, then +- Luck, dodge and counter work in quick duels only. Vitality works in both: you start with more HP, heal up to it, and a flawless win needs all of it. 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. - `!checkin`, once per live stream, adds a point too, with streak bonuses: +1 at 3, 7, 14 and 30 streams, +2 at 60, +3 at 100, +5 at 200 and +10 at 365. -- `!pet` shows a fighter's pet. +- `!pet` shows a fighter's pet. `!stats` (or `!stats @name`) shows the upgrade levels that count, unspent points and the pet's boost. +- A legendary pet gives +1 to 4 different stats, rolled at random when you buy it. You can reroll them + once for free on the Fighter tab; the new set is always different. Not during a challenge or duel. ## PixFray dollars diff --git a/docs/STREAMER_SETUP.md b/docs/STREAMER_SETUP.md index ef8f610..5834d8b 100644 --- a/docs/STREAMER_SETUP.md +++ b/docs/STREAMER_SETUP.md @@ -44,8 +44,8 @@ with chat connected. - Type `/mod pixfray` in your chat. Twitch lets a bot that isn't a moderator answer only about one command a second. -- Type `!fray` to test. The bot answers 12 commands: `!challenge`, `!fight`, `!decline`, - `!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!elo`, `!ranks`, `!fray` and `!look`. The +- Type `!fray` to test. The bot answers 13 commands: `!challenge`, `!fight`, `!decline`, + `!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!stats`, `!elo`, `!ranks`, `!fray` and `!look`. The **Chat commands** section below the checklist adds your own text commands. - If StreamElements is also in your chat, turn off its Duel module (Chat bot, then Modules), or its `!duel` and `!accept` answer next to PixFray. @@ -96,8 +96,8 @@ In StreamElements go to Chat bot, then Modules, and switch off Duel, or type ### Add the commands to StreamElements -The table in **Add the chat commands to StreamElements** lists 12 commands: `!challenge`, `!fight`, `!decline`, -`!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!elo`, `!ranks`, `!fray` and `!look`. You can rename them first and click **Save names**. +The table in **Add the chat commands to StreamElements** lists 13 commands: `!challenge`, `!fight`, `!decline`, +`!rematch`, `!checkin`, `!wallet`, `!pay`, `!pet`, `!stats`, `!elo`, `!ranks`, `!fray` and `!look`. You can rename them first and click **Save names**. Keep `!pay` off `!give`: StreamElements' built-in `!givepoints` already answers to `!give`. The commands run as StreamElements custom commands; Nightbot and Fossabot can't run them. diff --git a/server/channel.ts b/server/channel.ts index 1ccd5b7..ab7cc22 100644 --- a/server/channel.ts +++ b/server/channel.ts @@ -4,6 +4,7 @@ import { chatStatus, createInitialState, defaultConfig, + hasOpenDuel, hiddenResults, NEW_ACCOUNT_MS, normalizeGameState, @@ -30,7 +31,7 @@ import { HATS, STATS, } from "./upgrades.ts"; -import { ensurePetSchema, handleRoomPets, petCatalog, petOf, hatPrice } from "./pets.ts"; +import { ensurePetSchema, handleRoomPets, petCatalog, petOf, hatPrice, parseRoll, rollLegendary } from "./pets.ts"; import { ensureSpriteSchema, handleRoomSprites, spriteCatalog } from "./sprites.ts"; import { COSMETIC_KINDS, @@ -73,6 +74,7 @@ import { seWalletText, seGiveText, sePetText, + seStatsText, seAmount, seTarget, seReminderText, @@ -97,6 +99,7 @@ import type { SendResult } from "./model.ts"; import type { SpriteRow } from "./sprites.ts"; import type { CatalogEntry } from "./uploads.ts"; import type { CosmeticKind } from "./cosmetics.ts"; +import type { Stat } from "./upgrades.ts"; import type { Env } from "./env.ts"; import type { AdminEvent, @@ -132,6 +135,7 @@ type ProfileTable = { crit: number; dodge: number; counter: number; + vitality: number; hat: string; last_opponent: string; bonus_points: number; @@ -166,7 +170,7 @@ type ProfileRow = Omit< | "give_stream" | "account_created_at" | "account_checked_at" -> & { pet_custom: string | null; last_opponent?: string }; +> & { pet_custom: string | null; pet_roll: string | null; last_opponent?: string }; /** A saved fighter as normalizeProfileRow builds it: the player plus the columns only the profile keeps. */ type Profile = Player & { checkins: number; streak: number; dollars: number; build: number }; /** A config_history row. */ @@ -227,7 +231,9 @@ type ChatOutcome = Pick; revealAt: number }; /** How a bot command reached the room, for its replies and the command log. */ @@ -306,6 +312,7 @@ const E2E_STEPS: [who: "B" | "O", action: string, target: "" | "O", want: string ["B", "elo", "O", ["elo"]], ["B", "wallet", "", ["wallet"]], ["B", "pet", "", ["pet", "no_pet"]], + ["B", "stats", "", ["stats"]], ["B", "checkin", "", ["checked_in", "already_checked_in", "not_live"]], ["B", "challenge", "", ["target_required"]], ["B", "challenge", "O", ["challenge"], true], @@ -367,7 +374,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, 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"; + "user_id, username, display_name, avatar, color, default_ability, elo, wins, losses, last_seen, power, guard, luck, crit, dodge, counter, vitality, 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, (SELECT roll FROM owned_items o WHERE o.user_id = profiles.user_id AND o.kind = 'pet' AND o.item_id = profiles.pet) AS pet_roll"; function json(data: unknown, status = 200, headers: { [name: string]: string } = {}): Response { return new Response(JSON.stringify(data), { @@ -474,9 +481,10 @@ function normalizeProfileRow(row: ProfileRow | null | undefined, config: Config) }; } -// The active pet (server/pets.ts): its id, tier and boost; none when it's gone (a deleted upload). -function petFieldsOf(row: { pet: string; pet_custom: string | null }) { - const pet = petOf(row.pet, row.pet_custom); +// The active pet (server/pets.ts): its id, tier and boost (a legendary's from the owner's roll); none when it's gone +// (a deleted upload). +function petFieldsOf(row: { pet: string; pet_custom: string | null; pet_roll: string | null }) { + const pet = petOf(row.pet, row.pet_custom, row.pet_roll); return { pet: pet ? pet.id : "", petTier: pet ? pet.tier : "", petBoost: pet ? pet.boost : emptyStats() }; } @@ -549,7 +557,7 @@ export class ChannelRoom extends DurableObject { .exec<{ name: string }>("PRAGMA table_info(profiles)") .toArray() .map((c) => c.name); - // v2.10: crit, dodge and counter join the upgrade stats. + // v2.10: crit, dodge and counter join the upgrade stats; v2.11: vitality. for (const column of STATS) if (!profileColumns.includes(column)) sql.exec(`ALTER TABLE profiles ADD COLUMN ${column} INTEGER NOT NULL DEFAULT 0`); @@ -570,7 +578,7 @@ export class ChannelRoom extends DurableObject { sql.exec("ALTER TABLE profiles ADD COLUMN give_stream TEXT NOT NULL DEFAULT ''"); // v2.8: the active pet, plus bought items and uploaded pets (server/pets.ts). PROFILE_COLUMNS reads custom_pets. if (!profileColumns.includes("pet")) sql.exec("ALTER TABLE profiles ADD COLUMN pet TEXT NOT NULL DEFAULT ''"); - ensurePetSchema(sql); + const rolled = ensurePetSchema(sql); // v3.1: sprites viewers made from their own images, waiting for a mod or approved (server/sprites.ts). ensureSpriteSchema(sql); // v2.9: cosmetics worn (server/cosmetics.ts) and the active build slot. Each slot's loadout is a JSON row in builds; @@ -655,6 +663,15 @@ export class ChannelRoom extends DurableObject { if (!seColumns.includes("duel_module_off")) sql.exec("ALTER TABLE se_settings ADD COLUMN duel_module_off INTEGER NOT NULL DEFAULT 0"); this.seenMessages = new Map(); // EventSub Message-Id -> receivedAt; commands are also deduped durably by message_id + // Legendary pets ensurePetSchema just rolled: fighters on stage take their rolled boost. + const stored = rolled.length ? this.readStoredState() : null; + if (typeof stored?.channel === "string") { + const state = this.readState(stored.channel); + if (this.syncPetBoosts(state, rolled)) { + state.revision += 1; + this.writeState(state); + } + } } async fetch(request: Request): Promise { @@ -781,14 +798,19 @@ export class ChannelRoom extends DurableObject { const userId = validUserId(request.headers.get(USER_HEADER)); if (!userId || userId !== validUserId(body.value.userId)) return json({ error: "profile identity mismatch" }, 403); - const bought = this.buy(channel, userId, body.value); + // the Worker's parseShopBuy passed only a string id; a reroll looks it up and finds nothing else + const bought = + body.value.kind === "reroll" + ? this.reroll(channel, userId, String(body.value.id)) + : this.buy(channel, userId, body.value); + if (bought.ok && "state" in bought && bought.state) this.broadcast(bought.state); return json( bought.ok ? bought : { ...bought, error: bought.reason }, bought.ok ? 200 : bought.reason === "no_fighter" ? 404 - : ["not_enough", "owned", "max_slots", "price_changed"].includes(bought.reason) + : ["not_enough", "owned", "max_slots", "price_changed", "rerolled", "in_duel"].includes(bought.reason) ? 409 : 400, ); @@ -1360,7 +1382,16 @@ export class ChannelRoom extends DurableObject { ) .toArray()[0]?.c : ""; - const pet = petId ? petOf(petId, custom) : null; + const roll = petId + ? this.ctx.storage.sql + .exec<{ roll: string }>( + "SELECT roll FROM owned_items WHERE user_id = ? AND kind = 'pet' AND item_id = ?", + userId, + petId, + ) + .toArray()[0]?.roll + : ""; + const pet = petId ? petOf(petId, custom, roll) : null; if (petId && (!pet || !owns("pet", petId))) return { ok: false, reason: "pet_locked" }; // Cosmetics: "" (none) or one the fighter bought. Left out = keep the saved one. const cosmetics: Partial = {}; @@ -1501,9 +1532,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, crit, dodge, counter, 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, vitality, 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, crit = excluded.crit, dodge = excluded.dodge, counter = excluded.counter, hat = excluded.hat, " + + "power = excluded.power, guard = excluded.guard, luck = excluded.luck, crit = excluded.crit, dodge = excluded.dodge, counter = excluded.counter, vitality = excluded.vitality, 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. @@ -1518,9 +1549,15 @@ export class ChannelRoom extends DurableObject { profile.wins, profile.losses, Number.isInteger(profile.lastSeen) ? profile.lastSeen : 0, - ...(({ power, guard, luck, crit, dodge, counter }) => [power, guard, luck, crit, dodge, counter])( - cleanStats(profile.stats), - ), + ...(({ power, guard, luck, crit, dodge, counter, vitality }) => [ + power, + guard, + luck, + crit, + dodge, + counter, + vitality, + ])(cleanStats(profile.stats)), knownHat(profile.hat) ? profile.hat : "", String(profile.lastOpponentId || "").slice(0, 32), typeof profile.pet === "string" ? profile.pet : null, @@ -2277,6 +2314,21 @@ export class ChannelRoom extends DurableObject { info ? (info.pet ? "pet" : "no_pet") : "no_fighter", ); } + if (action === "stats") { + // like !pet, it works while duels are paused + const self = !target || target === username, + info = this.statsInfo(channel, self ? { userId } : { username: target }); + return done( + // the sender's name as sent; short() reads it through String() + seStatsText(info, { + who: (input.displayName as string) || username, + target: self ? "" : target, + origin, + channel, + }), + info ? "stats" : "no_fighter", + ); + } if (action === "give") { const given = await this.give(channel, { userId, target, amount: seAmount(input.amount) }, now); return done( @@ -2430,9 +2482,10 @@ export class ChannelRoom extends DurableObject { | "crit" | "dodge" | "counter" + | "vitality" > >( - "SELECT wins, bonus_points, streak, last_stream, last_stream_seq, free_miss_at, power, guard, luck, crit, dodge, counter FROM profiles WHERE user_id = ?", + "SELECT wins, bonus_points, streak, last_stream, last_stream_seq, free_miss_at, power, guard, luck, crit, dodge, counter, vitality FROM profiles WHERE user_id = ?", userId, ) .toArray()[0]; @@ -2571,15 +2624,20 @@ export class ChannelRoom extends DurableObject { [kind in CosmeticKind]: string[]; }), slots: 1, + rolls: {}, + rerolled: [], }; for (const r of this.ctx.storage.sql - .exec<{ kind: string; item_id: string }>( - "SELECT kind, item_id FROM owned_items WHERE user_id = ? ORDER BY bought_at", + .exec<{ kind: string; item_id: string; roll: string; rerolled: number }>( + "SELECT kind, item_id, roll, rerolled FROM owned_items WHERE user_id = ? ORDER BY bought_at", userId, ) .toArray()) { - if (r.kind === "pet") out.pets.push(r.item_id); - else if (r.kind === "hat") out.hats.push(r.item_id); + if (r.kind === "pet") { + out.pets.push(r.item_id); + if (r.roll) out.rolls[r.item_id] = parseRoll(r.roll); + if (r.rerolled) out.rerolled.push(r.item_id); + } else if (r.kind === "hat") out.hats.push(r.item_id); else if (r.kind === "slot") out.slots += 1; // a cosmetic kind; any other reads undefined and is skipped else if (out[r.kind as CosmeticKind]) out[r.kind as CosmeticKind].push(r.item_id); @@ -2632,7 +2690,8 @@ export class ChannelRoom extends DurableObject { if (!row) return { ok: false, reason: "no_fighter" }; const h = hiddenResults(state, now).get(userId) || { wins: 0, dollars: 0 }; let price: number | null = null; - if (kind === "pet") price = petCatalog(sql, channel, config).find((p) => p.id === id)?.price ?? null; + const pet = kind === "pet" ? petCatalog(sql, channel, config).find((p) => p.id === id) : undefined; + if (kind === "pet") price = pet?.price ?? null; else if (kind === "hat") { const hat = HATS.find((x) => x.id && x.id === id); if (hat && hatUnlocked(hat.id, row.wins - h.wins)) return { ok: false, reason: "already_unlocked" }; @@ -2658,12 +2717,13 @@ export class ChannelRoom extends DurableObject { if (price > dollars) return { ok: false, reason: "not_enough", price, dollars }; sql.exec("UPDATE profiles SET dollars = dollars - ? WHERE user_id = ?", price, userId); sql.exec( - "INSERT INTO owned_items (user_id, kind, item_id, price, bought_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO owned_items (user_id, kind, item_id, price, bought_at, roll) VALUES (?, ?, ?, ?, ?, ?)", userId, kind, id, price, now, + pet?.tier === "legendary" ? rollLegendary().join(",") : "", ); logRoomEvent(this, "command", userId + " bought " + kind + " " + id + " for " + price, { channel, @@ -2677,7 +2737,92 @@ export class ChannelRoom extends DurableObject { }); } + // A legendary pet's one free reroll (shop kind "reroll"): new rolled stats, never the same set. Refused while that pet + // is in an open duel, like a build change. state is the stage to broadcast when the pet is out. + reroll(channel: string, userId: string, id: string) { + return this.ctx.storage.transactionSync(() => { + const sql = this.ctx.storage.sql, + state = this.readState(channel); + const fighter = sql + .exec>("SELECT dollars FROM profiles WHERE user_id = ?", userId) + .toArray()[0]; + if (!fighter) return { ok: false, reason: "no_fighter" }; + const pet = petCatalog(sql, channel, state.config).find((p) => p.id === id); + const row = + pet?.tier === "legendary" + ? sql + .exec<{ roll: string; rerolled: number }>( + "SELECT roll, rerolled FROM owned_items WHERE user_id = ? AND kind = 'pet' AND item_id = ?", + userId, + id, + ) + .toArray()[0] + : undefined; + if (!row) return { ok: false, reason: "unknown_item" }; + if (row.rerolled) return { ok: false, reason: "rerolled" }; + const out = state.players.some((p) => p.userId === userId && p.pet === id); + if (out && hasOpenDuel(state, userId)) return { ok: false, reason: "in_duel" }; + const roll = rollLegendary(Math.random, parseRoll(row.roll)); + sql.exec( + "UPDATE owned_items SET roll = ?, rerolled = 1 WHERE user_id = ? AND kind = 'pet' AND item_id = ?", + roll.join(","), + userId, + id, + ); + logRoomEvent(this, "command", userId + " rerolled pet " + id + " to " + roll.join(","), { + channel, + action: "reroll", + userId, + id, + }); + const changed = out && this.syncPetBoosts(state, [userId]); + if (changed) { + state.revision += 1; + this.writeState(state); + } + const dollars = Math.max(0, fighter.dollars - (hiddenResults(state, Date.now()).get(userId)?.dollars || 0)); + return { + ok: true, + reason: "rerolled", + kind: "reroll", + id, + dollars, + owned: this.owned(userId), + state: changed ? state : null, + }; + }); + } + + // Fighters on stage whose active pet's stored roll differs from their boost take the stored one; true when any did. + syncPetBoosts(state: GameState, userIds: readonly string[]): boolean { + let changed = false; + for (const p of state.players) { + if (!p.pet || !userIds.includes(p.userId)) continue; + const saved = this.getProfile(p.userId, state.config); + if (saved?.pet !== p.pet || JSON.stringify(saved.petBoost) === JSON.stringify(p.petBoost)) continue; + p.petBoost = saved.petBoost; + changed = true; + } + return changed; + } + // !pet [@name]: a saved fighter's active pet as the catalog names it, or null when there's no such fighter. + // !stats [@name]: the upgrade levels that count in duels, unspent points and the pet's boost, as the stream has shown + // them (a quick duel still playing doesn't give its win away, like !wallet). + statsInfo(channel: string, { userId = "", username = "" }: { userId?: string; username?: string }) { + const state = this.readState(channel); + const found = userId ? this.getProfile(userId, state.config) : this.getProfileByUsername(username, state.config); + if (!found) return null; + const profile = shownProfile(found, hiddenResults(state, Date.now())); + const own = effectiveStats(profile.stats, profile.wins, profile.bonus); + return { + name: profile.displayName || profile.username, + stats: effectiveStats(own, profile.wins, profile.bonus, null, state.config.maxStatLevel), + unspent: Math.max(0, pointsFor(profile.wins, profile.bonus) - spentOn(own)), + petBoost: profile.petBoost, + }; + } + petInfo(channel: string, { userId = "", username = "" }: { userId?: string; username?: string }) { const config = this.readState(channel).config; const profile = userId ? this.getProfile(userId, config) : this.getProfileByUsername(username, config); @@ -2687,7 +2832,7 @@ export class ChannelRoom extends DurableObject { : null; return { name: profile.displayName || profile.username, - pet: item ? { label: item.label, tier: item.tier, boost: item.boost } : null, + pet: item ? { label: item.label, tier: item.tier, boost: profile.petBoost } : null, // a legendary's own roll }; } diff --git a/server/developer.ts b/server/developer.ts index bf59225..15786e1 100644 --- a/server/developer.ts +++ b/server/developer.ts @@ -1272,6 +1272,7 @@ function roomExport(room: DevRoom, channel: string) { crit: p.crit, dodge: p.dodge, counter: p.counter, + vitality: p.vitality, hat: p.hat, lastOpponentId: p.last_opponent, bonus: p.bonus_points, diff --git a/server/game.ts b/server/game.ts index 00f879e..baf361b 100644 --- a/server/game.ts +++ b/server/game.ts @@ -1,5 +1,6 @@ import site from "../site.config.js"; import { + bonusHp, cleanStats, counterChance, effectiveStats, @@ -265,7 +266,7 @@ function duelHasUser(duel: Duel, userId: string): boolean { return duel.a === userId || duel.b === userId; } -function hasOpenDuel(state: GameState, userId: string): boolean { +export function hasOpenDuel(state: GameState, userId: string): boolean { return openDuels(state).some((duel) => duelHasUser(duel, userId)); } @@ -495,7 +496,14 @@ function beginDuel(state: GameState, duel: Duel, now: number, event: { newAccoun duel.status = "active"; duel.startedAt = now; duel.lastActionAt = now; - duel.hp = { [duel.a]: state.config.maxHp, [duel.b]: state.config.maxHp }; + // Each fighter's max HP: the channel's plus their vitality. + const vitality = (p: Player): number => + effectiveStats(p.stats, p.wins, p.bonus, p.petBoost, state.config.maxStatLevel).vitality; + duel.maxHp = { + [duel.a]: state.config.maxHp + bonusHp(vitality(a)), + [duel.b]: state.config.maxHp + bonusHp(vitality(b)), + }; + duel.hp = clone(duel.maxHp); duel.rules = { maxHp: state.config.maxHp, inactivityMs: state.config.inactivityMs, @@ -508,8 +516,8 @@ function beginDuel(state: GameState, duel: Duel, now: number, event: { newAccoun [duel.a]: { sharedUntil: 0, strikeUntil: 0, heavyUntil: 0, healUntil: 0 }, [duel.b]: { sharedUntil: 0, strikeUntil: 0, heavyUntil: 0, healUntil: 0 }, }; - a.hp = state.config.maxHp; - b.hp = state.config.maxHp; + a.hp = fullHp(duel, a.userId); + b.hp = fullHp(duel, b.userId); a.respawnAt = 0; b.respawnAt = 0; addEvent(state, "duel_started", now, { @@ -518,12 +526,17 @@ function beginDuel(state: GameState, duel: Duel, now: number, event: { newAccoun b: duel.b, round: duel.round, hp: clone(duel.hp), + maxHp: clone(duel.maxHp), }); return { ok: true, duelId: duel.id }; } +// A fighter's full health in a started duel: their own max HP, or the rules' for a duel started before vitality. +// rules!: only started duels reach here, and beginDuel sets rules +const fullHp = (duel: Duel, userId: string): number => duel.maxHp?.[userId] ?? duel.rules!.maxHp; + // Quick duel: a d6 exchange settled at once. Fighters take turns swinging, the challenger first: -// 6 is a crit (50% of max HP), 5 a hit (34%), 3-4 a miss, 1-2 the other fighter counters (34%). +// 6 is a crit (50% of the channel's 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) then change the swing in this order, each check with its own roll (slot k reads @@ -655,7 +668,7 @@ function settleQuickDuel(state: GameState, duel: Duel, rolls: unknown, now: numb } [attacker, defender] = [defender, attacker]; } - const flawless = duel.hp[winnerId] === maxHp; + const flawless = duel.hp[winnerId] === fullHp(duel, winnerId); finishDuel(state, duel, winnerId, now, { decision, bonus: flawless ? QUICK_FLAWLESS_BONUS : 0 }); duel.revealAt = now + replayMs(swings.length) + state.config.streamDelayMs; return { @@ -787,7 +800,7 @@ function applyAbility( if (abilityName === "heal") { const before = duel.hp[actor.userId]!; // the heal branch, so ability is abilities.heal - duel.hp[actor.userId] = Math.min(duel.rules!.maxHp, before + (ability as Abilities["heal"]).amount); + duel.hp[actor.userId] = Math.min(fullHp(duel, actor.userId), before + (ability as Abilities["heal"]).amount); amount = duel.hp[actor.userId]! - before; } else { const before = duel.hp[targetId]!; @@ -1055,11 +1068,10 @@ function applyAdmin(state: GameState, event: AdminEvent, now: number): GameResul for (const duel of state.duels) { if (duel.status !== "active") continue; for (const userId of [duel.a, duel.b]) { - // rules! (twice): an active duel has rules - duel.hp[userId] = duel.rules!.maxHp; + duel.hp[userId] = fullHp(duel, userId); const p = player(state, userId); if (p) { - p.hp = duel.rules!.maxHp; + p.hp = fullHp(duel, userId); p.respawnAt = 0; } } diff --git a/server/model.ts b/server/model.ts index 13ee16d..707395d 100644 --- a/server/model.ts +++ b/server/model.ts @@ -146,6 +146,8 @@ export type Duel = { lastActionAt?: number; round?: number; rules?: DuelRules; + /** userId -> full health in this duel: rules.maxHp plus vitality (server/upgrades.ts bonusHp). */ + maxHp?: Record; cooldowns?: Record; /** Played out, but moves no Elo, wins, losses or dollars. */ unrated?: "new_account" | "pair_cap"; diff --git a/server/parse.ts b/server/parse.ts index 051511d..d79a9fe 100644 --- a/server/parse.ts +++ b/server/parse.ts @@ -142,8 +142,11 @@ export function parseProfileSave(body: JsonObject): Parsed { // ---------- Shop ---------- -/** Shop kinds: pets, hats, build slots and the cosmetics of server/cosmetics.ts (the room checks the ids and prices). */ -export const SHOP_KINDS: readonly string[] = ["pet", "hat", "slot", ...COSMETIC_KINDS]; +/** + * Shop kinds: pets, hats, build slots and the cosmetics of server/cosmetics.ts (the room checks the ids and prices), + * plus "reroll", a legendary pet's free reroll (id = the pet). + */ +export const SHOP_KINDS: readonly string[] = ["pet", "hat", "slot", ...COSMETIC_KINDS, "reroll"]; /** A purchase as the Worker forwards it. */ export type ShopBuy = { kind: string; id: string; price: number | undefined }; // POST /api/shop/: a known kind, an id up to 64 ("" for a slot), and optionally the price the buyer saw diff --git a/server/pets.ts b/server/pets.ts index fb8f847..8b0a62f 100644 --- a/server/pets.ts +++ b/server/pets.ts @@ -1,12 +1,13 @@ // Pets (docs/PROGRESSION_PLAN.md, Stage 3): bought with PixFray dollars, one active per fighter, each with a small // stat boost by tier. Built-in pets are drawn in code (src/served/pets.ts, same ids); streamers can upload their own as -// PNGs (custom_pets, ids "p-..."), picking the tier and the boosted stat. +// PNGs (custom_pets, ids "p-..."), picking the tier and the boosted stat. A legendary pet boosts LEGENDARY_ROLL stats +// rolled for each owner when they buy it (owned_items.roll), with one free reroll (owned_items.rerolled). // // GET /api/pets/:channel public: { pets:[{id,label,tier,boost,price,custom,url?}], hatPricePerWin } // 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 { emptyStats } from "./upgrades.ts"; +import { emptyStats, STATS } from "./upgrades.ts"; import { readPng } from "./uploads.ts"; import type { Stat, Stats } from "./upgrades.ts"; import type { Config } from "./model.ts"; @@ -104,14 +105,15 @@ export const PRICE_KEYS: Record = { epic: "petPriceEpic", legendary: "petPriceLegendary", }; -// Pets boost power, guard and luck only; crit, dodge and counter come from upgrade points. +// Common to epic pets boost power, guard or luck; a legendary's rolled stats can be any of STATS. 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 each of PET_STATS. +export const LEGENDARY_ROLL = 4; +// Built-in pets: the stat (and second stat for epic) the tier boost goes to. A legendary's stat is unused (rolled). export const PETS: BuiltinPet[] = [ { id: "mouse", label: "Mouse", tier: "common", stat: "power" }, { id: "chick", label: "Chick", tier: "common", stat: "luck" }, @@ -130,13 +132,13 @@ export const PETS: BuiltinPet[] = [ ]; const BUILTIN = new Map(PETS.map((p) => [p.id, p])); -// common/uncommon +1 to one stat, rare +2, epic +2 and +1 to a second stat, legendary +1 to every stat. -// Uncommon is cheaper than rare and looks better than common; its boost matches common on purpose (the plan keeps -// boosts small so a pet never decides a duel on its own). -export function tierBoost(tier: string, stat: string | undefined, stat2?: string): Stats { +// common/uncommon +1 to one stat, rare +2, epic +2 and +1 to a second stat, legendary +1 to each rolled stat (none +// before an owner rolls it, as in the catalog). Uncommon is cheaper than rare and looks better than common; its boost +// matches common on purpose (the plan keeps boosts small so a pet never decides a duel on its own). +export function tierBoost(tier: string, stat: string | undefined, stat2?: string, roll: readonly Stat[] = []): Stats { const out = emptyStats(); if (tier === "legendary") { - for (const key of PET_STATS) out[key] = 1; + for (const key of roll) out[key] = 1; return out; } // Widened so any string can be looked up @@ -150,7 +152,7 @@ export function tierBoost(tier: string, stat: string | undefined, stat2?: string export function cleanBoost(input?: unknown): Stats { const out = emptyStats(); - for (const key of PET_STATS) { + for (const key of 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; @@ -160,26 +162,45 @@ export function cleanBoost(input?: unknown): Stats { export const builtinPet = (id: string): BuiltinPet | null => BUILTIN.get(id) || null; -// The pet a profile row points at: a built-in id, or a custom one with "tier:stat:stat2" read from custom_pets. -// null when there's no pet or it no longer exists. -export function petOf(id: string, custom: unknown = ""): PetRef | null { +// A legendary roll: LEGENDARY_ROLL different stats in STATS order. A reroll never gives back the same set. +export function rollLegendary(random: () => number = Math.random, not: readonly Stat[] = []): Stat[] { + for (;;) { + const pool: Stat[] = [...STATS], + picked = new Set(); + // pool keeps STATS.length - picked.size entries, so the index is always inside it + while (picked.size < LEGENDARY_ROLL) picked.add(pool.splice(Math.floor(random() * pool.length), 1)[0]!); + const roll = STATS.filter((s) => picked.has(s)); + if (roll.join() !== not.join()) return roll; + } +} +// owned_items.roll ("crit,dodge,luck,vitality") -> the stats, or [] when it isn't a whole roll. +export function parseRoll(text: unknown): Stat[] { + const words = String(text || "").split(","); + const roll = STATS.filter((s) => words.includes(s)); + return roll.length === LEGENDARY_ROLL && words.length === LEGENDARY_ROLL ? roll : []; +} + +// The pet a profile row points at: a built-in id, or a custom one with "tier:stat:stat2" read from custom_pets, with +// the owner's roll for a legendary. null when there's no pet or it no longer exists. +export function petOf(id: string, custom: unknown = "", roll: unknown = ""): PetRef | null { if (!id) return null; const pet = builtinPet(id); - if (pet) return { id, tier: pet.tier, boost: tierBoost(pet.tier, pet.stat, pet.stat2) }; + if (pet) return { id, tier: pet.tier, boost: tierBoost(pet.tier, pet.stat, pet.stat2, parseRoll(roll)) }; if (!CUSTOM_PET_ID.test(id) || !custom) return null; const [tier, stat, stat2] = String(custom).split(":"); // split() always returns at least one element, so tier is a string - return TIERS.includes(tier!) ? { id, tier: tier!, boost: tierBoost(tier!, stat, stat2) } : null; + return TIERS.includes(tier!) ? { id, tier: tier!, boost: tierBoost(tier!, stat, stat2, parseRoll(roll)) } : null; } -// "+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 => - 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(", "); +// "+2 power" / "+2 power, +1 guard" / "+1 power, guard and luck" / "+1 guard, crit, dodge and vitality" (same wording +// as the viewer page). Three or more stats with the same boost share one number. +export function boostText(boost: Stats): string { + const keys = STATS.filter((k) => boost?.[k] > 0); + const first = keys[0]; + if (first && keys.length >= 3 && keys.every((k) => boost[k] === boost[first])) + return `+${boost[first]} ${keys.slice(0, -1).join(", ")} and ${keys.at(-1)}`; + return keys.map((k) => `+${boost[k]} ${k}`).join(", "); +} export const petPrice = (tier: string, config: PetPriceConfig | null | undefined): number | null => // An unknown tier indexes with undefined, which reads undefined, so ?? gives null @@ -191,7 +212,7 @@ export const hatPrice = ( ): number | null => (hat && hat.wins > 0 && config?.hatPricePerWin > 0 ? hat.wins * config.hatPricePerWin : null); // Schema: called from ChannelRoom's constructor (before any profile query, which reads custom_pets). -export function ensurePetSchema(sql: SqlStorage): void { +export function ensurePetSchema(sql: SqlStorage): string[] { sql.exec( "CREATE TABLE IF NOT EXISTS custom_pets (id TEXT PRIMARY KEY, label TEXT NOT NULL, tier TEXT NOT NULL, stat TEXT NOT NULL, stat2 TEXT NOT NULL DEFAULT '', png BLOB NOT NULL, bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL)", ); @@ -199,6 +220,28 @@ export function ensurePetSchema(sql: SqlStorage): void { sql.exec( "CREATE TABLE IF NOT EXISTS owned_items (user_id TEXT NOT NULL, kind TEXT NOT NULL, item_id TEXT NOT NULL, price INTEGER NOT NULL, bought_at INTEGER NOT NULL, PRIMARY KEY (user_id, kind, item_id))", ); + // v2.11: a legendary pet's rolled stats and whether its free reroll is used. Legendaries bought before roll here. + const columns = sql + .exec<{ name: string }>("PRAGMA table_info(owned_items)") + .toArray() + .map((c) => c.name); + if (!columns.includes("roll")) sql.exec("ALTER TABLE owned_items ADD COLUMN roll TEXT NOT NULL DEFAULT ''"); + if (!columns.includes("rerolled")) sql.exec("ALTER TABLE owned_items ADD COLUMN rerolled INTEGER NOT NULL DEFAULT 0"); + const legendary = PETS.filter((p) => p.tier === "legendary").map((p) => p.id); + const rolled = sql + .exec<{ user_id: string; item_id: string }>( + `SELECT user_id, item_id FROM owned_items WHERE kind = 'pet' AND roll = '' AND (item_id IN (${legendary.map(() => "?").join(", ")}) OR item_id IN (SELECT id FROM custom_pets WHERE tier = 'legendary'))`, + ...legendary, + ) + .toArray(); + for (const r of rolled) + sql.exec( + "UPDATE owned_items SET roll = ? WHERE user_id = ? AND kind = 'pet' AND item_id = ?", + rollLegendary().join(","), + r.user_id, + r.item_id, + ); + return rolled.map((r) => r.user_id); } // The channel's pet catalog with prices from its config: built-ins first, then uploads in upload order. diff --git a/server/streamelements.ts b/server/streamelements.ts index d78916e..e0481bc 100644 --- a/server/streamelements.ts +++ b/server/streamelements.ts @@ -5,6 +5,7 @@ import { hiddenResults, PAIR_RATED_PER_DAY } from "./game.ts"; import { boostText } from "./pets.ts"; import type { Env } from "./env.ts"; import type { GameResult, GameState, Player } from "./model.ts"; +import { STATS } from "./upgrades.ts"; import type { Stats } from "./upgrades.ts"; /** A channel's command names (se_settings.names), action -> trigger word; a missing one uses DEFAULT_SE_NAMES. */ @@ -26,6 +27,7 @@ export const SE_ACTIONS = [ "wallet", "give", "pet", + "stats", ]; // quick duels need no attack commands export const SE_READ_ACTIONS = ["top", "elo", "help", "look"]; // no game state, so they work while duels are paused // StreamElements has a built-in !top that can't be edited, so the leaderboard command is !ranks. @@ -42,6 +44,7 @@ export const DEFAULT_SE_NAMES = { wallet: "!wallet", give: "!pay", pet: "!pet", + stats: "!stats", }; // not !give: StreamElements' built-in !givepoints answers to that export const LOST_TEXT = "Lost in the arena? Type !fray"; export const MISSED_TEXT = "That move didn't land. Try again in a moment!"; @@ -426,6 +429,27 @@ export function sePetText( return `@${target ? short(p.name) : me}'s pet: ${p.pet.label} (${p.pet.tier}), ${boostText(p.pet.boost)}.`; } +// !stats [@name]: s = ChannelRoom.statsInfo() -> { name, stats (levels that count in duels), unspent, petBoost }, or +// null (no fighter). "@x: Power 10, Guard 8, Crit 4. 12 points unspent. Pet: +2 power." +export function seStatsText( + s: { name: string; stats: Stats; unspent: number; petBoost: Stats } | null | undefined, + { + who = "", + target = "", + origin = "", + channel = "", + }: { who?: string | undefined; target?: string | undefined } & SeLinkOptions = {}, +): string { + const me = short(who) || "you"; + if (!s) return target ? noFighter(target, false, origin, channel) : noFighter(me, true, origin, channel); + const levels = STATS.filter((k) => s.stats[k] > 0).map((k) => k[0]!.toUpperCase() + k.slice(1) + " " + s.stats[k]); // k[0]!: stat names aren't empty + const pet = boostText(s.petBoost); + return ( + `@${target ? short(s.name) : me}: ${levels.length ? levels.join(", ") : "no upgrades yet"}. ` + + `${s.unspent} point${s.unspent === 1 ? "" : "s"} unspent.${pet ? " Pet: " + pet + "." : ""}` + ); +} + // !give @name amount: one line per outcome of ChannelRoom.give (server/channel.ts). /** ChannelRoom.give's answer; the fields beside `reason` depend on it (server/channel.ts). */ export type GiveReply = { diff --git a/server/upgrades.ts b/server/upgrades.ts index 72480d7..ea4cc52 100644 --- a/server/upgrades.ts +++ b/server/upgrades.ts @@ -7,11 +7,12 @@ // 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) +// vitality: +4 max HP per level in a duel (both kinds), up to +60; blows stay sized on the channel's base max HP // 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", "crit", "dodge", "counter"] as const; // const: each stat a literal, so Stat names them +export const STATS = ["power", "guard", "luck", "crit", "dodge", "counter", "vitality"] 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; @@ -22,7 +23,7 @@ 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. */ +/** What one of the first 8 levels adds to each stat's effect, as a fraction (vitality: in HP). */ export const STAT_STEPS: Record = { power: STAT_STEP, guard: STAT_STEP, @@ -30,8 +31,9 @@ export const STAT_STEPS: Record = { crit: STAT_STEP, dodge: STAT_STEP, counter: 0.02, + vitality: 4, }; -/** The most each stat's effect can reach, as a fraction (0.6 is 60%). */ +/** The most each stat's effect can reach, as a fraction (0.6 is 60%; vitality: in HP). */ export const STAT_CEILING: Record = { power: 0.6, guard: 0.6, @@ -39,6 +41,7 @@ export const STAT_CEILING: Record = { crit: 0.5, dodge: 0.5, counter: 0.6, + vitality: 60, }; /** Counter's second effect: the chance a foe's miss becomes your counter. */ const COUNTER_CHANCE = { step: 0.02, ceiling: 0.5 }; @@ -83,13 +86,15 @@ function effectAt(step: number, ceiling: number, level: number): number { 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). +// What a stat's levels do, as a fraction (for counter, the extra counter damage; for vitality, HP). 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); +// Whole extra max HP from vitality levels. +export const bonusHp = (level: number): number => Math.round(statValue("vitality", level)); export function emptyStats(): Stats { - return { power: 0, guard: 0, luck: 0, crit: 0, dodge: 0, counter: 0 }; + return { power: 0, guard: 0, luck: 0, crit: 0, dodge: 0, counter: 0, vitality: 0 }; } // Any input -> whole numbers within 0..LEVEL_LIMIT for each stat. @@ -104,7 +109,7 @@ export function cleanStats(input?: unknown): Stats { } // 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 +// STATS first (vitality, 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. diff --git a/src/admin.ts b/src/admin.ts index 1e338a5..08d2775 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -867,7 +867,7 @@ function drawDuels() { tbody.replaceChildren( ...open.map((d) => { // S.admin!: as above - const max = d.rules?.maxHp || S.admin!.config.maxHp; + const max = (id: string) => d.maxHp?.[id] || d.rules?.maxHp || S.admin!.config.maxHp; const left = d.status === "pending" ? Math.max(0, d.expiresAt - now) @@ -893,8 +893,8 @@ function drawDuels() { ? h( "div", {}, - h("div", {}, hp(d.hp?.[d.a] ?? max, max, nameOf(d.a))), - h("div", {}, hp(d.hp?.[d.b] ?? max, max, nameOf(d.b))), + h("div", {}, hp(d.hp?.[d.a] ?? max(d.a), max(d.a), nameOf(d.a))), + h("div", {}, hp(d.hp?.[d.b] ?? max(d.b), max(d.b), nameOf(d.b))), ) : "—", ), @@ -941,8 +941,9 @@ function drawPlayers() { // element type from the page HTML const tbody = $("#players tbody") as HTMLTableSectionElement, now = Date.now(), - // S.admin!: the table renders only after load() filled it - max = S.admin!.config.maxHp; + // S.admin!: the table renders only after load() filled it. A fighter in a duel has its vitality max HP there. + max = (userId: string) => + S.admin!.duels.find((d) => OPEN.has(d.status) && d.maxHp?.[userId])?.maxHp?.[userId] || S.admin!.config.maxHp; // as above if (!S.admin!.players.length) return tbody.replaceChildren( @@ -961,7 +962,11 @@ function drawPlayers() { p.displayName || p.username, p.registered ? null : h("span", { class: "muted" }, " (no profile)"), ), - h("td", {}, p.respawnAt > now ? h("span", { class: "badge negative" }, "Knocked out") : hp(p.hp, max)), + h( + "td", + {}, + p.respawnAt > now ? h("span", { class: "badge negative" }, "Knocked out") : hp(p.hp, max(p.userId)), + ), h("td", { class: "num" }, p.elo), h("td", { class: "num" }, p.wins), h("td", { class: "num" }, p.losses), @@ -1423,9 +1428,10 @@ const TIER_LABELS: Record = { epic: "Epic", legendary: "Legendary", }; -const boostLabel = (b: Record | undefined) => - b?.power && b.power === b.guard && b.power === b.luck - ? "+" + b.power + " power, guard and luck" +// A custom pet's boost. A legendary rolls its stats for each owner, so the table says so instead of a number. +const boostLabel = (tier: string, b: Record | undefined) => + tier === "legendary" + ? "+1 to 4 random stats" : ["power", "guard", "luck"] .filter((k) => b?.[k]) // b!: the filter kept only stats b has @@ -1643,7 +1649,7 @@ async function loadPets() { {}, h("td", {}, h("img", { class: "pet-thumb", src: p.url, alt: "", width: 32, height: 32 }), " " + p.label), h("td", {}, TIER_LABELS[p.tier] || p.tier), - h("td", {}, boostLabel(p.boost)), + h("td", {}, boostLabel(p.tier, p.boost)), h("td", { class: "num" }, p.price), h( "td", @@ -1932,6 +1938,7 @@ const SE_LABELS: Record = { help: "How to play", look: "Link to change your look", pet: "Own pet, or @viewer's", + stats: "Own stats, or @viewer's", checkin: "Daily check-in", wallet: "Wallet", give: "Give dollars", diff --git a/src/dashboard.ts b/src/dashboard.ts index 320d6f8..e048f56 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -24,6 +24,7 @@ import { } from "./ui.ts"; import { upgradeRules, + bonusHp, counterChance, effectiveStats, emptyStats, @@ -143,6 +144,8 @@ const emptyOwned = () => hats: [], ...Object.fromEntries(Object.keys(KINDS).map((k) => [k, []])), slots: 1, + rolls: {}, + rerolled: [], }) as unknown as Owned; // the spread's keys are the KINDS keys (see above) const state: State = { session: null, @@ -253,9 +256,11 @@ const STAT_TEXT = { Math.round(counterChance(n) * 1000) / 10 + "% of foe misses countered", }, + vitality: { label: "Vitality", effect: (n: number) => "+" + bonusHp(n) + " max HP in duels" }, }; const REASONS: Record = { - in_duel: "finish your open challenge or duel before moving upgrade points.", + in_duel: "finish your open challenge or duel first.", + rerolled: "you already used this pet's free reroll.", invalid_upgrades: "those upgrades need more wins.", hat_locked: "that hat needs more wins.", pet_locked: "buy that pet first.", @@ -305,12 +310,28 @@ const edited = () => const petById = (id: string) => state.shop?.pets.find((x) => x.id === id) || null; const entryOf = (id: string | undefined) => state.catalog.find((x) => x.id === id) || null; 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 + " power, guard and luck" - : STATS.filter((k) => b?.[k]) - .map((k) => "+" + b![k] + " " + k) // b!: filter() kept only stats b has - .join(", "); +// "+2 power, +1 guard", or "+1 guard, crit, dodge and vitality" when three or more stats share one number (as +// server/pets.ts boostText). +function boostText(b: Partial | undefined) { + const keys = STATS.filter((k) => b?.[k]); + const first = keys[0]; + if (first && keys.length >= 3 && keys.every((k) => b![k] === b![first])) + // b!: filter() kept only stats b has + return "+" + b![first] + " " + keys.slice(0, -1).join(", ") + " and " + keys.at(-1); // b!: as above + return keys.map((k) => "+" + b![k] + " " + k).join(", "); // b!: as above +} +// A pet's boost for this fighter: an owned legendary gives +1 to each stat it rolled, other pets their catalog boost. +function petBoost(pet: ShopPet | null): Partial | undefined { + if (pet?.tier !== "legendary") return pet?.boost; + const roll = state.owned.rolls?.[pet.id] || []; + return Object.fromEntries(STATS.filter((k) => roll.includes(k)).map((k) => [k, 1])); +} +const petTag = (pet: ShopPet) => + TIER_NAMES[pet.tier] + + ", " + + (pet.tier === "legendary" && !state.owned.rolls?.[pet.id]?.length + ? "+1 to 4 random stats" + : boostText(petBoost(pet))); const TIER_NAMES: Record = { common: "Common", uncommon: "Uncommon", @@ -457,6 +478,46 @@ function showShopPart(id: string | null) { showShopPart(null); // ---------- shop ---------- +// A legendary pet's one free reroll: confirm, then the server rolls 4 different stats. +async function reroll(pet: ShopPet, btn: HTMLButtonElement, out: Element) { + if (btn.dataset.confirm !== "1") { + btn.dataset.confirm = "1"; + btn.textContent = "Confirm: reroll stats"; + setTimeout(() => { + if (btn.isConnected && btn.dataset.confirm === "1") { + btn.dataset.confirm = ""; + btn.textContent = "Reroll stats (free, once)"; + } + }, 6000); + return; + } + btn.disabled = true; + setStatus(out, "Rerolling…"); + const r = parseAnswer( + await api("/api/shop/" + CHANNEL, { method: "POST", body: { kind: "reroll", id: pet.id } }), + parseBought, + "a reroll", + ); + if (!r.ok) { + btn.disabled = false; + btn.dataset.confirm = ""; + btn.textContent = "Reroll stats (free, once)"; + if (r.status === 401) { + state.session = { ...state.session, user: null }; + renderSignedIn(); + } + setStatus( + out, + "Not rerolled: " + + (r.status === 401 ? "your session expired. Sign in again." : REASONS[r.data?.error] || errorText(r)), + "error", + ); + return; + } + state.owned = { ...emptyOwned(), ...(r.data.owned || state.owned) }; + renderAll(); + setStatus(out, "Rerolled " + pet.label + ": " + boostText(petBoost(pet)) + ".", "ok"); +} // The first click on a Buy button asks to confirm, the second spends the dollars. A bought item is worn right away; // Save brings it on stream. A bought build slot opens as a copy of the build being edited. async function buy( @@ -798,7 +859,7 @@ function renderPets() { : h("span", { class: "thumb empty-slot", "aria-hidden": "true" }), price: pet?.price, out, - tag: pet ? TIER_NAMES[pet.tier] + ", " + boostText(pet.boost) : null, + tag: pet ? petTag(pet) : null, tier: pet?.tier, onPick: (id) => { state.d.pet = id; @@ -807,7 +868,26 @@ function renderPets() { }, }); }; - box.replaceChildren(option(null), ...state.shop.pets.map(option)); + // An owned legendary keeps its free reroll button until it's used. + const withReroll = (pet: ShopPet) => { + const el = option(pet); + if (pet.tier === "legendary" && canShop() && owns("pet", pet.id) && !state.owned.rerolled?.includes(pet.id)) + el.append( + h( + "button", + { + type: "button", + class: "btn btn-small reroll", + "aria-label": "Reroll " + pet.label + "'s stats, free, once", + // as: the handler sits on this button + onclick: (e: MouseEvent) => reroll(pet, e.currentTarget as HTMLButtonElement, out), + }, + "Reroll stats (free, once)", + ), + ); + return el; + }; + box.replaceChildren(option(null), ...state.shop.pets.map(withReroll)); checkPicked(box, "pet", state.d.pet); ($("#pet-note") as HTMLSpanElement).textContent = canShop() // element type from the page HTML ? "(you own " + state.owned.pets.length + ", you have " + money(state.profile!.dollars) + ")" // profile!: canShop() needs one @@ -899,7 +979,7 @@ function renderUpgrades() { const t = STAT_TEXT[k], n = shown[k], counted = Math.min(n, r.maxLevel), - extra = petById(state.d.pet)?.boost?.[k] || 0, + extra = petBoost(petById(state.d.pet))?.[k] || 0, total = counted + extra, cost = levelCost(n + 1), atCap = n >= r.maxLevel; diff --git a/src/parse.ts b/src/parse.ts index 8b40c5f..0438e7d 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -122,11 +122,27 @@ export const parseAccess = shape( export type Build = { avatar?: string; color?: string; defaultAbility?: string; [field: string]: unknown }; const BUILD: Spec = { avatar: opt(str), color: opt(str), defaultAbility: opt(str) }; const isBuild = fits(BUILD); -/** What a fighter owns: pets, hats, each cosmetic kind's items and the number of build slots. */ -export type Owned = { pets: string[]; hats: string[]; slots: number } & Record; -// slots a number, every other list a list of ids +/** + * What a fighter owns: pets, hats, each cosmetic kind's items and the number of build slots, plus each legendary pet's + * rolled stats and the legendaries whose free reroll is used. + */ +export type Owned = { + pets: string[]; + hats: string[]; + slots: number; + rolls: Record; + rerolled: string[]; +} & Record; +// slots a number, rolls an object of stat lists, every other list a list of ids const isOwned: Check = (v) => - isObject(v) && Object.entries(v).every(([kind, list]) => (kind === "slots" ? num(list) : strList(list))); + isObject(v) && + Object.entries(v).every(([kind, list]) => + kind === "slots" + ? num(list) + : kind === "rolls" + ? isObject(list) && Object.values(list).every(strList) + : strList(list), + ); export type Profile = Build & { wins: number; losses: number; @@ -337,6 +353,7 @@ export type AdminDuel = { b: string; status: string; rules?: { maxHp?: number }; + maxHp?: Record; expiresAt: number; startedAt?: number; lastActionAt?: number; @@ -350,6 +367,7 @@ const parseAdminDuel = shape( b: str, status: str, rules: opt(fits({ maxHp: opt(num) })), + maxHp: opt(numMap), expiresAt: num, startedAt: opt(num), lastActionAt: opt(num), diff --git a/src/served/overlay.ts b/src/served/overlay.ts index f785c73..2151135 100644 --- a/src/served/overlay.ts +++ b/src/served/overlay.ts @@ -1177,6 +1177,7 @@ async function start() { b: String(event.b), status: "active", hp: { ...event.hp }, + ...(event.maxHp ? { maxHp: { ...event.maxHp } } : {}), rules: { maxHp: Number(arenaConfig?.maxHp) || 100 }, }); const fa = findPlayer(String(event.a)), @@ -1327,7 +1328,7 @@ async function start() { if (!duel || duel.status !== "active") return null; const current = Number(duel.hp?.[p.userId]); if (!Number.isFinite(current)) return null; - const max = Number(duel.rules?.maxHp ?? arenaConfig?.maxHp ?? 100); + const max = Number(duel.maxHp?.[p.userId] ?? duel.rules?.maxHp ?? arenaConfig?.maxHp ?? 100); return { current: Math.max(0, current), max: Math.max(1, Number.isFinite(max) ? max : 100) }; } function onMessage(message: { [K in keyof ChatMessage]?: ChatMessage[K] | undefined }) { diff --git a/src/served/parse.ts b/src/served/parse.ts index e441ad1..750a60f 100644 --- a/src/served/parse.ts +++ b/src/served/parse.ts @@ -140,6 +140,8 @@ export type ArenaDuel = { b: string; status: string; hp: Record; + /** userId -> full health with vitality; older duels have none and use rules.maxHp. */ + maxHp?: Record | undefined; rules?: { maxHp?: number | undefined; [field: string]: unknown } | undefined; ratings?: Record | null | undefined; /** Rolls replayed so far (replays only). */ @@ -157,6 +159,7 @@ const isDuel = (v: unknown): v is ArenaDuel => typeof v.b === "string" && typeof v.status === "string" && isNumberMap(v.hp) && + (v.maxHp === undefined || isNumberMap(v.maxHp)) && (v.rules === undefined || (isObject(v.rules) && optNumber(v.rules.maxHp))) && isRatings(v.ratings) && optNumber(v.round) && @@ -176,6 +179,7 @@ export type ArenaEvent = { declinedBy?: string | undefined; round?: number | undefined; hp?: Record | undefined; + maxHp?: Record | undefined; userId?: string | undefined; targetId?: string | undefined; die?: number | undefined; @@ -223,6 +227,7 @@ const isEvent = (v: unknown): v is ArenaEvent => !badField(v, EVENT_NUMBERS, optNumber) && !badField(v, EVENT_BOOLEANS, optBoolean) && (v.hp === undefined || isNumberMap(v.hp)) && + (v.maxHp === undefined || isNumberMap(v.maxHp)) && isRatings(v.ratings); export function parseArenaEvent(value: unknown): Parsed { if (isEvent(value)) return ok(value); diff --git a/tests/channel.test.ts b/tests/channel.test.ts index d4e575b..b2ab8ad 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -8,7 +8,7 @@ import { logRoomError } from "../server/developer.ts"; 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 { tierBoost, petOf, PETS, boostText, cleanBoost, parseRoll, rollLegendary } from "../server/pets.ts"; import { asEnv, fakeCtx, fullStats, must } from "./fakes.ts"; import type { FakeSocketLike, FakeState, Loose } from "./fakes.ts"; import type { Env } from "../server/env.ts"; @@ -1735,7 +1735,11 @@ test("pets: tier boosts, added after the upgrade cap", () => { "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"), fullStats({ power: 1, guard: 1, luck: 1 })); + assert.deepEqual((tierBoost as TierBoostOneArg)("legendary"), fullStats({}), "a legendary boosts what it rolled"); + assert.deepEqual( + tierBoost("legendary", undefined, undefined, ["crit", "dodge", "luck", "vitality"]), + fullStats({ luck: 1, crit: 1, dodge: 1, vitality: 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"); @@ -2120,9 +2124,12 @@ 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 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 power, guard and luck"); +test('pet boost text: a rolled legendary reads "+1 luck, crit, dodge and vitality", like the viewer page', () => { + assert.equal( + boostText(tierBoost("legendary", undefined, undefined, ["vitality", "crit", "dodge", "luck"])), + "+1 luck, crit, dodge and vitality", + ); + assert.equal(boostText(fullStats({ power: 1, guard: 1, luck: 1 })), "+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"); }); @@ -2689,7 +2696,7 @@ test("debug bot: !fray e2e plays every command once against the asker and posts await r.call("/dev-live", { method: "POST", body: { live: false } }); assert.match((await r.say("u1", "alice", "!fray e2e")).reply, /only the broadcaster or a mod/); const run = await r.say("u1", "alice", "!fray e2e", { mod: true }); - assert.match(run.replies[0], /^PixFray e2e, pixbot vs alice: 17 of 17 steps passed\.$/); + assert.match(run.replies[0], /^PixFray e2e, pixbot vs alice: 18 of 18 steps passed\.$/); const detail = run.replies.slice(1).join(" · "); assert.doesNotMatch(detail, /FAIL/); assert.match(detail, /checkin ok \(not_live\)/); @@ -2942,3 +2949,155 @@ test('rooms answer the overlay "ping" with "pong" through the WebSocket auto-res else delete workerGlobals.WebSocketRequestResponsePair; } }); + +test("legendary pets: 4 stats rolled at purchase, one free reroll to a new set, refused mid-duel", async () => { + const r = room({}, { quick: true }); + const se = (await r.call("/admin")).body.streamelements; + await r.call("/chat", { + method: "POST", + body: { action: "connected", subscriptionId: "se-streamelements", status: "enabled", createdAt: Date.now() }, + }); + await r.save("u1", "alice"); + await r.save("u2", "bob"); + await r.call("/admin", { + method: "POST", + body: { actorId: "mod1", action: "giftDollars", payload: { username: "alice", amount: 2000 } }, + }); + const shop = (userId: string, kind: string, id: string) => + r.call("/shop", { method: "POST", userId, body: { userId, kind, id } }); + const bought = (await shop("u1", "pet", "dragon")).body; + const roll: string[] = bought.owned.rolls.dragon; + assert.equal(roll.length, 4); + assert.equal(new Set(roll).size, 4, "4 different stats"); + assert.deepEqual( + roll, + ["power", "guard", "luck", "crit", "dodge", "counter", "vitality"].filter((s) => roll.includes(s)), + "in STATS order", + ); + assert.deepEqual(bought.owned.rerolled, []); + const saved = (await r.save("u1", "alice", { pet: "dragon" })).body.profile; + assert.deepEqual(saved.petBoost, fullStats(Object.fromEntries(roll.map((s) => [s, 1])))); + // Not a legendary, or not owned: nothing to reroll. + assert.deepEqual((({ status, body }) => [status, body.error])(await shop("u2", "reroll", "dragon")), [ + 400, + "unknown_item", + ]); + await shop("u1", "pet", "fox"); + assert.equal((await shop("u1", "reroll", "fox")).body.error, "unknown_item"); + // An open challenge with the dragon out blocks the reroll. + const challenge = await r.call("/se", { + method: "POST", + body: { key: se.secret, action: "challenge", userId: "u1", username: "alice", target: "bob", messageId: "c1" }, + }); + assert.equal(challenge.status, 200); + assert.deepEqual((({ status, body }) => [status, body.error])(await shop("u1", "reroll", "dragon")), [ + 409, + "in_duel", + ]); + await r.call("/admin", { + method: "POST", + body: { actorId: "mod1", action: "cancelDuel", payload: { duelId: must(r.readState("nesszerra").duels[0]).id } }, + }); + const again = (await shop("u1", "reroll", "dragon")).body; + assert.equal(again.ok, true, again.error); + assert.notDeepEqual(again.owned.rolls.dragon, roll, "a reroll never gives the same set"); + assert.deepEqual(again.owned.rerolled, ["dragon"]); + const player = must(r.readState("nesszerra").players.find((p) => p.userId === "u1")); + assert.deepEqual( + player.petBoost, + fullStats(Object.fromEntries(again.owned.rolls.dragon.map((s: string) => [s, 1]))), + "the fighter on stage takes the new roll", + ); + assert.deepEqual((({ status, body }) => [status, body.error])(await shop("u1", "reroll", "dragon")), [ + 409, + "rerolled", + ]); +}); + +test("legendary pets bought before rolls get one when the room starts", async () => { + const r = room(); + await r.save("u1", "alice"); + const sql = r.ctx.storage.sql; + sql.exec( + "INSERT INTO owned_items (user_id, kind, item_id, price, bought_at) VALUES ('u1', 'pet', 'phoenix', 900, 1), ('u1', 'pet', 'fox', 60, 2)", + ); + // Env: a partial Env on purpose + new ChannelRoom(r.ctx, { INTERNAL_SECRET: SECRET } as Env); + const rows = Object.fromEntries( + sql + .exec<{ item_id: string; roll: string }>("SELECT item_id, roll FROM owned_items") + .toArray() + .map((x) => [x.item_id, x.roll]), + ); + assert.equal(rows.fox, "", "only legendaries roll"); + assert.equal(String(rows.phoenix).split(",").length, 4); +}); + +test("!stats: own levels that count, unspent points and the pet; @name for someone else", async () => { + const r = room({}, { quick: true }); + const se = (await r.call("/admin")).body.streamelements; + assert.equal(se.names.stats, "!stats"); + await r.save("u1", "alice"); + await r.save("u2", "bob"); + // Ten wins, in the database and in the live game state (duels keep the two in step). + r.ctx.storage.sql.exec("UPDATE profiles SET wins = 10 WHERE user_id = 'u1'"); + const live = r.readState("nesszerra"); + must(live.players.find((p) => p.userId === "u1")).wins = 10; + r.writeState(live); + const saved = await r.save("u1", "alice", { stats: { power: 2, vitality: 3 } }); + assert.equal(saved.status, 200, saved.body.error); + assert.equal( + r.ctx.storage.sql.exec<{ vitality: number }>("SELECT vitality FROM profiles WHERE user_id = 'u1'").toArray()[0] + ?.vitality, + 3, + ); + let m = 0; + const stats = async (id: string, login: string, target = "") => + ( + await r.call("/se?origin=https%3A%2F%2Ftest.example", { + method: "POST", + body: { + key: se.secret, + action: "stats", + userId: id, + username: login, + displayName: login, + target, + messageId: "s" + ++m, + }, + }) + ).body.reply; + assert.equal(await stats("u1", "alice"), "@alice: Power 2, Vitality 3. 5 points unspent."); + assert.equal(await stats("u1", "alice", "bob"), "@bob: no upgrades yet. 0 points unspent."); + assert.match(await stats("u1", "alice", "nobody"), /nobody/); +}); + +test("legendary rolls: 4 different stats in STATS order, never the set it replaces; stored text parses back", () => { + let i = 0; + const seq = (values: number[]) => () => must(values[i++ % values.length]); + // always the first stat left in the pool + assert.deepEqual(rollLegendary(seq([0])), ["power", "guard", "luck", "crit"]); + i = 0; + // the same draw again is refused; the next draw (last stat each time) wins + assert.deepEqual(rollLegendary(seq([0, 0, 0, 0, 0.99, 0.99, 0.99, 0.99]), ["power", "guard", "luck", "crit"]), [ + "crit", + "dodge", + "counter", + "vitality", + ]); + for (let n = 0; n < 50; n++) { + const roll = rollLegendary(); + assert.equal(new Set(roll).size, 4); + assert.deepEqual(parseRoll(roll.join(",")), roll); + } + assert.deepEqual(parseRoll("vitality,crit,dodge,luck"), ["luck", "crit", "dodge", "vitality"]); + assert.deepEqual(parseRoll("crit,dodge,luck"), [], "3 stats is no roll"); + assert.deepEqual(parseRoll("crit,dodge,luck,speed"), [], "an unknown stat is no roll"); + assert.deepEqual(parseRoll(""), []); + assert.deepEqual( + petOf("dragon", "", "crit,dodge,luck,vitality")?.boost, + fullStats({ luck: 1, crit: 1, dodge: 1, vitality: 1 }), + ); + assert.deepEqual(petOf("dragon")?.boost, fullStats({}), "an unrolled legendary boosts nothing"); + assert.deepEqual(cleanBoost({ vitality: 1, counter: 5, crit: "x" }), fullStats({ vitality: 1, counter: 2 })); +}); diff --git a/tests/dev-api.test.ts b/tests/dev-api.test.ts index 11c5036..afd2d4a 100644 --- a/tests/dev-api.test.ts +++ b/tests/dev-api.test.ts @@ -360,7 +360,7 @@ test("channels lists the registry without reading any room, and progress answers ); assert.equal(p.overlays, 0); assert.equal(p.source, ""); - assert.equal(p.commands, 12); + assert.equal(p.commands, 13); assert.equal(p.commandsWorking, 0); }); @@ -404,7 +404,7 @@ test("progress covers more than 40 channels across batched calls, 40 room reads Object.assign(seen, r.body.progress); } assert.deepEqual(Object.keys(seen).sort(), [...on].sort()); - assert.equal(seen.streamer_050.commands, 12); + assert.equal(seen.streamer_050.commands, 13); }); test("progress validates logins: required, at most 40, only channels that are on", async () => { diff --git a/tests/game.test.ts b/tests/game.test.ts index 7d0f073..26c5f3a 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -1313,13 +1313,15 @@ test("upgrade presets spread the points by weight within the channel's level cap "lucky-striker", "iron-wall", "glass-cannon", + "tank", "counter-puncher", "duelist", "gambler", "ghost", + "bruiser", "sharpshooter", ], - "tank and bruiser wait for vitality", + "every preset is ready once vitality exists", ); 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 })); @@ -1568,3 +1570,49 @@ test("duels with an account younger than 7 days are unrated", () => { const hidden = hiddenResults(w.state, w.now); assert.deepEqual(hidden.get("id-alice"), { elo: 1000, wins: 0, losses: 0, dollars: 0 }, "nothing hidden to undo"); }); + +test("vitality: +4 max HP per level, set per fighter when the duel starts and sent with duel_started", () => { + const w = upgraded({ alice: { vitality: 5 } }); + w.state.config.quickDuel = false; + const id = w.fight("alice", "bob"); + assert.deepEqual(w.duel(id).maxHp, { "id-alice": 120, "id-bob": 100 }); + assert.deepEqual(w.duel(id).hp, { "id-alice": 120, "id-bob": 100 }); + assert.equal(w.player("alice").hp, 120); + const started = must(w.state.events.findLast((e) => e.type === "duel_started")); + assert.deepEqual(started.maxHp, { "id-alice": 120, "id-bob": 100 }); + assert.deepEqual(started.hp, { "id-alice": 120, "id-bob": 100 }); +}); + +test("vitality: heal stops at the fighter's own max HP, and a mod's health reset refills to it", () => { + const w = upgraded({ alice: { vitality: 5 } }); + w.state.config.quickDuel = false; + const id = w.fight("alice", "bob"); + assert.equal(w.say("bob", "!strike").amount, 20); + assert.equal(w.duel(id).hp["id-alice"], 100); + assert.equal(w.say("alice", "!heal").amount, 15, "a heal above the channel's 100 still counts"); + assert.equal(w.duel(id).hp["id-alice"], 115); + w.now += 20_000; + assert.equal(w.say("alice", "!heal").amount, 5, "capped at 120"); + assert.equal(w.duel(id).hp["id-alice"], 120); + w.now += 1000; + w.say("bob", "!strike"); + assert.equal(w.duel(id).hp["id-alice"], 100); + assert.equal(w.apply({ type: "admin", actorId: "mod", action: "resetHealth" }).ok, true); + assert.deepEqual(w.duel(id).hp, { "id-alice": 120, "id-bob": 100 }); + assert.equal(w.player("alice").hp, 120); +}); + +test("vitality: a quick-duel winner at their own full HP is flawless; base blows stay the same size", () => { + const r = quickWith(upgraded({ alice: { vitality: 5 } }), [0.9, 0.5, 0.9]); + assert.deepEqual( + r.swings.map((s) => s.damage), + [50, 0, 50], + ); + assert.equal(r.winnerHp, 120); + assert.equal(r.flawless, true); + // bob with 120 HP survives two crits and loses the flawless to one hit + const tough = quickWith(upgraded({ bob: { vitality: 5 } }), [0.9, 0.75, 0.9, 0.5, 0.9]); + assert.equal(tough.winnerId, "id-alice"); + assert.equal(tough.winnerHp, 66); + assert.equal(tough.flawless, false); +}); diff --git a/tests/parse.test.ts b/tests/parse.test.ts index 52fe044..640c37f 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -109,6 +109,10 @@ test("shop and dev-tool parsers", async () => { assert.deepEqual(p.parseShopBuy({ kind: "hat", id: 5 }), no("invalid_item")); assert.deepEqual(p.parseShopBuy({ id: "crown" }), no("invalid_item")); assert.deepEqual(p.parseShopBuy({ kind: "slot", note: "x" }), yes({ kind: "slot", id: "", price: undefined })); + assert.deepEqual( + p.parseShopBuy({ kind: "reroll", id: "dragon" }), + yes({ kind: "reroll", id: "dragon", price: undefined }), + ); assert.deepEqual(p.parseDevLive({ live: true, streamId: "s1" }), yes({ live: true, streamId: "s1" })); assert.deepEqual(p.parseDevLive({ live: 5 }), no("invalid_body")); diff --git a/tests/ui.ts b/tests/ui.ts index b1dab09..7f9ae18 100644 --- a/tests/ui.ts +++ b/tests/ui.ts @@ -575,13 +575,15 @@ try { "Lucky striker", "Iron wall", "Glass cannon", + "Tank", "Counter-puncher", "Duelist", "Gambler", "Ghost", + "Bruiser", "Sharpshooter", ], - "only presets whose stats exist (Tank and Bruiser wait for vitality)", + "all twelve presets", ); 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(); @@ -601,7 +603,7 @@ try { avatar: "adventurer", color: "#34d399", defaultAbility: "heal", - stats: { power: 2, guard: 0, luck: 1, crit: 0, dodge: 0, counter: 0 }, + stats: { power: 2, guard: 0, luck: 1, crit: 0, dodge: 0, counter: 0, vitality: 0 }, hat: "cap", pet: "mouse", recolor: "",