Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions docs/CONTRACTS.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions docs/DUELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The duel is rolled at once, the moment the challenge is accepted.

## Upgrades, hats and check-ins

- Each win earns an upgrade point and unlocks hats. Points have no cap. Points buy levels in 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 |
|---|---|---|
Expand All @@ -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

Expand Down
8 changes: 4 additions & 4 deletions docs/STREAMER_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
195 changes: 170 additions & 25 deletions server/channel.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions server/developer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 22 additions & 10 deletions server/game.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import site from "../site.config.js";
import {
bonusHp,
cleanStats,
counterChance,
effectiveStats,
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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,
Expand All @@ -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, {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]!;
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions server/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
cooldowns?: Record<string, Cooldowns>;
/** Played out, but moves no Elo, wins, losses or dollars. */
unrated?: "new_account" | "pair_cap";
Expand Down
7 changes: 5 additions & 2 deletions server/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,11 @@ export function parseProfileSave(body: JsonObject): Parsed<ProfileSave> {

// ---------- 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/<channel>: a known kind, an id up to 64 ("" for a slot), and optionally the price the buyer saw
Expand Down
91 changes: 67 additions & 24 deletions server/pets.ts
Original file line number Diff line number Diff line change
@@ -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:"<base64 PNG>" } -> 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";
Expand Down Expand Up @@ -104,14 +105,15 @@ export const PRICE_KEYS: Record<string, keyof PetPriceConfig> = {
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" },
Expand All @@ -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
Expand All @@ -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<Record<Stat, unknown>> | null | undefined)?.[key]);
out[key] = Number.isInteger(v) ? Math.max(0, Math.min(MAX_BOOST, v)) : 0;
Expand All @@ -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<Stat>();
// 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
Expand All @@ -191,14 +212,36 @@ 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)",
);
// What each fighter bought: kind "pet" or "hat". Kept on a rank reset; deleted with the profile.
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.
Expand Down
Loading
Loading