chore(deps): bump vite and vitest (#1)

* build(deps): bump vite and vitest

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) to 8.0.8 and updates ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `vite` from 5.4.21 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

Updates `vitest` from 2.1.9 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat(loldle): share game state per-chat in groups

Groups and supergroups now share one daily puzzle + one stats counter
across all members. Private chats remain per-user.

- state.js: renamed key arg from userId to subject (user|chat id)
- handlers.js: getSubject(ctx) picks user id in DM, chat id in groups
- /loldle_stats labels scope as "your" vs "group" accordingly

* feat(loldle): add /loldle_new + switch to self-paced rounds

- /loldle_new starts a new random round. If the previous round is not
  solved/given-up, it's recorded as a loss (auto-giveup) before rerolling.
- Drop daily-seeded targets: each round picks a uniformly-random champion
  (pickRandom in daily.js; pickDaily kept for future use).
- state.js: one active round per subject (no date in key). TTL raised to
  7 days; streak = consecutive wins (round-based, not date-based).
- Register /loldle_new in module index; now 8 public loldle commands.
- Tests: add pickRandom cases; bump expected command count to 12.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tiennm99 <tiennm99@outlook.com>
This commit is contained in:
dependabot[bot]
2026-04-20 17:55:40 +07:00
committed by GitHub
parent 763ca8c696
commit 6de35d3e4f
8 changed files with 1088 additions and 666 deletions
+22 -7
View File
@@ -1,6 +1,5 @@
/**
* @file Daily puzzle seeding — deterministic per UTC date.
* Uses djb2 hash of YYYY-MM-DD to pick champion index.
* @file Champion pickers — deterministic daily seeding and fresh-random.
*/
/** UTC date string YYYY-MM-DD. */
@@ -18,16 +17,32 @@ function hash(str) {
}
/**
* Pick today's target champion deterministically.
* Deterministic pick seeded by date (or any string).
* @template T
* @param {T[]} champions
* @param {string} [seed] — defaults to today's UTC date
* @param {string} [seed]
* @returns {T}
*/
export function pickDaily(champions, seed) {
if (!Array.isArray(champions) || champions.length === 0) {
throw new Error("pickDaily: champions array is empty");
}
assertNonEmpty(champions);
const s = seed ?? todayUtc();
return champions[hash(s) % champions.length];
}
/**
* Uniformly random pick. `rng` defaults to Math.random — override for tests.
* @template T
* @param {T[]} champions
* @param {() => number} [rng]
* @returns {T}
*/
export function pickRandom(champions, rng = Math.random) {
assertNonEmpty(champions);
return champions[Math.floor(rng() * champions.length)];
}
function assertNonEmpty(arr) {
if (!Array.isArray(arr) || arr.length === 0) {
throw new Error("picker: champions array is empty");
}
}
+94 -40
View File
@@ -1,15 +1,21 @@
/**
* @file Command handlers for loldle module.
*
* - /loldle → show board / start puzzle
* - /loldle <champion> → submit a guess
* - /loldle_giveup → reveal answer, end today's game
* - /loldle_stats → show personal stats
* Subject resolution:
* private chat → user id (per-user game)
* group/supergroup chat → chat id (shared game — all members play together)
*
* Commands:
* /loldle → show board / start puzzle
* /loldle <champion> → submit a guess
* /loldle_new → abandon current round (counts as giveup) + start fresh
* /loldle_giveup → reveal answer, end current round
* /loldle_stats → show stats (per-user in DM, per-group in groups)
*/
import championsData from "./champions-data.js";
import { compareChampions } from "./compare.js";
import { pickDaily, todayUtc } from "./daily.js";
import { pickRandom } from "./daily.js";
import { findChampion } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
import { loadGame, loadStats, MAX_GUESSES, recordResult, saveGame } from "./state.js";
@@ -17,18 +23,44 @@ import { loadGame, loadStats, MAX_GUESSES, recordResult, saveGame } from "./stat
/** @type {Array<Record<string, any>>} */
const champions = championsData;
/**
* Returns the stable subject identifier for the current chat.
* In private chat: user id. In groups: chat id (shared across all members).
* @param {import("grammy").Context} ctx
* @returns {number|null}
*/
function getSubject(ctx) {
const type = ctx.chat?.type;
if (type === "private") return ctx.from?.id ?? null;
if (type === "group" || type === "supergroup") return ctx.chat.id;
return ctx.from?.id ?? null;
}
function argAfterCommand(text) {
if (!text) return "";
const idx = text.indexOf(" ");
return idx === -1 ? "" : text.slice(idx + 1).trim();
}
async function getOrInitGame(db, userId, date) {
const existing = await loadGame(db, userId, date);
function isFinished(game) {
return game.solved || game.giveup || game.guesses.length >= MAX_GUESSES;
}
/**
* Load existing round, or create + persist a fresh random one.
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} subject
*/
async function getOrInitGame(db, subject) {
const existing = await loadGame(db, subject);
if (existing) return existing;
const target = pickDaily(champions, date);
const fresh = { target: target.id, guesses: [], solved: false };
await saveGame(db, userId, date, fresh);
return startFreshGame(db, subject);
}
async function startFreshGame(db, subject) {
const target = pickRandom(champions);
const fresh = { target: target.id, guesses: [], solved: false, startedAt: Date.now() };
await saveGame(db, subject, fresh);
return fresh;
}
@@ -37,27 +69,25 @@ async function getOrInitGame(db, userId, date) {
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleLoldle(ctx, db) {
const userId = ctx.from?.id;
if (!userId) return ctx.reply("Cannot identify user.");
const date = todayUtc();
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const arg = argAfterCommand(ctx.message?.text ?? "");
const game = await getOrInitGame(db, userId, date);
const game = await getOrInitGame(db, subject);
if (!arg) {
const status = game.solved
? `🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}.`
const header = game.solved
? `🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}. /loldle_new for another.`
: game.giveup
? `🏳️ Gave up. Answer was ${game.target}.`
? `🏳️ Gave up. Answer was ${game.target}. /loldle_new for another.`
: `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/loldle <champion>\`.`;
return ctx.reply(`${status}\n\n${renderBoard(game.guesses)}`);
return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`);
}
if (game.solved || game.giveup) {
return ctx.reply(`Today's game is over. Answer was ${game.target}.`);
}
if (game.guesses.length >= MAX_GUESSES) {
return ctx.reply(`Out of guesses. Answer was ${game.target}.`);
if (isFinished(game)) {
return ctx.reply(
`Current round is over. Use /loldle_new to start another. Answer was ${game.target}.`,
);
}
const guess = findChampion(champions, arg);
@@ -68,36 +98,59 @@ export async function handleLoldle(ctx, db) {
game.guesses.push({ champion: guess.name, results });
const won = guess.id === target.id;
if (won) game.solved = true;
await saveGame(db, userId, date, game);
await saveGame(db, subject, game);
const reply = renderGuess(guess.name, results);
if (won) {
const s = await recordResult(db, userId, date, true);
return ctx.reply(`${reply}\n\n🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}.`);
const s = await recordResult(db, subject, true);
return ctx.reply(
`${reply}\n\n🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}. /loldle_new for another.`,
);
}
if (game.guesses.length >= MAX_GUESSES) {
await recordResult(db, userId, date, false);
return ctx.reply(`${reply}\n\n❌ Out of guesses. Answer was ${target.name}.`);
await recordResult(db, subject, false);
return ctx.reply(
`${reply}\n\n❌ Out of guesses. Answer was ${target.name}. /loldle_new to retry.`,
);
}
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`);
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleNew(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const prior = await loadGame(db, subject);
let prelude = "";
if (prior && !isFinished(prior)) {
await recordResult(db, subject, false);
const prev = champions.find((c) => c.id === prior.target);
prelude = `🏳️ Previous round abandoned (auto-giveup). Answer was ${prev?.name ?? prior.target}.\n\n`;
}
await startFreshGame(db, subject);
return ctx.reply(`${prelude}🆕 New round started. Use \`/loldle <champion>\` to guess.`);
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleGiveup(ctx, db) {
const userId = ctx.from?.id;
if (!userId) return ctx.reply("Cannot identify user.");
const date = todayUtc();
const game = await getOrInitGame(db, userId, date);
if (game.solved) return ctx.reply(`Already solved today — ${game.target}.`);
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const game = await getOrInitGame(db, subject);
if (game.solved) return ctx.reply(`Already solved — ${game.target}.`);
if (game.giveup) return ctx.reply(`Already gave up — ${game.target}.`);
game.giveup = true;
await saveGame(db, userId, date, game);
await recordResult(db, userId, date, false);
await saveGame(db, subject, game);
await recordResult(db, subject, false);
const target = champions.find((c) => c.id === game.target);
return ctx.reply(`🏳️ Answer was ${target.name}${target.title}.`);
return ctx.reply(`🏳️ Answer was ${target.name}${target.title}. /loldle_new for another.`);
}
/**
@@ -105,12 +158,13 @@ export async function handleGiveup(ctx, db) {
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleStats(ctx, db) {
const userId = ctx.from?.id;
if (!userId) return ctx.reply("Cannot identify user.");
const s = await loadStats(db, userId);
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const s = await loadStats(db, subject);
const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0;
const scope = ctx.chat?.type === "private" ? "your" : "group";
return ctx.reply(
`📊 Loldle stats\n` +
`📊 Loldle ${scope} stats\n` +
`Played: ${s.played}\n` +
`Wins: ${s.wins} (${winRate}%)\n` +
`Current streak: ${s.streak}\n` +
+9 -3
View File
@@ -5,7 +5,7 @@
* tiennm99/loldle-data's champions.json (synced via GH Actions).
*/
import { handleGiveup, handleLoldle, handleStats } from "./handlers.js";
import { handleGiveup, handleLoldle, handleNew, handleStats } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
@@ -20,13 +20,19 @@ const loldleModule = {
{
name: "loldle",
visibility: "public",
description: "Classic loldle — guess today's champion",
description: "Classic loldle — guess the current champion",
handler: (ctx) => handleLoldle(ctx, db),
},
{
name: "loldle_new",
visibility: "public",
description: "Start a new round (auto-gives-up any in-progress one)",
handler: (ctx) => handleNew(ctx, db),
},
{
name: "loldle_giveup",
visibility: "public",
description: "Reveal today's loldle answer",
description: "Reveal the current loldle answer",
handler: (ctx) => handleGiveup(ctx, db),
},
{
+33 -36
View File
@@ -1,16 +1,22 @@
/**
* @file Per-user daily game state in KV.
* @file Game state in KV, keyed by "subject" (user in DM, chat in groups).
*
* One active round per subject at a time. Rounds are self-paced: players
* can /loldle_new to abandon and reroll. Streak = consecutive wins.
*
* Key layout (inside module-prefixed store):
* game:<userId>:<yyyy-mm-dd> -> { target, guesses[], solved, giveup }
* stats:<userId> -> { played, wins, streak, bestStreak, lastDate }
* game:<subject> -> { target, guesses[], solved, giveup, startedAt }
* stats:<subject> -> { played, wins, streak, bestStreak, lastResultAt }
*/
const MAX_GUESSES = 8;
// 7 days — a round can't linger forever, but is far longer than typical play.
const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
/** @param {number} userId @param {string} date */
const gameKey = (userId, date) => `game:${userId}:${date}`;
/** @param {number} userId */
const statsKey = (userId) => `stats:${userId}`;
/** @param {number|string} subject */
const gameKey = (subject) => `game:${subject}`;
/** @param {number|string} subject */
const statsKey = (subject) => `stats:${subject}`;
/**
* @typedef {object} GameState
@@ -18,73 +24,64 @@ const statsKey = (userId) => `stats:${userId}`;
* @property {Array<{champion:string, results:any[]}>} guesses
* @property {boolean} solved
* @property {boolean} [giveup]
* @property {number} [startedAt] — epoch ms
*/
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
* @param {string} date
* @param {number|string} subject
* @returns {Promise<GameState|null>}
*/
export async function loadGame(db, userId, date) {
return db.getJSON(gameKey(userId, date));
export async function loadGame(db, subject) {
return db.getJSON(gameKey(subject));
}
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
* @param {string} date
* @param {number|string} subject
* @param {GameState} state
*/
export async function saveGame(db, userId, date, state) {
// Expire after 3 days — stats are the only long-lived record.
await db.putJSON(gameKey(userId, date), state, { expirationTtl: 60 * 60 * 24 * 3 });
export async function saveGame(db, subject, state) {
await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS });
}
export { MAX_GUESSES };
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
* @param {number|string} subject
*/
export async function loadStats(db, userId) {
export async function loadStats(db, subject) {
return (
(await db.getJSON(statsKey(userId))) ?? {
(await db.getJSON(statsKey(subject))) ?? {
played: 0,
wins: 0,
streak: 0,
bestStreak: 0,
lastDate: null,
lastResultAt: null,
}
);
}
/**
* Record a finished game and update streaks.
* Record a finished round (win/loss/giveup) and update streaks.
* Streak increments on each win, resets to 0 on any non-win.
*
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
* @param {string} date
* @param {number|string} subject
* @param {boolean} won
*/
export async function recordResult(db, userId, date, won) {
const s = await loadStats(db, userId);
export async function recordResult(db, subject, won) {
const s = await loadStats(db, subject);
s.played += 1;
if (won) {
s.wins += 1;
const prev = s.lastDate;
const yesterday = prevDate(date);
s.streak = prev === yesterday || prev === date ? s.streak + (prev === date ? 0 : 1) : 1;
s.streak += 1;
if (s.streak > s.bestStreak) s.bestStreak = s.streak;
} else {
s.streak = 0;
}
s.lastDate = date;
await db.putJSON(statsKey(userId), s);
s.lastResultAt = Date.now();
await db.putJSON(statsKey(subject), s);
return s;
}
function prevDate(ymd) {
const d = new Date(`${ymd}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}