feat(ui): explicit offline state inside an online game (+ offline word check)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
When an online game loses the connection the game screen now says so and freezes instead of silently greying out: a "connection lost" banner appears and the rack, the move controls, the add-friend/block controls and the chat/dictionary entry all disable (a started move stays a draft, committed by the player on reconnect). It is driven off the net-state machine (netState.offline), so it also covers the Telegram/VK mini-apps, where a lost connection was previously mute in-game. If the player is already in the dictionary when the drop happens, the word check falls back to the game's pinned on-device dictionary (exact when that dawg is cached; "unavailable offline" otherwise) and the network-only complaint + external look-up hide. Chat, when already open, keeps its existing read-only degrade (send/nudge disable). New pure helper localWordCheck is unit-tested; the in-game gating gets an e2e.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setAlphabet } from '../alphabet';
|
||||
|
||||
// getDawg is the only side-effecting dependency; stub it so the test exercises localWordCheck's
|
||||
// own logic (load -> encode -> membership -> null handling) against a fake reader, not a real DAWG.
|
||||
vi.mock('./loader', () => ({ getDawg: vi.fn() }));
|
||||
import { getDawg } from './loader';
|
||||
import { localWordCheck } from './check';
|
||||
|
||||
const mockedGetDawg = vi.mocked(getDawg);
|
||||
|
||||
// A tiny cached alphabet so indexForLetter can encode the test words.
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'A', value: 1 },
|
||||
{ index: 1, letter: 'B', value: 3 },
|
||||
{ index: 2, letter: 'C', value: 3 },
|
||||
]);
|
||||
|
||||
describe('localWordCheck', () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns true when the dawg contains the word', async () => {
|
||||
mockedGetDawg.mockResolvedValue({ indexOf: () => 5 } as never);
|
||||
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the dawg does not contain the word', async () => {
|
||||
mockedGetDawg.mockResolvedValue({ indexOf: () => -1 } as never);
|
||||
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when the dawg is unavailable (offline and uncached)', async () => {
|
||||
mockedGetDawg.mockResolvedValue(null);
|
||||
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(null);
|
||||
});
|
||||
|
||||
it('encodes the word to alphabet indices for the membership test', async () => {
|
||||
const indexOf = vi.fn(() => 0);
|
||||
mockedGetDawg.mockResolvedValue({ indexOf } as never);
|
||||
await localWordCheck('scrabble_en', 'v1', 'CAB');
|
||||
expect(indexOf).toHaveBeenCalledWith([2, 0, 1]);
|
||||
});
|
||||
|
||||
it('loads the dawg for the pinned (variant, version)', async () => {
|
||||
mockedGetDawg.mockResolvedValue({ indexOf: () => 1 } as never);
|
||||
await localWordCheck('scrabble_en', 'v1.3.0', 'AB');
|
||||
expect(mockedGetDawg).toHaveBeenCalledWith('scrabble_en', 'v1.3.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Offline word-check fallback for an online game. An online game's dictionary check normally goes to
|
||||
// the gateway (game.check_word); while disconnected that call fails, so the check falls back to the
|
||||
// game's own pinned dictionary read locally. Using the game's pinned (variant, version) means the
|
||||
// answer matches the server's — no divergence — as long as that dawg is available on-device; if it
|
||||
// is not cached/bundled offline, the check is simply unavailable (null). The membership test mirrors
|
||||
// the local engine's dictionaryHas (encode to alphabet indices, then Dawg.indexOf).
|
||||
|
||||
import { getDawg } from './loader';
|
||||
import { indexForLetter } from '../alphabet';
|
||||
import type { Variant } from '../model';
|
||||
|
||||
/**
|
||||
* localWordCheck reports whether word is in the (variant, version) dictionary using the on-device
|
||||
* dawg, or null when that dawg cannot be obtained (offline and neither cached nor bundled). word
|
||||
* must already be sanitised to the variant's alphabet (as the check panel does).
|
||||
*/
|
||||
export async function localWordCheck(
|
||||
variant: Variant,
|
||||
version: string,
|
||||
word: string,
|
||||
): Promise<boolean | null> {
|
||||
const dawg = await getDawg(variant, version);
|
||||
if (!dawg) return null;
|
||||
const idx = Array.from(word, (ch) => indexForLetter(variant, ch));
|
||||
return dawg.indexOf(idx) >= 0;
|
||||
}
|
||||
@@ -133,6 +133,8 @@ export const en = {
|
||||
'game.complaintSent': 'Thanks, sent for review.',
|
||||
'game.check': 'Check',
|
||||
'game.checkWait': 'Please wait a moment.',
|
||||
'game.checkOffline': 'Word check is unavailable offline.',
|
||||
'game.offlineBanner': 'Connection lost — your move will send once you’re back online.',
|
||||
'game.noHintOptions': 'No options with your letters.',
|
||||
'game.thinking': 'thinking…',
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
||||
'game.check': 'Проверить',
|
||||
'game.checkWait': 'Секунду, пожалуйста.',
|
||||
'game.checkOffline': 'Проверка недоступна офлайн.',
|
||||
'game.offlineBanner': 'Связь потеряна — ход отправится, когда вернётся сеть.',
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
'game.thinking': 'думает…',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user