mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-05-12 18:58:17 +00:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user