Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
14 changed files with 197 additions and 12 deletions
Showing only changes of commit 91d5c341ef - Show all commits
+3 -1
View File
@@ -111,7 +111,9 @@ an existing word (down a column or across a row) is accepted. A play is validate
against the game's dictionary at submit time and scored; an unlimited preview
reports the word(s) a tentative move would form and its score, or that it is not
legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is
unlimited and offers a complaint on any result. Hints are governed per game —
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. The game ends when the
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
+3 -1
View File
@@ -115,7 +115,9 @@ nudge) приходят от бота **этой партии** — по язы
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат. Подсказки управляются настройками
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
+4 -1
View File
@@ -73,7 +73,10 @@ Login uses `Screen`.
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
auto-save); **vertical swipes** (swipe-to-minimise)
are disabled so they don't fight tile drag or the board scroll; and a live stream dropped
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
confirmation a plain `target=_blank` triggers; and a live stream dropped
by a background suspend reconnects silently on return — the connection banner is
suppressed while hidden and for a short grace after resume (visibilitychange +
pageshow/pagehide + Telegram `activated`/`deactivated`).
+7
View File
@@ -221,6 +221,13 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => {
await page.locator('.check button').click(); // Check (enabled: length 3)
await expect(page.locator('.ok, .bad')).toBeVisible();
// A found word offers an external dictionary lookup link that opens in a new tab; the
// verdict still carries the complaint button.
const lookup = page.getByRole('link', { name: 'Look it up' });
await expect(lookup).toBeVisible();
await expect(lookup).toHaveAttribute('target', '_blank');
await expect(lookup).toHaveAttribute('href', /qza$/);
});
test('dropping the game ends it and shows the result', async ({ page }) => {
+6 -1
View File
@@ -9,6 +9,7 @@
type BannerConfig,
type BannerItem,
} from '../lib/banner';
import { onExternalLinkClick } from '../lib/telegram';
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
$props();
@@ -39,7 +40,11 @@
onDestroy(() => rotator?.stop());
</script>
<div class="ad" bind:this={viewport}>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
Telegram SDK (onExternalLinkClick uses closest('a')) so they skip the WebView confirmation. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
{#key current}
<div
class="track"
+23 -3
View File
@@ -4,7 +4,8 @@
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
import { onExternalLinkClick } from '../lib/telegram';
import type { Variant } from '../lib/model';
// Word-check on its own screen: unlimited dictionary lookups, each with a
@@ -75,7 +76,17 @@
? t('game.wordLegal', { word: result.word })
: t('game.wordIllegal', { word: result.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
<div class="actions">
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{#if result.legal}
<a
class="lookup"
href={dictionaryLookupUrl(result.word, variant)}
target="_blank"
rel="noopener noreferrer"
onclick={onExternalLinkClick}>{t('game.lookup')}</a>
{/if}
</div>
{/if}
</div>
@@ -120,12 +131,21 @@
.verdict.bad {
color: var(--danger, #c0392b);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.complain {
align-self: flex-start;
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.lookup {
color: var(--accent);
text-decoration: underline;
font-size: 0.95em;
}
</style>
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { canCheckWord, MAX_WORD_LEN, sanitizeCheckWord } from './checkword';
import { canCheckWord, dictionaryLookupUrl, MAX_WORD_LEN, sanitizeCheckWord } from './checkword';
const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
@@ -40,3 +40,18 @@ describe('canCheckWord', () => {
expect(canCheckWord(' a ', false, false)).toBe(false);
});
});
describe('dictionaryLookupUrl', () => {
it('points the Russian variants at gramota.ru with a lower-cased, encoded query', () => {
expect(dictionaryLookupUrl('КОТ', 'scrabble_ru')).toBe(
'https://gramota.ru/poisk?query=' + encodeURIComponent('кот'),
);
expect(dictionaryLookupUrl('ЭРА', 'erudit_ru')).toBe(
'https://gramota.ru/poisk?query=' + encodeURIComponent('эра'),
);
});
it('points English at scrabblewordfinder.org with a lower-cased path', () => {
expect(dictionaryLookupUrl('CAT', 'scrabble_en')).toBe('https://scrabblewordfinder.org/dictionary/cat');
});
});
+15
View File
@@ -3,6 +3,8 @@
// variant alphabet, the length bounds, the answered-word cache and the cool-down
// throttle) are unit-testable and stay in lockstep with the UI.
import type { Variant } from './model';
/** The longest word that fits on a standard 15-cell board line. */
export const MAX_WORD_LEN = 15;
/** The shortest word worth checking. */
@@ -29,3 +31,16 @@ export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boo
const w = word.trim();
return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling;
}
/**
* dictionaryLookupUrl returns the external reference-dictionary search URL for a checked word
* in the given variant: gramota.ru for the Russian variants (scrabble_ru, erudit_ru) and
* scrabblewordfinder.org for English. The word is lower-cased and percent-encoded, so a
* Cyrillic query travels safely.
*/
export function dictionaryLookupUrl(word: string, variant: Variant): string {
const w = encodeURIComponent(word.toLowerCase());
return variant.endsWith('_ru')
? `https://gramota.ru/poisk?query=${w}`
: `https://scrabblewordfinder.org/dictionary/${w}`;
}
+1
View File
@@ -92,6 +92,7 @@ export const en = {
'game.wordLegal': '“{word}” is valid',
'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree',
'game.lookup': 'Look it up',
'game.complaintSent': 'Thanks, sent for review.',
'game.check': 'Check',
'game.checkWait': 'Please wait a moment.',
+2 -1
View File
@@ -92,7 +92,8 @@ export const ru: Record<MessageKey, string> = {
'game.checkWordPrompt': 'Введите слово',
'game.wordLegal': '«{word}» допустимо',
'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен',
'game.complain': 'Возражаю',
'game.lookup': 'Поискать в словаре',
'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.',
+56 -1
View File
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram';
import {
insideTelegram,
routeExternalLinkInTelegram,
telegramClosingConfirmation,
telegramLaunch,
telegramOpenExternalLink,
telegramRequestFullscreen,
} from './telegram';
function stubWebApp(initData: string, startParam?: string) {
vi.stubGlobal('window', {
@@ -100,3 +107,51 @@ describe('telegramRequestFullscreen', () => {
}
});
});
describe('telegramOpenExternalLink', () => {
afterEach(() => vi.unstubAllGlobals());
it('routes an external URL through the SDK openLink inside Telegram', () => {
const openLink = vi.fn();
vi.stubGlobal('window', { Telegram: { WebApp: { openLink } } });
expect(telegramOpenExternalLink('https://gramota.ru/poisk?query=кот')).toBe(true);
expect(openLink).toHaveBeenCalledWith('https://gramota.ru/poisk?query=кот');
});
it('returns false without the SDK so the caller falls back to the anchor', () => {
expect(telegramOpenExternalLink('https://x.io')).toBe(false);
});
});
describe('routeExternalLinkInTelegram', () => {
afterEach(() => vi.unstubAllGlobals());
function stubInside(openLink = vi.fn(), origin = 'https://app.example') {
vi.stubGlobal('window', { Telegram: { WebApp: { initData: 'query_id=abc', openLink } } });
vi.stubGlobal('location', { href: origin + '/', origin });
return openLink;
}
it('routes an external http(s) _blank link through openLink inside Telegram', () => {
const openLink = stubInside();
expect(routeExternalLinkInTelegram({ href: 'https://gramota.ru/poisk?query=кот', target: '_blank' })).toBe(true);
expect(openLink).toHaveBeenCalledWith('https://gramota.ru/poisk?query=кот');
});
it('leaves a t.me link to the native handler (openTelegramLink owns those)', () => {
const openLink = stubInside();
expect(routeExternalLinkInTelegram({ href: 'https://t.me/some_bot', target: '_blank' })).toBe(false);
expect(openLink).not.toHaveBeenCalled();
});
it('leaves same-origin and non-_blank links to in-app navigation', () => {
const openLink = stubInside(vi.fn(), 'https://app.example');
expect(routeExternalLinkInTelegram({ href: 'https://app.example/lobby', target: '_blank' })).toBe(false);
expect(routeExternalLinkInTelegram({ href: 'https://gramota.ru', target: '' })).toBe(false);
expect(openLink).not.toHaveBeenCalled();
});
it('does nothing outside Telegram (the anchor opens natively)', () => {
expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false);
});
});
+57
View File
@@ -18,6 +18,7 @@ interface TelegramWebApp {
expand?: () => void;
requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void;
@@ -65,6 +66,62 @@ export function telegramOpenLink(url: string): boolean {
return true;
}
/**
* telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink,
* so Telegram opens it directly in its in-app browser instead of the WebView's generic "open
* this link?" confirmation that a plain target=_blank navigation triggers. Returns false
* outside Telegram or when the SDK lacks the method, so the caller can fall back to a normal
* anchor.
*/
export function telegramOpenExternalLink(url: string): boolean {
const w = webApp();
if (!w?.openLink) return false;
w.openLink(url);
return true;
}
/**
* isExternalHttpUrl reports whether href is an absolute http(s) URL on a different origin than the
* app — so a same-origin or root-relative in-app (SPA) link is left to normal navigation, only a
* genuinely external link is routed through Telegram's browser.
*/
function isExternalHttpUrl(href: string): boolean {
try {
const u = new URL(href, typeof location === 'undefined' ? undefined : location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
return typeof location === 'undefined' || u.origin !== location.origin;
} catch {
return false;
}
}
/**
* routeExternalLinkInTelegram decides whether a clicked anchor should be opened through the Mini
* App SDK rather than the WebView's default navigation: only inside Telegram, and only for an
* external http(s) link opened in a new tab (target=_blank) that is not a t.me link (those use
* telegramOpenLink/openTelegramLink). Returns true when it opened the link through openLink — the
* caller should then preventDefault — and false to let the browser handle the click normally.
*/
export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean {
if (!insideTelegram()) return false;
if (anchor.target !== '_blank') return false;
if (anchor.href.startsWith('https://t.me/')) return false;
if (!isExternalHttpUrl(anchor.href)) return false;
return telegramOpenExternalLink(anchor.href);
}
/**
* onExternalLinkClick is the anchor click handler that applies routeExternalLinkInTelegram. It
* resolves the clicked anchor with closest(), so it works both attached directly on an <a> and
* delegated on a container that renders links (e.g. {@html} markdown). Attach it as onclick on an
* external link or its container; outside Telegram it is a no-op and the anchor's own
* target=_blank handles the click.
*/
export function onExternalLinkClick(e: MouseEvent): void {
const a = (e.target as HTMLElement | null)?.closest('a');
if (a && routeExternalLinkInTelegram(a)) e.preventDefault();
}
/**
* shareTelegramLink opens Telegram's native "share to a chat" picker for url with a
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside
+2 -1
View File
@@ -3,6 +3,7 @@
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { aboutContent } from '../lib/aboutContent';
import { onExternalLinkClick } from '../lib/telegram';
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24;
@@ -13,7 +14,7 @@
<div class="page">
<h1>{c.title}</h1>
<p>
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer">{c.rulesLink}</a>.
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{c.rulesLink}</a>.
</p>
<section>
+2 -1
View File
@@ -7,6 +7,7 @@
import { connection } from '../lib/connection.svelte';
import { clientChannel } from '../lib/channel';
import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
import { onExternalLinkClick } from '../lib/telegram';
import type { FeedbackState } from '../lib/model';
// The feedback screen: a message (+ optional single attachment) the player sends to
@@ -123,7 +124,7 @@
{#if view?.reply}
<section class="reply">
<h2>{t('feedback.replyTitle')}</h2>
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
</section>
{/if}
</div>