d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
242 lines
9.5 KiB
TypeScript
242 lines
9.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBlink } from './lobbysort';
|
|
import type { GameView, Seat } from './model';
|
|
|
|
const ME = 'me';
|
|
// The self-id set the lobby passes (server user id + device-local guest id); ME is used as a bare seat
|
|
// id where a single seat is built, MINE where a function takes the whole "you" set.
|
|
const MINE = [ME];
|
|
const seat = (s: number, accountId: string): Seat => ({
|
|
seat: s,
|
|
accountId,
|
|
displayName: accountId,
|
|
score: 0,
|
|
hintsUsed: 0,
|
|
isWinner: false,
|
|
});
|
|
|
|
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
|
|
return {
|
|
id,
|
|
variant: 'scrabble_en',
|
|
dictVersion: 'v1',
|
|
vsAi: false,
|
|
unreadChat: false,
|
|
unreadMessages: false,
|
|
kind: 0,
|
|
status,
|
|
players: 2,
|
|
toMove,
|
|
turnTimeoutSecs: 0,
|
|
multipleWordsPerTurn: true,
|
|
noRepeatWords: false,
|
|
moveCount: 0,
|
|
endReason: '',
|
|
lastActivityUnix,
|
|
seats: [seat(0, ME), seat(1, 'opp')],
|
|
};
|
|
}
|
|
|
|
describe('groupGames', () => {
|
|
it('partitions into your-turn, their-turn and finished', () => {
|
|
const g = groupGames(
|
|
[
|
|
game('a', 'active', 0, 100), // toMove 0 == my seat -> my turn
|
|
game('b', 'active', 1, 100), // their turn
|
|
game('c', 'finished', 0, 100),
|
|
],
|
|
MINE,
|
|
{},
|
|
);
|
|
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
|
|
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
|
|
expect(g.finished.map((x) => x.id)).toEqual(['c']);
|
|
});
|
|
|
|
it('orders your-turn oldest-first, the other two newest-first', () => {
|
|
const g = groupGames(
|
|
[
|
|
game('y_new', 'active', 0, 200),
|
|
game('y_old', 'active', 0, 100),
|
|
game('t_new', 'active', 1, 200),
|
|
game('t_old', 'active', 1, 100),
|
|
game('f_new', 'finished', 0, 200),
|
|
game('f_old', 'finished', 0, 100),
|
|
],
|
|
MINE,
|
|
{},
|
|
);
|
|
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
|
|
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
|
|
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
|
|
});
|
|
|
|
it('bumps unread games first in the active sections, leaving finished by activity', () => {
|
|
const g = groupGames(
|
|
[
|
|
game('y_read', 'active', 0, 50), // my turn, oldest, read
|
|
game('y_unread', 'active', 0, 200), // my turn, newest, unread -> bumped above y_read
|
|
game('t_read', 'active', 1, 200), // their turn, newest, read
|
|
game('t_unread', 'active', 1, 50), // their turn, oldest, unread -> bumped above t_read
|
|
game('f_unread', 'finished', 0, 100),
|
|
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
|
|
],
|
|
MINE,
|
|
{ y_unread: true, t_unread: true, f_unread: true },
|
|
);
|
|
// Unread is the primary key, so it overrides the within-section activity order.
|
|
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_unread', 'y_read']);
|
|
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_unread', 't_read']);
|
|
// Finished ignores unread: still newest-first by activity.
|
|
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_unread']);
|
|
});
|
|
|
|
it('isMyTurn is false for a finished game even at my seat', () => {
|
|
expect(isMyTurn(game('x', 'finished', 0, 0), MINE)).toBe(false);
|
|
expect(isMyTurn(game('x', 'active', 0, 0), MINE)).toBe(true);
|
|
expect(isMyTurn(game('x', 'active', 1, 0), MINE)).toBe(false);
|
|
});
|
|
|
|
it('treats an open game (awaiting an opponent) as in progress, not finished', () => {
|
|
const g = groupGames(
|
|
[
|
|
game('open_mine', 'open', 0, 100), // my turn while waiting
|
|
game('open_wait', 'open', 1, 100), // the empty seat's turn
|
|
],
|
|
MINE,
|
|
{},
|
|
);
|
|
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
|
|
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
|
|
expect(g.finished).toEqual([]);
|
|
expect(isMyTurn(game('x', 'open', 0, 0), MINE)).toBe(true);
|
|
});
|
|
|
|
it('recognises "you" under any of the self ids (local game under localGuestId, server under userId)', () => {
|
|
// After reconciliation a device-local game seats you under the local guest id and a server game
|
|
// under the user id; the two differ (ids never collide) and both must count as your turn.
|
|
const localGame: GameView = { ...game('local', 'active', 0, 100), seats: [seat(0, 'guest'), seat(1, 'robot')] };
|
|
const serverGame: GameView = { ...game('server', 'active', 0, 100), seats: [seat(0, ME), seat(1, 'opp')] };
|
|
const both = ['guest', ME];
|
|
expect(isMyTurn(localGame, both)).toBe(true);
|
|
expect(isMyTurn(serverGame, both)).toBe(true);
|
|
expect(isMyTurn(localGame, [ME])).toBe(false); // a single id misses the other source's game
|
|
const g = groupGames([localGame, serverGame], both, {});
|
|
expect(g.yourTurn.map((x) => x.id).sort()).toEqual(['local', 'server']);
|
|
});
|
|
});
|
|
|
|
describe('gamePhase', () => {
|
|
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
|
|
expect(gamePhase(game('x', 'active', 0, 0), MINE)).toBe('mine');
|
|
expect(gamePhase(game('x', 'active', 1, 0), MINE)).toBe('theirs');
|
|
expect(gamePhase(game('x', 'open', 0, 0), MINE)).toBe('mine');
|
|
expect(gamePhase(game('x', 'open', 1, 0), MINE)).toBe('theirs');
|
|
expect(gamePhase(game('x', 'finished', 0, 0), MINE)).toBe('finished');
|
|
});
|
|
});
|
|
|
|
describe('scoreStanding', () => {
|
|
const withScores = (status: GameView['status'], myScore: number, oppScore: number): GameView => ({
|
|
...game('g', status, 0, 0),
|
|
seats: [
|
|
{ ...seat(0, ME), score: myScore },
|
|
{ ...seat(1, 'opp'), score: oppScore },
|
|
],
|
|
});
|
|
const at = (g: GameView, accountId: string): Seat => g.seats.find((s) => s.accountId === accountId)!;
|
|
|
|
it('greens the leading viewer and reds a trailing one; the opponent stays muted', () => {
|
|
const lead = withScores('active', 30, 10);
|
|
expect(scoreStanding(lead, MINE, at(lead, ME))).toBe('win');
|
|
expect(scoreStanding(lead, MINE, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
|
|
|
|
const behind = withScores('active', 5, 10);
|
|
expect(scoreStanding(behind, MINE, at(behind, ME))).toBe('lose');
|
|
expect(scoreStanding(behind, MINE, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
|
|
});
|
|
|
|
it('greens both seats on an equal non-zero score', () => {
|
|
const tie = withScores('active', 10, 10);
|
|
expect(scoreStanding(tie, MINE, at(tie, ME))).toBe('win');
|
|
expect(scoreStanding(tie, MINE, at(tie, 'opp'))).toBe('win');
|
|
});
|
|
|
|
it('leaves a fresh 0:0 board muted (nobody has scored yet)', () => {
|
|
const fresh = withScores('active', 0, 0);
|
|
expect(scoreStanding(fresh, MINE, at(fresh, ME))).toBeNull();
|
|
expect(scoreStanding(fresh, MINE, at(fresh, 'opp'))).toBeNull();
|
|
});
|
|
|
|
it('is null for any non-active game (open or finished)', () => {
|
|
const fin = withScores('finished', 30, 10);
|
|
expect(scoreStanding(fin, MINE, at(fin, ME))).toBeNull();
|
|
const op = withScores('open', 0, 0);
|
|
expect(scoreStanding(op, MINE, at(op, ME))).toBeNull();
|
|
});
|
|
|
|
it('is null when the viewer is not seated or has no opponent', () => {
|
|
const g = withScores('active', 30, 10);
|
|
expect(scoreStanding(g, ['stranger'], at(g, ME))).toBeNull();
|
|
const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] };
|
|
expect(scoreStanding(solo, MINE, solo.seats[0])).toBeNull();
|
|
});
|
|
|
|
it('compares against the strongest opponent in a multiplayer game', () => {
|
|
const g3: GameView = {
|
|
...game('g', 'active', 0, 0),
|
|
players: 3,
|
|
seats: [
|
|
{ ...seat(0, ME), score: 40 },
|
|
{ ...seat(1, 'a'), score: 35 },
|
|
{ ...seat(2, 'b'), score: 50 }, // b is ahead -> the viewer is losing
|
|
],
|
|
};
|
|
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('lose');
|
|
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
|
|
});
|
|
|
|
it('greens every seat tied with the viewer for the lead in a multiplayer game', () => {
|
|
const g3: GameView = {
|
|
...game('g', 'active', 0, 0),
|
|
players: 3,
|
|
seats: [
|
|
{ ...seat(0, ME), score: 40 },
|
|
{ ...seat(1, 'a'), score: 40 }, // tied with the viewer for the lead -> green
|
|
{ ...seat(2, 'b'), score: 20 }, // trailing -> muted
|
|
],
|
|
};
|
|
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('win');
|
|
expect(scoreStanding(g3, MINE, at(g3, 'a'))).toBe('win');
|
|
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('orderedSeats', () => {
|
|
it('returns seats in seat-number order without mutating the source', () => {
|
|
const g: GameView = {
|
|
...game('g', 'active', 0, 0),
|
|
seats: [seat(2, 'c'), seat(0, ME), seat(1, 'b')],
|
|
};
|
|
const src = g.seats;
|
|
expect(orderedSeats(g).map((s) => s.seat)).toEqual([0, 1, 2]);
|
|
expect(g.seats).toBe(src); // the source array is left untouched
|
|
expect(g.seats.map((s) => s.seat)).toEqual([2, 0, 1]);
|
|
});
|
|
});
|
|
|
|
describe('shouldBlink', () => {
|
|
it('blinks on a transition into mine or finished', () => {
|
|
expect(shouldBlink('theirs', 'mine')).toBe(true);
|
|
expect(shouldBlink('mine', 'finished')).toBe(true);
|
|
expect(shouldBlink('theirs', 'finished')).toBe(true);
|
|
});
|
|
|
|
it('stays quiet on a transition into theirs, a no-op, or a first sighting', () => {
|
|
expect(shouldBlink('mine', 'theirs')).toBe(false);
|
|
expect(shouldBlink('mine', 'mine')).toBe(false);
|
|
expect(shouldBlink(undefined, 'mine')).toBe(false);
|
|
expect(shouldBlink(undefined, 'finished')).toBe(false);
|
|
});
|
|
});
|