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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
ADMIN_IDS=YOUR_TELEGRAM_USER_ID
AFFILIATE_SOURCE=GambleCodez
ANNOUNCE_CHANNEL="-1002648883359"
# Set in BotFather/non-privacy mode so bot can read group messages.
BOTPRIVACYMODE="disabled"
# BOT_PRIVACY_MODE="disabled" in BotFather/non-privacy mode so bot can read group messages.
BOT_PRIVACY_MODE="disabled"
BOT_TOKEN=
DEVICE=vps
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ You are the Runewager Bot Ops Commander. You manage the full lifecycle of the Ru
- Use `clearOldMenus(ctx)` (or approved wrapper) for menu-card transitions to enforce single-active-menu behavior.
- Keep link behavior deterministic: browser mode => external web URL only; telegram mode => startapp deep link only; Discord URLs must remain external browser links.
- `/testall` updates must preserve structured category checks and final summary: `TestAll complete — X passed, Y warnings, Z failures.`
- QA tester contract: maintain `/qa_on`, `/qa_off`, `/qa_mode`, `/qa_status` behavior and keep `qa/context/bot_capabilities.json`, `qa/context/repo_info.json`, and `qa/state/provider_status.json` synchronized with runtime changes.
- Referral onboarding rules must be enforced in code/tests: one-time code entry during onboarding only, no self-referral, no post-onboarding referral application, and dual 7-day boost assignment on valid referral.

## Workflow Patterns
Expand Down
4 changes: 1 addition & 3 deletions RUNEWAGER_FUNCTIONALITY_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ Navigation is driven by inline menus plus command aliases. Persistent user/admin
- Many menu-style responses use `replaceCallbackPanel(...)` to avoid stale stacked cards.
- Single active menu rule: `clearOldMenus(ctx)` + menu send helpers keep only one active transient menu card per user/chat.
- `to_main_menu` clears transient menu cards via `clearOldMenus(...)` before rendering persistent menu headers.
- SSHV command prompt reliability: `sshv_run_prompt` seeds `await_sshv_command` and private-DM admin text falls back to active SSHV session execution even if pending state was lost.
- QA control commands exist for tester integration: `/qa_on`, `/qa_off`, `/qa_mode user|admin`, `/qa_status`.

## 4. User Menu Tree

Expand Down Expand Up @@ -196,7 +194,7 @@ Notes:
## 14. Permissions & Access Control

- Admin identity is based on `ADMIN_IDS` env list.
- `.env.example` documents both `BOT_PRIVACY_MODE="disabled"` (BotFather non-privacy mode), plus Telegram/HTTPS/discovery keys used by runtime checks.
- `.env.example` documents both `BOT_PRIVACY_MODE="disabled"` (BotFather non-privacy mode) and compatibility `BOTPRIVACYMODE`, plus Telegram/HTTPS/discovery keys used by runtime checks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (typo): Consider clarifying the phrase "and compatibility BOTPRIVACYMODE" by adding a noun such as "alias".

Here “compatibility” is used without a noun, which reads a bit incomplete. Please rephrase to something like “and compatibility alias BOTPRIVACYMODE” or “and its compatibility alias BOTPRIVACYMODE” to improve the grammar while keeping the meaning.

Suggested change
- `.env.example` documents both `BOT_PRIVACY_MODE="disabled"` (BotFather non-privacy mode) and compatibility `BOTPRIVACYMODE`, plus Telegram/HTTPS/discovery keys used by runtime checks.
- `.env.example` documents both `BOT_PRIVACY_MODE="disabled"` (BotFather non-privacy mode) and its compatibility alias `BOTPRIVACYMODE`, plus Telegram/HTTPS/discovery keys used by runtime checks.

- `requireAdmin(ctx)` gates command/callback execution.
- Admin mode UI toggle changes visibility of admin UI, not true authorization.
- Group approvals list controls where certain broadcast/group operations target.
Expand Down
72 changes: 24 additions & 48 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,12 @@ function getDiscordLink(url) {
return 'https://discord.gg/runewagers';
}

function getPlayMode(user = null) {
const mode = user && user.settings ? user.settings.playMode : user && user.playMode;
return mode === 'browser' ? 'browser' : 'miniapp';
}

