diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 189aa74..dd266bf 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -35,17 +35,18 @@ func gameSummary(g Game, names []string) notify.GameSummary { last = *g.FinishedAt } return notify.GameSummary{ - ID: g.ID.String(), - Variant: g.Variant.String(), - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, - LastActivityUnix: last.Unix(), + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: last.Unix(), } } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index c5e866b..4e53f4d 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -159,17 +159,18 @@ func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitat gameID = inv.GameID.String() } return notify.InvitationSummary{ - ID: inv.ID.String(), - Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)}, - Invitees: invitees, - Variant: inv.Settings.Variant.String(), - TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second), - HintsAllowed: inv.Settings.HintsAllowed, - HintsPerPlayer: inv.Settings.HintsPerPlayer, - DropoutTiles: inv.Settings.DropoutTiles.String(), - Status: inv.Status, - GameID: gameID, - ExpiresAtUnix: inv.ExpiresAt.Unix(), + ID: inv.ID.String(), + Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)}, + Invitees: invitees, + Variant: inv.Settings.Variant.String(), + TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second), + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn, + DropoutTiles: inv.Settings.DropoutTiles.String(), + Status: inv.Status, + GameID: gameID, + ExpiresAtUnix: inv.ExpiresAt.Unix(), } } diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index 47a4fae..ceb3982 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -28,17 +28,18 @@ func toWireGame(g GameSummary) wire.GameView { } } return wire.GameView{ - ID: g.ID, - Variant: g.Variant, - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: g.TurnTimeoutSecs, - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, - LastActivityUnix: g.LastActivityUnix, + ID: g.ID, + Variant: g.Variant, + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: g.TurnTimeoutSecs, + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: g.LastActivityUnix, } } @@ -102,16 +103,17 @@ func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers. } } return wire.BuildInvitation(b, wire.Invitation{ - ID: inv.ID, - Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, - Invitees: invitees, - Variant: inv.Variant, - TurnTimeoutSecs: inv.TurnTimeoutSecs, - HintsAllowed: inv.HintsAllowed, - HintsPerPlayer: inv.HintsPerPlayer, - DropoutTiles: inv.DropoutTiles, - Status: inv.Status, - GameID: inv.GameID, - ExpiresAtUnix: inv.ExpiresAtUnix, + ID: inv.ID, + Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, + Invitees: invitees, + Variant: inv.Variant, + TurnTimeoutSecs: inv.TurnTimeoutSecs, + HintsAllowed: inv.HintsAllowed, + HintsPerPlayer: inv.HintsPerPlayer, + MultipleWordsPerTurn: inv.MultipleWordsPerTurn, + DropoutTiles: inv.DropoutTiles, + Status: inv.Status, + GameID: inv.GameID, + ExpiresAtUnix: inv.ExpiresAtUnix, }) } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index 3177b5a..ce3c68a 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -21,17 +21,18 @@ type SeatStanding struct { // (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current // turn's start for an active game, the finish time once finished. type GameSummary struct { - ID string - Variant string - DictVersion string - Status string - Players int - ToMove int - TurnTimeoutSecs int - MoveCount int - EndReason string - Seats []SeatStanding - LastActivityUnix int64 + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + TurnTimeoutSecs int + MultipleWordsPerTurn bool + MoveCount int + EndReason string + Seats []SeatStanding + LastActivityUnix int64 } // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an @@ -75,15 +76,16 @@ type InvitationInvitee struct { // InvitationSummary is a friend-game invitation carried by the NotifyInvitation event so // the client adds it to its lobby list without a refetch (mirrors scrabblefb.Invitation). type InvitationSummary struct { - ID string - Inviter AccountRef - Invitees []InvitationInvitee - Variant string - TurnTimeoutSecs int - HintsAllowed bool - HintsPerPlayer int - DropoutTiles string - Status string - GameID string - ExpiresAtUnix int64 + ID string + Inviter AccountRef + Invitees []InvitationInvitee + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + MultipleWordsPerTurn bool + DropoutTiles string + Status string + GameID string + ExpiresAtUnix int64 } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 8d225b0..8796720 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -81,15 +81,16 @@ type seatDTO struct { // gameDTO is the shared game summary. type gameDTO struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` // LastActivityUnix is the lobby sort key: the current turn's start for an active // game, the finish time once finished. LastActivityUnix int64 `json:"last_activity_unix"` @@ -198,17 +199,18 @@ func gameDTOFromGame(g game.Game) gameDTO { last = *g.FinishedAt } return gameDTO{ - ID: g.ID.String(), - Variant: g.Variant.String(), - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), - MoveCount: g.MoveCount, - EndReason: g.EndReason, - LastActivityUnix: last.Unix(), - Seats: seats, + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + LastActivityUnix: last.Unix(), + Seats: seats, } } diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 89cc854..435c525 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -108,7 +108,10 @@ Login uses `Screen`. longer merely scrolls the board out of view, which used to leave a stale-open state that made a follow-up plaque tap "jump" the board). The drawer carries its own **header**: a 🏁 **Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬 - (badged with unread chat) at the right, icon-only. Each **opponent**'s card also gains a + (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian + game with "multiple words per turn" off) centres a **"One word per turn"** label between + those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that + yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains a 🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed (see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge. @@ -180,11 +183,15 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a guest sees a sign-in prompt. - **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept / - Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the - **"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move - time / hints). For a **Russian** variant (auto-match or invite) a **"Multiple words per - turn"** checkbox (`.toggle`, **default off** = the single-word rule) appears; English - variants never show it. + Decline for an invitee and a waiting/Cancel state for the inviter; a single-word-rule + invitation adds a **"One word per turn"** line to the card. Creating a game lives in + `NewGame.svelte`: **"Play with friends"** (pick invitees, then variant / move time / + hints) and **auto-match** (random opponent). The auto-match variant plaques are + **mutually-exclusive selects** — a tap **highlights** one (an accent inset border) instead + of starting a game; a lone offered variant is pre-selected, and a bottom **Start game** + button (disabled until a variant is chosen) confirms. For a **Russian** variant (either + flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word + rule) appears once that variant is selected; English variants never show it. - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index aefa8ce..91e421e 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -93,17 +93,18 @@ type SeatResp struct { // GameResp is the shared game summary. type GameResp struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` - LastActivityUnix int64 `json:"last_activity_unix"` - Seats []SeatResp `json:"seats"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` + LastActivityUnix int64 `json:"last_activity_unix"` + Seats []SeatResp `json:"seats"` } // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index c58da38..3d989c4 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -66,17 +66,18 @@ type InvitationInviteeResp struct { // InvitationResp is a friend-game invitation with its settings and invitees. type InvitationResp struct { - ID string `json:"id"` - Inviter AccountRefResp `json:"inviter"` - Invitees []InvitationInviteeResp `json:"invitees"` - Variant string `json:"variant"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - HintsAllowed bool `json:"hints_allowed"` - HintsPerPlayer int `json:"hints_per_player"` - DropoutTiles string `json:"dropout_tiles"` - Status string `json:"status"` - GameID string `json:"game_id"` - ExpiresAtUnix int64 `json:"expires_at_unix"` + ID string `json:"id"` + Inviter AccountRefResp `json:"inviter"` + Invitees []InvitationInviteeResp `json:"invitees"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + DropoutTiles string `json:"dropout_tiles"` + Status string `json:"status"` + GameID string `json:"game_id"` + ExpiresAtUnix int64 `json:"expires_at_unix"` } // InvitationListResp is the caller's open invitations. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index ae90e6b..eb0e7c4 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -326,17 +326,18 @@ func toWireGame(g backendclient.GameResp) wire.GameView { } } return wire.GameView{ - ID: g.ID, - Variant: g.Variant, - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: g.TurnTimeoutSecs, - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, - LastActivityUnix: g.LastActivityUnix, + ID: g.ID, + Variant: g.Variant, + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: g.TurnTimeoutSecs, + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: g.LastActivityUnix, } } diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index eabcbe8..e9fadcc 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -116,17 +116,18 @@ func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) f } } return wire.BuildInvitation(b, wire.Invitation{ - ID: inv.ID, - Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, - Invitees: invitees, - Variant: inv.Variant, - TurnTimeoutSecs: inv.TurnTimeoutSecs, - HintsAllowed: inv.HintsAllowed, - HintsPerPlayer: inv.HintsPerPlayer, - DropoutTiles: inv.DropoutTiles, - Status: inv.Status, - GameID: inv.GameID, - ExpiresAtUnix: inv.ExpiresAtUnix, + ID: inv.ID, + Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, + Invitees: invitees, + Variant: inv.Variant, + TurnTimeoutSecs: inv.TurnTimeoutSecs, + HintsAllowed: inv.HintsAllowed, + HintsPerPlayer: inv.HintsPerPlayer, + MultipleWordsPerTurn: inv.MultipleWordsPerTurn, + DropoutTiles: inv.DropoutTiles, + Status: inv.Status, + GameID: inv.GameID, + ExpiresAtUnix: inv.ExpiresAtUnix, }) } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index f54fdc3..e786445 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -63,6 +63,7 @@ table GameView { players:int; to_move:int; turn_timeout_secs:int; + multiple_words_per_turn:bool; move_count:int; end_reason:string; seats:[SeatView]; @@ -445,6 +446,7 @@ table Invitation { turn_timeout_secs:int; hints_allowed:bool; hints_per_player:int; + multiple_words_per_turn:bool; dropout_tiles:string; status:string; game_id:string; diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 01c977a..89b47c7 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -109,8 +109,20 @@ func (rcv *GameView) MutateTurnTimeoutSecs(n int32) bool { return rcv._tab.MutateInt32Slot(16, n) } -func (rcv *GameView) MoveCount() int32 { +func (rcv *GameView) MultipleWordsPerTurn() bool { o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *GameView) MutateMultipleWordsPerTurn(n bool) bool { + return rcv._tab.MutateBoolSlot(18, n) +} + +func (rcv *GameView) MoveCount() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) if o != 0 { return rcv._tab.GetInt32(o + rcv._tab.Pos) } @@ -118,11 +130,11 @@ func (rcv *GameView) MoveCount() int32 { } func (rcv *GameView) MutateMoveCount(n int32) bool { - return rcv._tab.MutateInt32Slot(18, n) + return rcv._tab.MutateInt32Slot(20, n) } func (rcv *GameView) EndReason() []byte { - o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) } @@ -130,7 +142,7 @@ func (rcv *GameView) EndReason() []byte { } func (rcv *GameView) Seats(obj *SeatView, j int) bool { - o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) if o != 0 { x := rcv._tab.Vector(o) x += flatbuffers.UOffsetT(j) * 4 @@ -142,7 +154,7 @@ func (rcv *GameView) Seats(obj *SeatView, j int) bool { } func (rcv *GameView) SeatsLength() int { - o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) if o != 0 { return rcv._tab.VectorLen(o) } @@ -150,7 +162,7 @@ func (rcv *GameView) SeatsLength() int { } func (rcv *GameView) LastActivityUnix() int64 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) if o != 0 { return rcv._tab.GetInt64(o + rcv._tab.Pos) } @@ -158,11 +170,11 @@ func (rcv *GameView) LastActivityUnix() int64 { } func (rcv *GameView) MutateLastActivityUnix(n int64) bool { - return rcv._tab.MutateInt64Slot(24, n) + return rcv._tab.MutateInt64Slot(26, n) } func GameViewStart(builder *flatbuffers.Builder) { - builder.StartObject(11) + builder.StartObject(12) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -185,20 +197,23 @@ func GameViewAddToMove(builder *flatbuffers.Builder, toMove int32) { func GameViewAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) { builder.PrependInt32Slot(6, turnTimeoutSecs, 0) } +func GameViewAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) { + builder.PrependBoolSlot(7, multipleWordsPerTurn, false) +} func GameViewAddMoveCount(builder *flatbuffers.Builder, moveCount int32) { - builder.PrependInt32Slot(7, moveCount, 0) + builder.PrependInt32Slot(8, moveCount, 0) } func GameViewAddEndReason(builder *flatbuffers.Builder, endReason flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(endReason), 0) + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(endReason), 0) } func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(seats), 0) + builder.PrependUOffsetTSlot(10, flatbuffers.UOffsetT(seats), 0) } func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) { - builder.PrependInt64Slot(10, lastActivityUnix, 0) + builder.PrependInt64Slot(11, lastActivityUnix, 0) } func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() diff --git a/pkg/fbs/scrabblefb/Invitation.go b/pkg/fbs/scrabblefb/Invitation.go index 0f77059..ecee230 100644 --- a/pkg/fbs/scrabblefb/Invitation.go +++ b/pkg/fbs/scrabblefb/Invitation.go @@ -126,15 +126,19 @@ func (rcv *Invitation) MutateHintsPerPlayer(n int32) bool { return rcv._tab.MutateInt32Slot(16, n) } -func (rcv *Invitation) DropoutTiles() []byte { +func (rcv *Invitation) MultipleWordsPerTurn() bool { o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) if o != 0 { - return rcv._tab.ByteVector(o + rcv._tab.Pos) + return rcv._tab.GetBool(o + rcv._tab.Pos) } - return nil + return false } -func (rcv *Invitation) Status() []byte { +func (rcv *Invitation) MutateMultipleWordsPerTurn(n bool) bool { + return rcv._tab.MutateBoolSlot(18, n) +} + +func (rcv *Invitation) DropoutTiles() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) @@ -142,7 +146,7 @@ func (rcv *Invitation) Status() []byte { return nil } -func (rcv *Invitation) GameId() []byte { +func (rcv *Invitation) Status() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) @@ -150,8 +154,16 @@ func (rcv *Invitation) GameId() []byte { return nil } -func (rcv *Invitation) ExpiresAtUnix() int64 { +func (rcv *Invitation) GameId() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Invitation) ExpiresAtUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) if o != 0 { return rcv._tab.GetInt64(o + rcv._tab.Pos) } @@ -159,11 +171,11 @@ func (rcv *Invitation) ExpiresAtUnix() int64 { } func (rcv *Invitation) MutateExpiresAtUnix(n int64) bool { - return rcv._tab.MutateInt64Slot(24, n) + return rcv._tab.MutateInt64Slot(26, n) } func InvitationStart(builder *flatbuffers.Builder) { - builder.StartObject(11) + builder.StartObject(12) } func InvitationAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -189,17 +201,20 @@ func InvitationAddHintsAllowed(builder *flatbuffers.Builder, hintsAllowed bool) func InvitationAddHintsPerPlayer(builder *flatbuffers.Builder, hintsPerPlayer int32) { builder.PrependInt32Slot(6, hintsPerPlayer, 0) } +func InvitationAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) { + builder.PrependBoolSlot(7, multipleWordsPerTurn, false) +} func InvitationAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(dropoutTiles), 0) + builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(dropoutTiles), 0) } func InvitationAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(status), 0) + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(status), 0) } func InvitationAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(gameId), 0) + builder.PrependUOffsetTSlot(10, flatbuffers.UOffsetT(gameId), 0) } func InvitationAddExpiresAtUnix(builder *flatbuffers.Builder, expiresAtUnix int64) { - builder.PrependInt64Slot(10, expiresAtUnix, 0) + builder.PrependInt64Slot(11, expiresAtUnix, 0) } func InvitationEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 8837a49..4ef6834 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -30,17 +30,18 @@ type SeatView struct { // GameView is the shared, non-private game summary. type GameView struct { - ID string - Variant string - DictVersion string - Status string - Players int - ToMove int - TurnTimeoutSecs int - MoveCount int - EndReason string - Seats []SeatView - LastActivityUnix int64 + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + TurnTimeoutSecs int + MultipleWordsPerTurn bool + MoveCount int + EndReason string + Seats []SeatView + LastActivityUnix int64 } // TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank @@ -103,17 +104,18 @@ type InvitationInvitee struct { // Invitation is a friend-game invitation with its settings and invitees. type Invitation struct { - ID string - Inviter AccountRef - Invitees []InvitationInvitee - Variant string - TurnTimeoutSecs int - HintsAllowed bool - HintsPerPlayer int - DropoutTiles string - Status string - GameID string - ExpiresAtUnix int64 + ID string + Inviter AccountRef + Invitees []InvitationInvitee + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + MultipleWordsPerTurn bool + DropoutTiles string + Status string + GameID string + ExpiresAtUnix int64 } // BuildGameView builds a GameView table from g and returns its offset. @@ -151,6 +153,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.GameViewAddPlayers(b, int32(g.Players)) fb.GameViewAddToMove(b, int32(g.ToMove)) fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) + fb.GameViewAddMultipleWordsPerTurn(b, g.MultipleWordsPerTurn) fb.GameViewAddMoveCount(b, int32(g.MoveCount)) fb.GameViewAddEndReason(b, endReason) fb.GameViewAddSeats(b, seats) @@ -291,6 +294,7 @@ func BuildInvitation(b *flatbuffers.Builder, inv Invitation) flatbuffers.UOffset fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) + fb.InvitationAddMultipleWordsPerTurn(b, inv.MultipleWordsPerTurn) fb.InvitationAddDropoutTiles(b, dropout) fb.InvitationAddStatus(b, status) fb.InvitationAddGameId(b, gameID) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index d568e3f..f4ce823 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -70,16 +70,35 @@ test('new game: variant buttons show a rules summary and the move-limit', async await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons }); -test('new game: Russian games offer the "multiple words per turn" toggle, off by default', async ({ page }) => { +test('new game: auto-match selects a variant, then a Russian pick reveals the off-by-default toggle', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /New/ }).click(); // auto-match - // The mock session supports Russian, so the single-word-rule toggle is offered and starts off. + // Several variants are offered, so nothing is selected: Start is disabled and there is no toggle yet. + const start = page.getByRole('button', { name: /Start game/i }); + await expect(start).toBeDisabled(); + await expect(page.getByLabel('Multiple words per turn')).toHaveCount(0); + // Selecting the Russian Scrabble variant highlights it, enables Start, and reveals the rule toggle (off). + await page.locator('.variant', { hasText: 'Скрэббл' }).click(); + await expect(page.locator('.variant.selected')).toHaveCount(1); + await expect(start).toBeEnabled(); const toggle = page.getByLabel('Multiple words per turn'); await expect(toggle).toBeVisible(); await expect(toggle).not.toBeChecked(); - await toggle.check(); - await expect(toggle).toBeChecked(); +}); + +test('single-word game shows the one-word indicator in the status bar and the history header', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + // g3 is a finished Russian single-word game (vs Rick); open it from the lobby. + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await expect(page.locator('.pane')).toHaveCount(1); + // The status bar carries the small "1️⃣" indicator (single-word, nothing pending). + await expect(page.locator('.oneword')).toBeVisible(); + // Tapping the scoreboard opens the history, whose header shows the spelled-out rule label. + await page.locator('.scoreboard').click(); + await expect(page.locator('.oneword-label')).toBeVisible(); }); test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => { diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 6f3d1f4..f94b0e6 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -48,6 +48,7 @@ test('invitations: the lobby shows an invitation and accepting clears it', async await loginLobby(page); await expect(page.getByText('Invitations')).toBeVisible(); await expect(page.getByText(/From Kaya/)).toBeVisible(); + await expect(page.getByText('One word per turn')).toBeVisible(); // the single-word-rule line on the card await page.getByRole('button', { name: /^Accept$/ }).click(); await expect(page.getByText(/From Kaya/)).toBeHidden(); }); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 4f4b292..057f5a9 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -790,6 +790,7 @@ {:else} {/if} + {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} @@ -857,7 +858,7 @@ {isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''} {/if} - {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if} + {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} @@ -1103,6 +1104,18 @@ min-width: 64px; text-align: right; } + .oneword { + font-size: 0.95rem; + } + /* The single-word-rule label centred in the history header between its two icons. */ + .oneword-label { + flex: 1; + text-align: center; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; + } .rack-row { display: flex; flex: none; diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index 61a516b..fc03e8e 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -66,35 +66,40 @@ turnTimeoutSecs():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } -moveCount():number { +multipleWordsPerTurn():boolean { const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +moveCount():number { + const offset = this.bb!.__offset(this.bb_pos, 20); return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } endReason():string|null endReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null endReason(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 20); + const offset = this.bb!.__offset(this.bb_pos, 22); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } seats(index: number, obj?:SeatView):SeatView|null { - const offset = this.bb!.__offset(this.bb_pos, 22); + const offset = this.bb!.__offset(this.bb_pos, 24); return offset ? (obj || new SeatView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; } seatsLength():number { - const offset = this.bb!.__offset(this.bb_pos, 22); + const offset = this.bb!.__offset(this.bb_pos, 24); return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } lastActivityUnix():bigint { - const offset = this.bb!.__offset(this.bb_pos, 24); + const offset = this.bb!.__offset(this.bb_pos, 26); return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); } static startGameView(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -125,16 +130,20 @@ static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) { builder.addFieldInt32(6, turnTimeoutSecs, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(7, +multipleWordsPerTurn, +false); +} + static addMoveCount(builder:flatbuffers.Builder, moveCount:number) { - builder.addFieldInt32(7, moveCount, 0); + builder.addFieldInt32(8, moveCount, 0); } static addEndReason(builder:flatbuffers.Builder, endReasonOffset:flatbuffers.Offset) { - builder.addFieldOffset(8, endReasonOffset, 0); + builder.addFieldOffset(9, endReasonOffset, 0); } static addSeats(builder:flatbuffers.Builder, seatsOffset:flatbuffers.Offset) { - builder.addFieldOffset(9, seatsOffset, 0); + builder.addFieldOffset(10, seatsOffset, 0); } static createSeatsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { @@ -150,7 +159,7 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) { } static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint) { - builder.addFieldInt64(10, lastActivityUnix, BigInt('0')); + builder.addFieldInt64(11, lastActivityUnix, BigInt('0')); } static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { @@ -158,7 +167,7 @@ static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { return offset; } -static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset { +static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset { GameView.startGameView(builder); GameView.addId(builder, idOffset); GameView.addVariant(builder, variantOffset); @@ -167,6 +176,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, GameView.addPlayers(builder, players); GameView.addToMove(builder, toMove); GameView.addTurnTimeoutSecs(builder, turnTimeoutSecs); + GameView.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); GameView.addMoveCount(builder, moveCount); GameView.addEndReason(builder, endReasonOffset); GameView.addSeats(builder, seatsOffset); diff --git a/ui/src/gen/fbs/scrabblefb/invitation.ts b/ui/src/gen/fbs/scrabblefb/invitation.ts index 0634f1e..e114cf4 100644 --- a/ui/src/gen/fbs/scrabblefb/invitation.ts +++ b/ui/src/gen/fbs/scrabblefb/invitation.ts @@ -68,34 +68,39 @@ hintsPerPlayer():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +multipleWordsPerTurn():boolean { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + dropoutTiles():string|null dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null dropoutTiles(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 18); + const offset = this.bb!.__offset(this.bb_pos, 20); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } status():string|null status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null status(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 20); + const offset = this.bb!.__offset(this.bb_pos, 22); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } gameId():string|null gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null gameId(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 22); + const offset = this.bb!.__offset(this.bb_pos, 24); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } expiresAtUnix():bigint { - const offset = this.bb!.__offset(this.bb_pos, 24); + const offset = this.bb!.__offset(this.bb_pos, 26); return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); } static startInvitation(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -138,20 +143,24 @@ static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) { builder.addFieldInt32(6, hintsPerPlayer, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(7, +multipleWordsPerTurn, +false); +} + static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) { - builder.addFieldOffset(7, dropoutTilesOffset, 0); + builder.addFieldOffset(8, dropoutTilesOffset, 0); } static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) { - builder.addFieldOffset(8, statusOffset, 0); + builder.addFieldOffset(9, statusOffset, 0); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { - builder.addFieldOffset(9, gameIdOffset, 0); + builder.addFieldOffset(10, gameIdOffset, 0); } static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) { - builder.addFieldInt64(10, expiresAtUnix, BigInt('0')); + builder.addFieldInt64(11, expiresAtUnix, BigInt('0')); } static endInvitation(builder:flatbuffers.Builder):flatbuffers.Offset { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 01aa273..ecb6454 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -238,6 +238,7 @@ function decodeGameView(g: fb.GameView): GameView { players: g.players(), toMove: g.toMove(), turnTimeoutSecs: g.turnTimeoutSecs(), + multipleWordsPerTurn: g.multipleWordsPerTurn(), moveCount: g.moveCount(), endReason: s(g.endReason()), lastActivityUnix: Number(g.lastActivityUnix()), @@ -686,6 +687,7 @@ function decodeInvitationTable(i: fb.Invitation): Invitation { turnTimeoutSecs: i.turnTimeoutSecs(), hintsAllowed: i.hintsAllowed(), hintsPerPlayer: i.hintsPerPlayer(), + multipleWordsPerTurn: i.multipleWordsPerTurn(), dropoutTiles: s(i.dropoutTiles()), status: s(i.status()), gameId: s(i.gameId()), @@ -721,6 +723,7 @@ function emptyGame(): GameView { players: 0, toMove: 0, turnTimeoutSecs: 0, + multipleWordsPerTurn: true, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 1d3f74a..5e09f7f 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -12,6 +12,7 @@ function gameView(moveCount: number, over = false): GameView { players: 2, toMove: 1, turnTimeoutSecs: 300, + multipleWordsPerTurn: true, moveCount, endReason: over ? 'standard' : '', lastActivityUnix: 0, diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index ee33a01..1199724 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -69,6 +69,7 @@ export const en = { 'game.dropGame': 'Drop game', 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Not a legal move', + 'game.oneWordRule': 'One word per turn', 'game.chooseBlank': 'Choose a letter for the blank', 'game.exchangeTitle': 'Select tiles to exchange', 'game.exchangeConfirm': 'Exchange {n}', @@ -233,6 +234,7 @@ export const en = { 'new.moveTime': 'Move time', 'new.hintsPerPlayer': 'Hints per player', 'new.multipleWordsPerTurn': 'Multiple words per turn', + 'new.start': 'Start game', 'new.invited': 'Invitation sent.', 'new.noFriends': 'Add friends first to invite them.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9dd8980..d4d2b2e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -70,6 +70,7 @@ export const ru: Record = { 'game.dropGame': 'Покинуть игру', 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Недопустимый ход', + 'game.oneWordRule': 'Одно слово за ход', 'game.chooseBlank': 'Выберите букву для бланка', 'game.exchangeTitle': 'Выберите фишки для обмена', 'game.exchangeConfirm': 'Обменять {n}', @@ -234,6 +235,7 @@ export const ru: Record = { 'new.moveTime': 'Время на ход', 'new.hintsPerPlayer': 'Подсказок на игрока', 'new.multipleWordsPerTurn': 'Несколько слов за ход', + 'new.start': 'Начать игру', 'new.invited': 'Приглашение отправлено.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index 3446d6d..962ecbb 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -21,6 +21,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi players: 2, toMove, turnTimeoutSecs: 0, + multipleWordsPerTurn: true, moveCount: 0, endReason: '', lastActivityUnix, diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 3688ee5..f8d5900 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient { } // --- lobby --- - async lobbyEnqueue(variant: Variant, _multipleWords: boolean): Promise { + async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise { // Simulate a 10s-style robot substitution, sped up: match found shortly. const id = crypto.randomUUID(); const g: MockGame = { @@ -154,6 +154,7 @@ export class MockGateway implements GatewayClient { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: multipleWords, moveCount: 0, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000), @@ -442,6 +443,7 @@ export class MockGateway implements GatewayClient { turnTimeoutSecs: settings.turnTimeoutSecs, hintsAllowed: settings.hintsAllowed, hintsPerPlayer: settings.hintsPerPlayer, + multipleWordsPerTurn: settings.multipleWordsPerTurn, dropoutTiles: settings.dropoutTiles, status: 'pending', gameId: '', diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 1cbbd99..602ec13 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -57,10 +57,11 @@ export function mockInvitations(): Invitation[] { id: 'inv1', inviter: { accountId: 'kaya', displayName: 'Kaya' }, invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }], - variant: 'scrabble_en', + variant: 'scrabble_ru', turnTimeoutSecs: 86400, hintsAllowed: true, hintsPerPlayer: 1, + multipleWordsPerTurn: false, dropoutTiles: 'remove', status: 'pending', gameId: '', @@ -141,6 +142,7 @@ function activeGame(): MockGame { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: true, moveCount: G1_MOVES.length, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, @@ -175,6 +177,7 @@ function finishedG2(): MockGame { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: true, moveCount: 2, endReason: 'normal', lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, @@ -210,6 +213,7 @@ function finishedG3(): MockGame { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: false, moveCount: 1, endReason: 'resignation', lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 42544ec..0d7d79f 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -38,6 +38,8 @@ export interface GameView { players: number; toMove: number; turnTimeoutSecs: number; + /** true = standard Scrabble; false = the single-word rule (Russian games). */ + multipleWordsPerTurn: boolean; moveCount: number; endReason: string; /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ @@ -177,6 +179,8 @@ export interface Invitation { turnTimeoutSecs: number; hintsAllowed: boolean; hintsPerPlayer: number; + /** true = standard Scrabble; false = the single-word rule (Russian games). */ + multipleWordsPerTurn: boolean; dropoutTiles: string; status: string; gameId: string; diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index e9b4a43..87d12f2 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -20,6 +20,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView { players: seats.length, toMove, turnTimeoutSecs: 0, + multipleWordsPerTurn: true, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 112e0ee..e8bca00 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -160,6 +160,7 @@ {t('invitations.from', { name: inv.inviter.displayName })} {t(variantKey[inv.variant] ?? 'new.english')} {/if} + {#if !inv.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} {#if inv.inviter.accountId === myId} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index de103c6..25c3737 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -24,7 +24,12 @@ // "Multiple words per turn" off is the single-word rule; it is offered for Russian games // only (English is always standard and shows no toggle). Shared by both flows. let multipleWords = $state(false); - const autoHasRussian = $derived(variants.some((v) => supportsMultipleWordsToggle(v.id))); + // Auto-match: the variant is a select (highlight, no immediate enqueue) confirmed by the + // Start button. A lone offered variant is pre-selected; with several the player must pick. + let selectedAuto = $state(''); + $effect(() => { + if (variants.length === 1 && !selectedAuto) selectedAuto = variants[0].id; + }); const timeouts = [ { secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, { secs: 1800, key: 'time.minutes' as MessageKey, n: 30 }, @@ -182,15 +187,14 @@ {#if mode === 'auto'}

{t('new.subtitle')}

- {#if autoHasRussian} - - {/if}
{#each variants as v (v.id)} - {/each}
+ {#if selectedAuto && supportsMultipleWordsToggle(selectedAuto)} + + {/if}

{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}

+ {:else if friends.length === 0}

{t('new.noFriends')}

{:else} @@ -310,6 +325,12 @@ font-size: 0.8rem; color: var(--text-muted); } + /* Selected auto-match variant: an accent inset border (the button no longer enqueues on + tap; the Start button confirms the choice). */ + .variant.selected { + border-color: var(--accent); + box-shadow: inset 0 0 0 2px var(--accent); + } .movelimit { margin: 0; text-align: center;