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
9 changes: 9 additions & 0 deletions admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ <h2 id="gift-title">Gift PixFray dollars to a saved fighter</h2>
<div><button class="btn" id="gift-send" type="submit">Gift dollars</button><p class="status" id="gift-status" role="status"></p></div>
</form>
</section>
<section class="section" aria-labelledby="pair-title">
<h2 id="pair-title">Reset ranked duels for one pair</h2>
<p class="muted small">If mods set a limit on ranked duels per pair (balance editor), this gives two fighters their full count again for this stream. Mods can also type <code>!resetpair @name @name</code> in chat.</p>
<form id="pair-form" class="controls" novalidate>
<div><label for="pair-a">First viewer (Twitch name)</label><input id="pair-a" maxlength="26" autocomplete="off" spellcheck="false" required></div>
<div><label for="pair-b">Second viewer (Twitch name)</label><input id="pair-b" maxlength="26" autocomplete="off" spellcheck="false" required></div>
<div><button class="btn" id="pair-send" type="submit">Reset pair count</button><p class="status" id="pair-status" role="status"></p></div>
</form>
</section>
</div>
<div role="tabpanel" id="panel-rules" aria-labelledby="tab-rules" tabindex="0" hidden>
<section class="section" aria-labelledby="config-title">
Expand Down
40 changes: 27 additions & 13 deletions docs/CONTRACTS.md

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions docs/DUELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,33 @@ The duel is rolled at once, the moment the challenge is accepted.

## PixFray dollars

- Ranked duels pay saved fighters: $5 a win, $3 a loss.
- Ranked duels pay saved fighters: $5 a win, $3 a loss. Past a pair limit (if mods set one, below), a
duel pays half ($2 and $1). Other unrated duels pay nothing.
- `!wallet` shows your dollars.
- `!pay @name 10` passes them on (`!give` on channels with the PixFray chat bot). You can pay after
5 finished duels, at most $100 per stream, and only while the stream is live.
- Dollars show only to mods and the owner on the website.

## Ranked and unrated duels

- The same two fighters get 5 ranked duels per rolling 24 hours. After that they still fight,
unrated.
- Duels count only while the stream is live. Offline duels are unrated and pay nothing. If Twitch
can't say whether the stream is live, the last answer stands.
- Live duels are always ranked, with no limit per pair. Mods can set a limit per pair each stream in
the balance editor (0 means no limit). With one, the last ranked result says "Last ranked duel
between them this stream." After that the pair still fights, unrated, for half the dollars, and
they're ranked again next stream.
- With a limit set, `!stats @name` adds how many ranked duels you have left against that fighter this
stream.
- A Twitch account under 7 days old also duels unrated. If Twitch can't say how old an account is,
the duel is ranked.
- An unrated duel moves no Elo, wins, losses or dollars, and the reply starts "Just for fun".
- An unrated duel moves no Elo, wins or losses. The reply starts "Just for fun" and says why.

## What mods and the broadcaster control

