R4: push enrichment — events carry a state delta, kill the last poll
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback. - pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS. - backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size. - gateway: MoveResult transcode carries rack+bag_len. - ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false. - docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
|
||||
import type { CachedGame } from './gamecache';
|
||||
import type { GameView, MoveRecord, StateView } from './model';
|
||||
|
||||
function gameView(moveCount: number, over = false): GameView {
|
||||
return {
|
||||
id: 'g1',
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
status: over ? 'finished' : 'active',
|
||||
players: 2,
|
||||
toMove: 1,
|
||||
turnTimeoutSecs: 300,
|
||||
moveCount,
|
||||
endReason: over ? 'standard' : '',
|
||||
lastActivityUnix: 0,
|
||||
seats: [],
|
||||
};
|
||||
}
|
||||
|
||||
function move(player: number): MoveRecord {
|
||||
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
|
||||
}
|
||||
|
||||
function cache(moveCount: number, seat = 0, over = false): CachedGame {
|
||||
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
|
||||
return { view, moves: [] };
|
||||
}
|
||||
|
||||
function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
|
||||
return { move: move(player), game: gameView(moveCount), bagLen };
|
||||
}
|
||||
|
||||
describe('seedInitialState', () => {
|
||||
it('wraps an initial view with an empty journal', () => {
|
||||
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
|
||||
expect(seedInitialState(view)).toEqual({ view, moves: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyMoveDelta', () => {
|
||||
it('ignores a delta for a game not in the cache', () => {
|
||||
expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => {
|
||||
expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it('is a no-op for an already-applied move count (idempotent / own echo)', () => {
|
||||
expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false });
|
||||
expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches on a gap (an intermediate move was missed)', () => {
|
||||
expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it("refetches the actor's own move (the new rack is not in the delta)", () => {
|
||||
// seat 0 is this device; the move's player is 0 -> our own move, our rack changed.
|
||||
expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it("applies an opponent's next move, preserving the rack and appending the move", () => {
|
||||
const before = cache(3, 0);
|
||||
const res = applyMoveDelta(before, delta(4, 1, 45));
|
||||
expect(res.refetch).toBe(false);
|
||||
expect(res.cache?.view.game.moveCount).toBe(4);
|
||||
expect(res.cache?.view.bagLen).toBe(45);
|
||||
expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move
|
||||
expect(res.cache?.view.seat).toBe(0);
|
||||
expect(res.cache?.moves).toHaveLength(1);
|
||||
expect(res.cache?.moves[0].player).toBe(1);
|
||||
expect(before.moves).toHaveLength(0); // input not mutated
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGameOver', () => {
|
||||
it('ignores a finished game not in the cache', () => {
|
||||
expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches a missing final summary only when the game is not already finished', () => {
|
||||
expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true });
|
||||
expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches when the cached board is behind the final move count', () => {
|
||||
expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it('settles the final summary when the board is current', () => {
|
||||
const res = applyGameOver(cache(10), gameView(10, true));
|
||||
expect(res.refetch).toBe(false);
|
||||
expect(res.cache?.view.game.status).toBe('finished');
|
||||
expect(res.cache?.view.game.moveCount).toBe(10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user