function getBrowserLink(route = 'play') {
const map = {
play: 'https://runewager.com/r/GambleCodez',
profile: 'https://www.runewager.com/profile',
claim: 'https://www.runewager.com/affiliate',
affiliate: 'https://www.runewager.com/affiliate',
play: 'https://gamble-codez.com/runewager',
profile: 'https://gamble-codez.com/runewager/profile',
claim: 'https://gamble-codez.com/runewager/claim',
affiliate: 'https://gamble-codez.com/runewager/affiliate',
discord: LINKS ? LINKS.rwDiscordJoin : 'https://discord.gg/runewagers',
};
return map[route] || map.play;
Expand All @@ -76,26 +71,8 @@ function getStartAppLink(route = 'play') {
}

function getPlayLink(user, route = 'play') {
return getPlayMode(user) === 'browser' ? getBrowserLink(route) : getStartAppLink(route);
}

function getPlayButton(user = null) {
if (getPlayMode(user) === 'browser') {
return {
text: '🔵 Play & Win',
url: 'https://runewager.com/r/GambleCodez',
};
}
return {
text: '🔵 Play & Win',
web_app: { url: 'https://t.me/RuneWager_bot/Play' },
};
}

function playButtonMarkup(user = null) {
const button = getPlayButton(user);
if (button.url) return Markup.button.url(button.text, button.url);
return Markup.button.webApp(button.text, button.web_app.url);
const mode = user && user.settings ? user.settings.playMode : 'miniapp';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Consider preserving the previous user.playMode fallback and normalization logic for play mode selection.

The previous implementation used user.playMode as a fallback when user.settings was missing and normalized values to 'browser' or 'miniapp'. The new code only reads user.settings.playMode and otherwise defaults to 'miniapp', changing behavior for users relying on user.playMode or non-standard values. If this behavior change isn’t deliberate, please restore the earlier fallback/normalization while keeping the simplified structure.

Comment on lines 73 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Revised play-mode resolution ignores legacy/user-level playMode and may change behavior for existing users.

Previously getPlayMode fell back to user.playMode when user.settings.playMode was missing; the new logic only checks user.settings.playMode (or defaults to 'miniapp'). To preserve existing behavior for records that still use the legacy field, consider:

const mode = user && user.settings
  ? (user.settings.playMode ?? user.playMode)
  : (user && user.playMode) || 'miniapp';

If user.playMode is guaranteed to be unused in production, this change is fine—but it would be good to confirm that assumption against existing data first.

return mode === 'browser' ? getBrowserLink(route) : getStartAppLink(route);
}

const LINKS = {
Expand Down Expand Up @@ -128,7 +105,7 @@ const PROMO_REQUIREMENT = {
const TELEGRAM_CHANNEL_ID = process.env.TELEGRAM_CHANNEL_ID || process.env.ANNOUNCE_CHANNEL || '';
const TELEGRAM_GROUP_ID = process.env.TELEGRAM_GROUP_ID || '';
const ANNOUNCE_CHANNEL = TELEGRAM_CHANNEL_ID;
const BOT_PRIVACY_MODE = String(process.env.BOT_PRIVACY_MODE || '').trim().toLowerCase();
const BOT_PRIVACY_MODE = String(process.env.BOT_PRIVACY_MODE || process.env.BOTPRIVACYMODE || '').trim().toLowerCase();

// Group chat_id for automatic Content Drops
const TIPS_GROUP = process.env.TIPS_GROUP || TELEGRAM_GROUP_ID || '';
Expand Down Expand Up @@ -539,19 +516,9 @@ function evaluatePendingActionTimeout(user, now = Date.now()) {
return { hadPending: false, expired: false, expiredType: null };
}

const nowMs = Number(now);
const safeNow = Number.isFinite(nowMs) ? nowMs : Date.now();
const createdRaw = user.pendingAction.createdAt;
const createdMs = Number(createdRaw);
if (!Number.isFinite(createdMs)) user.pendingAction.createdAt = safeNow;

const normalizedCreatedAt = Number(user.pendingAction.createdAt);
if (!Number.isFinite(normalizedCreatedAt)) {
user.pendingAction.createdAt = safeNow;
return { hadPending: true, expired: false, expiredType: null };
}
if (!user.pendingAction.createdAt) user.pendingAction.createdAt = now;

if ((safeNow - normalizedCreatedAt) <= PENDING_ACTION_TIMEOUT_MS) {
if ((now - Number(user.pendingAction.createdAt || 0)) <= PENDING_ACTION_TIMEOUT_MS) {
Comment on lines +519 to +521

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The simplified timeout calculation can misbehave when createdAt is non‑numeric or now is not a finite number.

Previously we normalized now/createdAt and treated non‑finite createdAt as "no timeout yet". The new code only sets createdAt when falsy and then does now - Number(user.pendingAction.createdAt || 0). For any truthy, non‑numeric createdAt (e.g., object, non‑numeric string), Number(...) becomes NaN, so the subtraction and <= comparison are NaN and the action is always considered expired. A non‑numeric now has the same effect. Please add back a minimal normalization/Number.isFinite check to avoid accidental early expiry when data is malformed.

Comment on lines +519 to +521

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The simplified timeout logic assumes now and createdAt are always numeric and may misbehave if non-numeric values are passed.

Previously, both now and createdAt were normalized with Number() and validated with Number.isFinite, falling back to Date.now() when invalid. The new version:

  • Uses now directly in arithmetic without validation.
  • Coerces createdAt with Number() but doesn’t handle NaN specially.

If now or createdAt are non-numeric (e.g., unexpected types), Number(createdAt) may be NaN, making the comparison always false and incorrectly treating actions as expired. To retain robustness, consider reintroducing lightweight validation:

const nowMs = Number(now);
const safeNow = Number.isFinite(nowMs) ? nowMs : Date.now();

if (!user.pendingAction.createdAt) {
  user.pendingAction.createdAt = safeNow;
}

const createdMs = Number(user.pendingAction.createdAt);
if (!Number.isFinite(createdMs)) {
  user.pendingAction.createdAt = safeNow;
  return { hadPending: true, expired: false, expiredType: null };
}

if ((safeNow - createdMs) <= PENDING_ACTION_TIMEOUT_MS) {
  // ...
}

return { hadPending: true, expired: false, expiredType: null };
}

Expand Down Expand Up @@ -2585,8 +2552,16 @@ function promoButton() {

*/

function miniAppPlayButton(user = null) {
return playButtonMarkup(user);
function playLaunchButton(user = null, textBrowser = '🌐 Open Runewager (Browser)', textMini = '🎮 Open Runewager (Mini App)', route = 'play') {
const inBrowser = (user && user.settings && user.settings.playMode === 'browser');
if (inBrowser) {
return Markup.button.url(textBrowser, getBrowserLink(route));
}
return Markup.button.webApp(textMini, getWebAppLink(route));
}

function miniAppPlayButton(user = null, route = 'play') {
return playLaunchButton(user, '🌐 Open Runewager (Browser)', '🎮 Open Runewager (Mini App)', route);
}

/**
Expand Down Expand Up @@ -2644,7 +2619,7 @@ function mainMenuKeyboard(isAdminUser = false, page = 1, user = null) {
// ── Page 1 — Primary Actions ───────────────────────────────────────────────
const rows = [
// Primary CTA — full width
[playButtonMarkup(user)],
[playLaunchButton(user, '🌐 Play Runewager (Browser)', '🎮 Play Runewager', 'play')],
// Account category
[
Markup.button.callback('✅ Create / Verify Account', 'menu_verify_account'),
Expand Down Expand Up @@ -3825,8 +3800,9 @@ async function sendMainMenu(ctx, user, page = 1) {

/** Keyboard for the new persistent USER MAIN MENU */
function userMainMenuKeyboard(isAdminUser, user = null) {
const browserMode = user && user.settings && user.settings.playMode === 'browser';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: browserMode is computed but not used in userMainMenuKeyboard.

browserMode is declared but never used in userMainMenuKeyboard. If it’s no longer needed after moving this logic into playLaunchButton, remove it; if it’s meant to control the keyboard layout, hook it into that branching logic instead.

const rows = [
[playButtonMarkup(user)],
[playLaunchButton(user, '🌐 Play Runewager (Browser)', '🎮 Play Runewager', 'play')],
[
Markup.button.callback('🎁 Bonuses & Rewards', 'pmenu_claim_bonus'),
Markup.button.callback('👤 Profile & Link', 'pmenu_my_profile'),
Expand Down Expand Up @@ -4768,7 +4744,7 @@ function referralShareHTML(code) {

Daily giveaways, instant rewards, drops, and a secret new-user bonus waiting inside the bot.

Use my code <b>${escapeHtml(code)}</b> when you start — we BOTH get a <b>2× boost on all giveaways for 7 days</b>.
Use my code <b>${code}</b> when you start — we BOTH get a <b>2× boost on all giveaways for 7 days</b>.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Removing escapeHtml on code introduces an HTML injection / formatting risk in the referral message.

The previous escapeHtml(code) call prevented codes with <, >, &, or quotes from breaking the markup or injecting tags. Now code is interpolated directly into HTML, so a malicious or malformed value could alter the rendered content. Unless code is strictly validated to only allow safe characters, this should continue to be HTML-escaped here.


👉 <a href="https://t.me/RunewagerBot">Tap here to join Runewager</a>

Expand Down Expand Up @@ -7314,7 +7290,7 @@ ${lines.join('\n')}`, Markup.inlineKeyboard([[Markup.button.callback('🔗 Group
bot.action('menu_qc_play', async (ctx) => {
const user = getUser(ctx);
await ctx.answerCbQuery();
await ctx.reply('🎮 Open Runewager:', Markup.inlineKeyboard([[playButtonMarkup(user)], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]]));
await ctx.reply('🎮 Open Runewager:', Markup.inlineKeyboard([[miniAppPlayButton(user)], [Markup.button.callback('⬅️ Main Menu', 'to_main_menu')]]));
});

bot.action('menu_qc_profile', async (ctx) => {
Expand Down
82 changes: 13 additions & 69 deletions test/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,8 @@ function extractActionRegexPatterns(source) {
* Supports `.*`, `^.*$`, and `.+` with optional grouping/anchors.
*/
function isCatchAllRegexPattern(patternSource) {
let compact = String(patternSource || '').replace(/\s+/g, '');
compact = compact.replace(/^\^/, '').replace(/\$$/, '');
Comment on lines 210 to -212

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Catch-all regex detection in the smoke test is now too narrow and may miss real catch-all patterns.

The previous isCatchAllRegexPattern normalized the pattern (removing anchors, outer grouping, and ?: prefixes) so grouped/non-capturing variants like (?:.*), (.*), ^ (?:.*)$, and lazy quantifiers were still treated as catch-alls. The new version only recognizes four exact strings, so semantically equivalent catch-all patterns will now be treated as non–catch-all, causing the smoke test to incorrectly flag them as needing explicit handlers or as coverage mismatches.

Consider either restoring the normalization (strip ^/$, surrounding (...), and ?:) or expanding the recognized patterns to cover common grouped/non-capturing forms so all true catch-all handlers are still ignored by the smoke test.

while (compact.startsWith('(') && compact.endsWith(')')) compact = compact.slice(1, -1);
compact = compact.replace(/^\?:/, '');

if (['.*', '.+', '.*?', '.+?'].includes(compact)) return true;
if (/^\(\?:?\.?\*\)$/.test(String(patternSource || '').replace(/\s+/g, ''))) return true;
return false;
const compact = String(patternSource || '').replace(/\s+/g, '');
return compact === '.*' || compact === '^.*$' || compact === '.+' || compact === '^.+$';
}

/**
Expand Down Expand Up @@ -558,67 +552,17 @@ test('to_main_menu clears old menus before rendering persistent menu', () => {
assert.ok(block.includes('await sendPersistentUserMenu(ctx, user);'), 'Expected sendPersistentUserMenu in to_main_menu');
});

test('play button helper is deterministic for browser and mini app modes', () => {
test('persistent user menu play button is settings-aware and supports browser mode', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
assert.ok(source.includes('function getPlayButton('), 'Expected unified getPlayButton helper');
const helperStart = source.indexOf('function getPlayButton(');
const helperBlock = source.slice(helperStart, helperStart + 650);
assert.ok(helperBlock.includes("url: 'https://runewager.com/r/GambleCodez'"), 'Expected browser mode external URL');
assert.ok(helperBlock.includes("web_app: { url: 'https://t.me/RuneWager_bot/Play' }"), 'Expected mini app web_app launch');
assert.ok(!helperBlock.includes('startapp='), 'Expected no startapp launch in getPlayButton helper');
});

test('all menu play buttons use unified getPlayButton path with no hardcoded mixed mode', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
assert.ok(source.includes('function playButtonMarkup('), 'Expected helper to convert getPlayButton to Markup button');

const playButtonUsageCount = (source.match(/\[playButtonMarkup\(user\)\]/g) || []).length;
assert.ok(playButtonUsageCount >= 2, 'Expected both main and persistent menus to use unified play button helper');

const quickStart = source.indexOf("bot.action('menu_qc_play'");
assert.ok(quickStart >= 0, 'Expected quick-play callback');
const quickBlock = source.slice(quickStart, quickStart + 350);
assert.ok(quickBlock.includes('playButtonMarkup(user)'), 'Expected quick-play to use unified play button helper');
});

test('settings play mode toggle persists, clears old menus, and re-renders settings', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
const start = source.indexOf("bot.action('settings_toggle_playmode'");
assert.ok(start >= 0, 'Expected settings_toggle_playmode callback');
assert.ok(source.includes('function playLaunchButton('), 'Expected shared playLaunchButton helper');
const helperStart = source.indexOf('function playLaunchButton(');
const helperBlock = source.slice(helperStart, helperStart + 700);
assert.ok(helperBlock.includes('Markup.button.url'), 'Expected browser-mode URL launch button');
assert.ok(helperBlock.includes('Markup.button.webApp'), 'Expected mini-app webApp launch button');

const start = source.indexOf('function userMainMenuKeyboard(');
assert.ok(start >= 0, 'Expected userMainMenuKeyboard');
Comment on lines +555 to +564

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Play button smoke test doesn’t fully exercise all callsites and routes of the new playLaunchButton helper

The new helper is also used by the quick‑play callback and on other routes that aren’t covered here. To make this a stronger guard on the new behavior, please:

  • Assert that the quick‑play callback (menu_qc_play) uses playLaunchButton or miniAppPlayButton, and
  • If there’s route‑specific behavior (e.g. profile, claim), add a small smoke test that those routes are passed into playLaunchButton/miniAppPlayButton as expected.

Suggested implementation:

test('persistent user menu play button is settings-aware and supports browser mode', () => {
  const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');

  // Ensure quick-play callback is wired through the new helper(s)
  assert.match(
    source,
    /menu_qc_play[\s\S]*?(playLaunchButton|miniAppPlayButton)/,
    'menu_qc_play should delegate to playLaunchButton or miniAppPlayButton'
  );

  // Ensure profile route is wired through the new helper(s)
  assert.match(
    source,
    /['"`]profile['"`][\s\S]*?(playLaunchButton|miniAppPlayButton)/,
    'profile route should delegate to playLaunchButton or miniAppPlayButton'
  );

  // Ensure claim route is wired through the new helper(s)
  assert.match(
    source,
    /['"`]claim['"`][\s\S]*?(playLaunchButton|miniAppPlayButton)/,
    'claim route should delegate to playLaunchButton or miniAppPlayButton'
  );
  • If the menu_qc_play, profile, or claim behaviors live in a different entry file (not index.js), adjust the fs.readFileSync target accordingly or add an additional source read for that file.
  • If the code structure separates route configuration from the helpers (e.g., routes in one module, handlers in another), you may want to tighten the regexes to match the actual wiring (e.g., menu_qc_play:.*playLaunchButton or router\.get\('/profile',.*playLaunchButton), once you confirm the concrete callsites in the codebase.

const block = source.slice(start, start + 500);
assert.ok(block.includes('persistRuntimeState();'), 'Expected play mode toggle to persist immediately');
assert.ok(block.includes('await clearOldMenus(ctx, user);'), 'Expected play mode toggle to clear stale menus before re-render');
assert.ok(block.includes('await sendSettingsMenu(ctx, user);'), 'Expected play mode toggle to re-render settings menu');
});


test('load_tooltips.sh exists and targets system tooltip json path', () => {
const script = fs.readFileSync(path.resolve(__dirname, '..', 'load_tooltips.sh'), 'utf8');
assert.ok(script.includes('/var/www/html/Runewager/data/tooltips.json'), 'Expected tooltips output path');
assert.ok(script.includes('✅ Tooltips loaded successfully'), 'Expected success message');
});

test('30 SC user submenu includes required manual-review actions and copy', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
for (const token of ['w30_menu_how', 'w30_menu_eligibility', 'w30_menu_request', 'w30_my_status']) {
assert.ok(source.includes(token), `Expected 30 SC menu callback: ${token}`);
}
assert.ok(source.includes('Eligibility is reviewed manually by GambleCodez.'), 'Expected manual eligibility copy');
assert.ok(source.includes('Your request has been submitted to GambleCodez for manual review.'), 'Expected request submission copy');
});

test('30 SC admin submenu and log sink are wired', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
assert.ok(source.includes("bot.action('w30_admin_menu'"), 'Expected admin bonus submenu callback');
for (const label of ['View Pending Requests', 'Approve Bonus', 'Deny Bonus', 'View User History', 'Reset Attempts']) {
assert.ok(source.includes(label), `Expected admin bonus menu label: ${label}`);
}
assert.ok(source.includes('/var/www/html/Runewager/logs/bonus_admin.log'), 'Expected bonus admin log file path');
});

test('BOTPRIVACYMODE compatibility env var is removed', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
const envExample = fs.readFileSync(path.resolve(__dirname, '..', '.env.example'), 'utf8');
assert.ok(!source.includes('BOTPRIVACYMODE'), 'Expected BOTPRIVACYMODE removed from runtime env parsing');
assert.ok(!envExample.includes('BOTPRIVACYMODE'), 'Expected BOTPRIVACYMODE removed from .env.example');
assert.ok(block.includes('playLaunchButton(user'), 'Expected settings-aware play launch helper in persistent menu');
assert.ok(block.includes('Play Runewager (Browser)'), 'Expected browser-mode label for persistent play button');
});
Loading