- Mods tune every number in the balance editor on the mod controls page. Every change is versioned
and can be undone.
- Mods reset one pair's ranked duels for this stream with `!resetpair @name @name` in chat or
"Reset pair count" on the mod page.
- Only the broadcaster (or the site owner) can reset all ranks, make a new StreamElements key, turn
PixFray off or move chat from another site.
- Turning PixFray off keeps every fighter and rank.
Expand Down
120 changes: 113 additions & 7 deletions server/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
hiddenResults,
NEW_ACCOUNT_MS,
normalizeGameState,
pairRankedLeft,
parseGameCommand,
reduceGame,
shownProfile,
Expand Down Expand Up @@ -958,6 +959,13 @@ export class ChannelRoom extends DurableObject<Env> {
gift.ok ? 200 : gift.reason === "profile_not_found" ? 404 : 400,
);
}
if (action === "resetPair") {
const out = this.resetPair(channel, payload.a, payload.b, actorId, actorName);
return json(
out.ok ? out : { ...out, error: out.reason },
out.ok ? 200 : out.reason === "profile_not_found" ? 404 : 400,
);
}
if (action === "saveCommand" || action === "deleteCommand" || action === "setCounter") {
const out = this.editBotCommands(channel, action, payload, actorName || actorId);
return json(
Expand Down Expand Up @@ -1110,7 +1118,7 @@ export class ChannelRoom extends DurableObject<Env> {
},
};
await this.checkAccountAge(userId);
const result = this.processChatMessage(channel, msg, now);
const result = this.processChatMessage(channel, msg, now, await this.streamNews(channel, line, now));
if (result.visible) this.broadcast(result.state);
if (result.changed) await this.scheduleAlarm(result.state);
const r = result.result || ({} as GameResult); // processChatMessage always has a result; {} only guards against none
Expand Down Expand Up @@ -1820,7 +1828,12 @@ export class ChannelRoom extends DurableObject<Env> {
} else if (kind === "notification" && msg.bot === true) return this.botCommand(channel, msg, origin);
else if (kind === "notification") {
await this.checkAccountAge(msg.event?.chatter_user_id);
result = this.processChatMessage(channel, msg, now);
result = this.processChatMessage(
channel,
msg,
now,
await this.streamNews(channel, msg.event?.message?.text, now),
);
} else return json({ ok: true, ignored: true });
if (result.visible) this.broadcast(result.state);
if (result.changed) await this.scheduleAlarm(result.state);
Expand Down Expand Up @@ -1931,7 +1944,9 @@ export class ChannelRoom extends DurableObject<Env> {
!Object.values(settings.names).some((n) => String(n).toLowerCase() === first)
)
action = BOT_ALIASES[first];
if (!action && !custom) return json({ ok: true, reason: "not_command", reply: "" });
// !resetpair @a @b (broadcaster or a mod) has a fixed name, like !fray debug.
const resetPair = first === "!resetpair" && !action && !custom;
if (!action && !custom && !resetPair) return json({ ok: true, reason: "not_command", reply: "" });
const subscriptionId = String(msg.subscription?.id || "");
const state = this.readState(channel);
if (!subscriptionId || subscriptionId !== state.chat.subscriptionId) {
Expand Down Expand Up @@ -1990,6 +2005,23 @@ export class ChannelRoom extends DurableObject<Env> {
if (!line) this.noteBotHeld(channel, "cooldown", { command: custom.name }, false);
return this.sendBotLines([line], channel);
}
if (resetPair) {
const actor = String(ev.chatter_user_login || "").slice(0, 48);
if (!boss) return this.sendBotLines(["Only the broadcaster or a mod can use !resetpair."], channel);
if (!words[1] || !words[2])
return this.sendBotLines(["Type !resetpair @name @name to reset a pair's ranked duels."], channel);
const out = this.resetPair(channel, words[1], words[2], chatterId, actor);
return this.sendBotLines(
[
out.ok
? `Ranked duels reset for ${out.a} and ${out.b}: ${typeof out.left === "number" ? out.left + " left this stream" : "no limit on this channel"}.`
: out.reason === "profile_not_found"
? `@${normalizeUsername(String(out.missing || "").replace(/^@/, ""))} has no saved fighter on this channel.`
: "Pick two different fighters: !resetpair @name @name",
],
channel,
);
}
// "!fray debug" (broadcaster, mods or the bot account): the bot's health in one line, as the admin page shows it.
if (action === "help" && String(words[1] || "").toLowerCase() === "debug") {
const userId = validUserId(ev.chatter_user_id),
Expand Down Expand Up @@ -2320,7 +2352,7 @@ export class ChannelRoom extends DurableObject<Env> {
if (action === "stats") {
// like !pet, it works while duels are paused
const self = !target || target === username,
info = this.statsInfo(channel, self ? { userId } : { username: target });
info = this.statsInfo(channel, self ? { userId } : { username: target, asker: userId });
return done(
// the sender's name as sent; short() reads it through String()
seStatsText(info, {
Expand Down Expand Up @@ -2413,7 +2445,12 @@ export class ChannelRoom extends DurableObject<Env> {
},
};
await this.checkAccountAge(userId);
const result = this.processChatMessage(channel, msg, Date.now());
const result = this.processChatMessage(
channel,
msg,
Date.now(),
await this.streamNews(channel, msg.event?.message?.text, now),
);
if (result.visible) this.broadcast(result.state);
if (result.changed) await this.scheduleAlarm(result.state);
const r = result.result || ({} as GameResult); // processChatMessage always has a result; {} only guards against none
Expand Down Expand Up @@ -2814,7 +2851,10 @@ export class ChannelRoom extends DurableObject<Env> {
// !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 }) {
statsInfo(
channel: string,
{ userId = "", username = "", asker = "" }: { userId?: string; username?: string; asker?: string },
) {
const state = this.readState(channel);
const found = userId ? this.getProfile(userId, state.config) : this.getProfileByUsername(username, state.config);
if (!found) return null;
Expand All @@ -2825,6 +2865,7 @@ export class ChannelRoom extends DurableObject<Env> {
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,
rankedLeft: asker && asker !== profile.userId ? pairRankedLeft(state, asker, profile.userId) : null,
};
}

Expand Down Expand Up @@ -2879,6 +2920,40 @@ export class ChannelRoom extends DurableObject<Env> {
};
}

// Mods start one pair's ranked count for this stream over (game.ts resetPair), by Twitch login, from the mod page or
// !resetpair @a @b in chat.
resetPair(channel: string, a: unknown, b: unknown, actorId: string, actorName: string) {
const config = this.readState(channel).config,
find = (name: unknown) =>
this.getProfileByUsername(normalizeUsername(String(name || "").replace(/^@/, "")), config);
const one = find(a),
two = find(b);
if (!one || !two)
return { ok: false, reason: "profile_not_found", missing: one ? String(b || "") : String(a || "") };
const out = this.advance(
channel,
{ type: "admin", actorId, actorName, action: "resetPair", payload: { a: one.userId, b: two.userId } },
Date.now(),
);
if (!out.result.ok) return { ok: false, reason: out.result.reason || "invalid_pair" };
logRoomEvent(this, "command", `${actorName || actorId} reset pair ${one.username} ${two.username}`, {
channel,
action: "resetPair",
actorId,
actorName,
a: one.username,
b: two.username,
amount: out.result.amount,
});
return {
ok: true,
reason: "pair_reset",
a: one.displayName || one.username,
b: two.displayName || two.username,
left: pairRankedLeft(out.state, one.userId, two.userId),
};
}

// Dollars for finished duels: game.ts finishDuel sets duel.payout, and this adds it to the saved profiles once
// (duel.paid). Called inside the transaction that wrote the result; returns true when the state needs writing.
payDuels(state: GameState): boolean {
Expand Down Expand Up @@ -3107,6 +3182,30 @@ export class ChannelRoom extends DurableObject<Env> {
return stream;
}

// The live check for a chat line that can start a duel (game.ts noteStream): {id, startedAt}, null offline, or
// undefined (another command, no Twitch app, or Twitch didn't answer) so the room keeps its last answer.
async streamNews(
channel: string,
text: unknown,
now: number,
): Promise<{ id: string; startedAt: number } | null | undefined> {
const action = parseGameCommand(text)?.action;
if (action !== "duel" && action !== "accept" && action !== "rematch") return undefined;
const dev =
Boolean(this.env?.DEV_TOOLS_TOKEN) &&
this.ctx.storage.sql.exec("SELECT 1 FROM dev_live WHERE id = 1").toArray().length > 0;
if (!dev && !this.env?.TWITCH_CLIENT_ID) return undefined;
try {
return await this.currentStream(channel, now);
} catch (error) {
// anything can be thrown
logRoomEvent(this, "warn", `live check failed: ${String((error as Thrown)?.message || error).slice(0, 80)}`, {
channel,
});
return undefined;
}
}

// A saved fighter's Twitch account age, asked once (again after ACCOUNT_RETRY_MS while Twitch doesn't answer).
async checkAccountAge(userId: unknown): Promise<void> {
const id = validUserId(userId),
Expand Down Expand Up @@ -3180,7 +3279,12 @@ export class ChannelRoom extends DurableObject<Env> {

// One channel.chat.message: a presence update, plus a game command when the text parses as one.
// No chat replies; rejected commands are logged to the room event list.
processChatMessage(channel: string, msg: BotMessage, now: number): ChatOutcome {
processChatMessage(
channel: string,
msg: BotMessage,
now: number,
stream?: { id: string; startedAt: number } | null,
): ChatOutcome {
const ev: Partial<ChatEvent> = msg.event && typeof msg.event === "object" ? msg.event : {};
const userId = validUserId(ev.chatter_user_id);
const username = normalizeUsername(ev.chatter_user_login);
Expand Down Expand Up @@ -3244,6 +3348,7 @@ export class ChannelRoom extends DurableObject<Env> {
newAccounts,
quick: subscriptionId === SE_SUBSCRIPTION_ID || msg.quick === true,
rolls: Array.from({ length: 120 }, () => Math.random()), // 5 slots of 24 (settleQuickDuel)
...(stream !== undefined ? { stream } : {}),
});
if (!main.result.ok && main.result.reason !== "duplicate")
step({
Expand Down Expand Up @@ -3474,6 +3579,7 @@ export class ChannelRoom extends DurableObject<Env> {
decision: d.decision,
flawless: d.flawless,
unrated: d.unrated,
lastRanked: d.lastRanked,
});
const { replies } = await this.sendBotLines([line], channel, { system: 1 }).json<{ replies: string[] }>(); // sendBotLines's answer
if (!replies.length) return;
Expand Down
Loading
Loading