Single-word rule indicators + auto-match select redesign #47

Merged
developer merged 2 commits from feature/rule-indicators-newgame into development 2026-06-12 08:45:00 +00:00
29 changed files with 364 additions and 216 deletions
Showing only changes of commit 0b57400c6f - Show all commits
+12 -11
View File
@@ -35,17 +35,18 @@ func gameSummary(g Game, names []string) notify.GameSummary {
last = *g.FinishedAt last = *g.FinishedAt
} }
return notify.GameSummary{ return notify.GameSummary{
ID: g.ID.String(), ID: g.ID.String(),
Variant: g.Variant.String(), Variant: g.Variant.String(),
DictVersion: g.DictVersion, DictVersion: g.DictVersion,
Status: g.Status, Status: g.Status,
Players: g.Players, Players: g.Players,
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MoveCount: g.MoveCount, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
EndReason: g.EndReason, MoveCount: g.MoveCount,
Seats: seats, EndReason: g.EndReason,
LastActivityUnix: last.Unix(), Seats: seats,
LastActivityUnix: last.Unix(),
} }
} }
+12 -11
View File
@@ -159,17 +159,18 @@ func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitat
gameID = inv.GameID.String() gameID = inv.GameID.String()
} }
return notify.InvitationSummary{ return notify.InvitationSummary{
ID: inv.ID.String(), ID: inv.ID.String(),
Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)}, Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)},
Invitees: invitees, Invitees: invitees,
Variant: inv.Settings.Variant.String(), Variant: inv.Settings.Variant.String(),
TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second), TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second),
HintsAllowed: inv.Settings.HintsAllowed, HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer, HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles.String(), MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
Status: inv.Status, DropoutTiles: inv.Settings.DropoutTiles.String(),
GameID: gameID, Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAt.Unix(), GameID: gameID,
ExpiresAtUnix: inv.ExpiresAt.Unix(),
} }
} }
+24 -22
View File
@@ -28,17 +28,18 @@ func toWireGame(g GameSummary) wire.GameView {
} }
} }
return wire.GameView{ return wire.GameView{
ID: g.ID, ID: g.ID,
Variant: g.Variant, Variant: g.Variant,
DictVersion: g.DictVersion, DictVersion: g.DictVersion,
Status: g.Status, Status: g.Status,
Players: g.Players, Players: g.Players,
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: g.TurnTimeoutSecs, TurnTimeoutSecs: g.TurnTimeoutSecs,
MoveCount: g.MoveCount, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
EndReason: g.EndReason, MoveCount: g.MoveCount,
Seats: seats, EndReason: g.EndReason,
LastActivityUnix: g.LastActivityUnix, Seats: seats,
LastActivityUnix: g.LastActivityUnix,
} }
} }
@@ -102,16 +103,17 @@ func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.
} }
} }
return wire.BuildInvitation(b, wire.Invitation{ return wire.BuildInvitation(b, wire.Invitation{
ID: inv.ID, ID: inv.ID,
Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
Invitees: invitees, Invitees: invitees,
Variant: inv.Variant, Variant: inv.Variant,
TurnTimeoutSecs: inv.TurnTimeoutSecs, TurnTimeoutSecs: inv.TurnTimeoutSecs,
HintsAllowed: inv.HintsAllowed, HintsAllowed: inv.HintsAllowed,
HintsPerPlayer: inv.HintsPerPlayer, HintsPerPlayer: inv.HintsPerPlayer,
DropoutTiles: inv.DropoutTiles, MultipleWordsPerTurn: inv.MultipleWordsPerTurn,
Status: inv.Status, DropoutTiles: inv.DropoutTiles,
GameID: inv.GameID, Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAtUnix, GameID: inv.GameID,
ExpiresAtUnix: inv.ExpiresAtUnix,
}) })
} }
+24 -22
View File
@@ -21,17 +21,18 @@ type SeatStanding struct {
// (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current // (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current
// turn's start for an active game, the finish time once finished. // turn's start for an active game, the finish time once finished.
type GameSummary struct { type GameSummary struct {
ID string ID string
Variant string Variant string
DictVersion string DictVersion string
Status string Status string
Players int Players int
ToMove int ToMove int
TurnTimeoutSecs int TurnTimeoutSecs int
MoveCount int MultipleWordsPerTurn bool
EndReason string MoveCount int
Seats []SeatStanding EndReason string
LastActivityUnix int64 Seats []SeatStanding
LastActivityUnix int64
} }
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an // 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 // 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). // the client adds it to its lobby list without a refetch (mirrors scrabblefb.Invitation).
type InvitationSummary struct { type InvitationSummary struct {
ID string ID string
Inviter AccountRef Inviter AccountRef
Invitees []InvitationInvitee Invitees []InvitationInvitee
Variant string Variant string
TurnTimeoutSecs int TurnTimeoutSecs int
HintsAllowed bool HintsAllowed bool
HintsPerPlayer int HintsPerPlayer int
DropoutTiles string MultipleWordsPerTurn bool
Status string DropoutTiles string
GameID string Status string
ExpiresAtUnix int64 GameID string
ExpiresAtUnix int64
} }
+22 -20
View File
@@ -81,15 +81,16 @@ type seatDTO struct {
// gameDTO is the shared game summary. // gameDTO is the shared game summary.
type gameDTO struct { type gameDTO struct {
ID string `json:"id"` ID string `json:"id"`
Variant string `json:"variant"` Variant string `json:"variant"`
DictVersion string `json:"dict_version"` DictVersion string `json:"dict_version"`
Status string `json:"status"` Status string `json:"status"`
Players int `json:"players"` Players int `json:"players"`
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
EndReason string `json:"end_reason"` MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
// LastActivityUnix is the lobby sort key: the current turn's start for an active // LastActivityUnix is the lobby sort key: the current turn's start for an active
// game, the finish time once finished. // game, the finish time once finished.
LastActivityUnix int64 `json:"last_activity_unix"` LastActivityUnix int64 `json:"last_activity_unix"`
@@ -198,17 +199,18 @@ func gameDTOFromGame(g game.Game) gameDTO {
last = *g.FinishedAt last = *g.FinishedAt
} }
return gameDTO{ return gameDTO{
ID: g.ID.String(), ID: g.ID.String(),
Variant: g.Variant.String(), Variant: g.Variant.String(),
DictVersion: g.DictVersion, DictVersion: g.DictVersion,
Status: g.Status, Status: g.Status,
Players: g.Players, Players: g.Players,
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MoveCount: g.MoveCount, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
EndReason: g.EndReason, MoveCount: g.MoveCount,
LastActivityUnix: last.Unix(), EndReason: g.EndReason,
Seats: seats, LastActivityUnix: last.Unix(),
Seats: seats,
} }
} }
+13 -6
View File
@@ -108,7 +108,10 @@ Login uses `Screen`.
longer merely scrolls the board out of view, which used to leave a stale-open state that 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 🏁 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 💬 **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 🤝 **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 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. (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 list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a
guest sees a sign-in prompt. guest sees a sign-in prompt.
- **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept / - **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 Decline for an invitee and a waiting/Cancel state for the inviter; a single-word-rule
**"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move invitation adds a **"One word per turn"** line to the card. Creating a game lives in
time / hints). For a **Russian** variant (auto-match or invite) a **"Multiple words per `NewGame.svelte`: **"Play with friends"** (pick invitees, then variant / move time /
turn"** checkbox (`.toggle`, **default off** = the single-word rule) appears; English hints) and **auto-match** (random opponent). The auto-match variant plaques are
variants never show it. **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 - **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 cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts. numbers, no charts.
+12 -11
View File
@@ -93,17 +93,18 @@ type SeatResp struct {
// GameResp is the shared game summary. // GameResp is the shared game summary.
type GameResp struct { type GameResp struct {
ID string `json:"id"` ID string `json:"id"`
Variant string `json:"variant"` Variant string `json:"variant"`
DictVersion string `json:"dict_version"` DictVersion string `json:"dict_version"`
Status string `json:"status"` Status string `json:"status"`
Players int `json:"players"` Players int `json:"players"`
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
EndReason string `json:"end_reason"` MoveCount int `json:"move_count"`
LastActivityUnix int64 `json:"last_activity_unix"` EndReason string `json:"end_reason"`
Seats []SeatResp `json:"seats"` 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 // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
+12 -11
View File
@@ -66,17 +66,18 @@ type InvitationInviteeResp struct {
// InvitationResp is a friend-game invitation with its settings and invitees. // InvitationResp is a friend-game invitation with its settings and invitees.
type InvitationResp struct { type InvitationResp struct {
ID string `json:"id"` ID string `json:"id"`
Inviter AccountRefResp `json:"inviter"` Inviter AccountRefResp `json:"inviter"`
Invitees []InvitationInviteeResp `json:"invitees"` Invitees []InvitationInviteeResp `json:"invitees"`
Variant string `json:"variant"` Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"` HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"` HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
Status string `json:"status"` DropoutTiles string `json:"dropout_tiles"`
GameID string `json:"game_id"` Status string `json:"status"`
ExpiresAtUnix int64 `json:"expires_at_unix"` GameID string `json:"game_id"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
} }
// InvitationListResp is the caller's open invitations. // InvitationListResp is the caller's open invitations.
+12 -11
View File
@@ -326,17 +326,18 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
} }
} }
return wire.GameView{ return wire.GameView{
ID: g.ID, ID: g.ID,
Variant: g.Variant, Variant: g.Variant,
DictVersion: g.DictVersion, DictVersion: g.DictVersion,
Status: g.Status, Status: g.Status,
Players: g.Players, Players: g.Players,
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: g.TurnTimeoutSecs, TurnTimeoutSecs: g.TurnTimeoutSecs,
MoveCount: g.MoveCount, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
EndReason: g.EndReason, MoveCount: g.MoveCount,
Seats: seats, EndReason: g.EndReason,
LastActivityUnix: g.LastActivityUnix, Seats: seats,
LastActivityUnix: g.LastActivityUnix,
} }
} }
+12 -11
View File
@@ -116,17 +116,18 @@ func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) f
} }
} }
return wire.BuildInvitation(b, wire.Invitation{ return wire.BuildInvitation(b, wire.Invitation{
ID: inv.ID, ID: inv.ID,
Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
Invitees: invitees, Invitees: invitees,
Variant: inv.Variant, Variant: inv.Variant,
TurnTimeoutSecs: inv.TurnTimeoutSecs, TurnTimeoutSecs: inv.TurnTimeoutSecs,
HintsAllowed: inv.HintsAllowed, HintsAllowed: inv.HintsAllowed,
HintsPerPlayer: inv.HintsPerPlayer, HintsPerPlayer: inv.HintsPerPlayer,
DropoutTiles: inv.DropoutTiles, MultipleWordsPerTurn: inv.MultipleWordsPerTurn,
Status: inv.Status, DropoutTiles: inv.DropoutTiles,
GameID: inv.GameID, Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAtUnix, GameID: inv.GameID,
ExpiresAtUnix: inv.ExpiresAtUnix,
}) })
} }
+2
View File
@@ -63,6 +63,7 @@ table GameView {
players:int; players:int;
to_move:int; to_move:int;
turn_timeout_secs:int; turn_timeout_secs:int;
multiple_words_per_turn:bool;
move_count:int; move_count:int;
end_reason:string; end_reason:string;
seats:[SeatView]; seats:[SeatView];
@@ -445,6 +446,7 @@ table Invitation {
turn_timeout_secs:int; turn_timeout_secs:int;
hints_allowed:bool; hints_allowed:bool;
hints_per_player:int; hints_per_player:int;
multiple_words_per_turn:bool;
dropout_tiles:string; dropout_tiles:string;
status:string; status:string;
game_id:string; game_id:string;
+27 -12
View File
@@ -109,8 +109,20 @@ func (rcv *GameView) MutateTurnTimeoutSecs(n int32) bool {
return rcv._tab.MutateInt32Slot(16, n) return rcv._tab.MutateInt32Slot(16, n)
} }
func (rcv *GameView) MoveCount() int32 { func (rcv *GameView) MultipleWordsPerTurn() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) 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 { if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos) return rcv._tab.GetInt32(o + rcv._tab.Pos)
} }
@@ -118,11 +130,11 @@ func (rcv *GameView) MoveCount() int32 {
} }
func (rcv *GameView) MutateMoveCount(n int32) bool { func (rcv *GameView) MutateMoveCount(n int32) bool {
return rcv._tab.MutateInt32Slot(18, n) return rcv._tab.MutateInt32Slot(20, n)
} }
func (rcv *GameView) EndReason() []byte { func (rcv *GameView) EndReason() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) o := flatbuffers.UOffsetT(rcv._tab.Offset(22))
if o != 0 { if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos) 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 { 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 { if o != 0 {
x := rcv._tab.Vector(o) x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4 x += flatbuffers.UOffsetT(j) * 4
@@ -142,7 +154,7 @@ func (rcv *GameView) Seats(obj *SeatView, j int) bool {
} }
func (rcv *GameView) SeatsLength() int { func (rcv *GameView) SeatsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
if o != 0 { if o != 0 {
return rcv._tab.VectorLen(o) return rcv._tab.VectorLen(o)
} }
@@ -150,7 +162,7 @@ func (rcv *GameView) SeatsLength() int {
} }
func (rcv *GameView) LastActivityUnix() int64 { func (rcv *GameView) LastActivityUnix() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) o := flatbuffers.UOffsetT(rcv._tab.Offset(26))
if o != 0 { if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos) return rcv._tab.GetInt64(o + rcv._tab.Pos)
} }
@@ -158,11 +170,11 @@ func (rcv *GameView) LastActivityUnix() int64 {
} }
func (rcv *GameView) MutateLastActivityUnix(n int64) bool { func (rcv *GameView) MutateLastActivityUnix(n int64) bool {
return rcv._tab.MutateInt64Slot(24, n) return rcv._tab.MutateInt64Slot(26, n)
} }
func GameViewStart(builder *flatbuffers.Builder) { func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(11) builder.StartObject(12)
} }
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) 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) { func GameViewAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) {
builder.PrependInt32Slot(6, turnTimeoutSecs, 0) builder.PrependInt32Slot(6, turnTimeoutSecs, 0)
} }
func GameViewAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) {
builder.PrependBoolSlot(7, multipleWordsPerTurn, false)
}
func GameViewAddMoveCount(builder *flatbuffers.Builder, moveCount int32) { 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) { 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) { 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 { func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) return builder.StartVector(4, numElems, 4)
} }
func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) { func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) {
builder.PrependInt64Slot(10, lastActivityUnix, 0) builder.PrependInt64Slot(11, lastActivityUnix, 0)
} }
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
+27 -12
View File
@@ -126,15 +126,19 @@ func (rcv *Invitation) MutateHintsPerPlayer(n int32) bool {
return rcv._tab.MutateInt32Slot(16, n) return rcv._tab.MutateInt32Slot(16, n)
} }
func (rcv *Invitation) DropoutTiles() []byte { func (rcv *Invitation) MultipleWordsPerTurn() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
if o != 0 { 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)) o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
if o != 0 { if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos) return rcv._tab.ByteVector(o + rcv._tab.Pos)
@@ -142,7 +146,7 @@ func (rcv *Invitation) Status() []byte {
return nil return nil
} }
func (rcv *Invitation) GameId() []byte { func (rcv *Invitation) Status() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) o := flatbuffers.UOffsetT(rcv._tab.Offset(22))
if o != 0 { if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos) return rcv._tab.ByteVector(o + rcv._tab.Pos)
@@ -150,8 +154,16 @@ func (rcv *Invitation) GameId() []byte {
return nil return nil
} }
func (rcv *Invitation) ExpiresAtUnix() int64 { func (rcv *Invitation) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) 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 { if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos) return rcv._tab.GetInt64(o + rcv._tab.Pos)
} }
@@ -159,11 +171,11 @@ func (rcv *Invitation) ExpiresAtUnix() int64 {
} }
func (rcv *Invitation) MutateExpiresAtUnix(n int64) bool { func (rcv *Invitation) MutateExpiresAtUnix(n int64) bool {
return rcv._tab.MutateInt64Slot(24, n) return rcv._tab.MutateInt64Slot(26, n)
} }
func InvitationStart(builder *flatbuffers.Builder) { func InvitationStart(builder *flatbuffers.Builder) {
builder.StartObject(11) builder.StartObject(12)
} }
func InvitationAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { func InvitationAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) 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) { func InvitationAddHintsPerPlayer(builder *flatbuffers.Builder, hintsPerPlayer int32) {
builder.PrependInt32Slot(6, hintsPerPlayer, 0) 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) { 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) { 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) { 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) { func InvitationAddExpiresAtUnix(builder *flatbuffers.Builder, expiresAtUnix int64) {
builder.PrependInt64Slot(10, expiresAtUnix, 0) builder.PrependInt64Slot(11, expiresAtUnix, 0)
} }
func InvitationEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func InvitationEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
+26 -22
View File
@@ -30,17 +30,18 @@ type SeatView struct {
// GameView is the shared, non-private game summary. // GameView is the shared, non-private game summary.
type GameView struct { type GameView struct {
ID string ID string
Variant string Variant string
DictVersion string DictVersion string
Status string Status string
Players int Players int
ToMove int ToMove int
TurnTimeoutSecs int TurnTimeoutSecs int
MoveCount int MultipleWordsPerTurn bool
EndReason string MoveCount int
Seats []SeatView EndReason string
LastActivityUnix int64 Seats []SeatView
LastActivityUnix int64
} }
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank // 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. // Invitation is a friend-game invitation with its settings and invitees.
type Invitation struct { type Invitation struct {
ID string ID string
Inviter AccountRef Inviter AccountRef
Invitees []InvitationInvitee Invitees []InvitationInvitee
Variant string Variant string
TurnTimeoutSecs int TurnTimeoutSecs int
HintsAllowed bool HintsAllowed bool
HintsPerPlayer int HintsPerPlayer int
DropoutTiles string MultipleWordsPerTurn bool
Status string DropoutTiles string
GameID string Status string
ExpiresAtUnix int64 GameID string
ExpiresAtUnix int64
} }
// BuildGameView builds a GameView table from g and returns its offset. // 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.GameViewAddPlayers(b, int32(g.Players))
fb.GameViewAddToMove(b, int32(g.ToMove)) fb.GameViewAddToMove(b, int32(g.ToMove))
fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs))
fb.GameViewAddMultipleWordsPerTurn(b, g.MultipleWordsPerTurn)
fb.GameViewAddMoveCount(b, int32(g.MoveCount)) fb.GameViewAddMoveCount(b, int32(g.MoveCount))
fb.GameViewAddEndReason(b, endReason) fb.GameViewAddEndReason(b, endReason)
fb.GameViewAddSeats(b, seats) fb.GameViewAddSeats(b, seats)
@@ -291,6 +294,7 @@ func BuildInvitation(b *flatbuffers.Builder, inv Invitation) flatbuffers.UOffset
fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs))
fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) fb.InvitationAddHintsAllowed(b, inv.HintsAllowed)
fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer))
fb.InvitationAddMultipleWordsPerTurn(b, inv.MultipleWordsPerTurn)
fb.InvitationAddDropoutTiles(b, dropout) fb.InvitationAddDropoutTiles(b, dropout)
fb.InvitationAddStatus(b, status) fb.InvitationAddStatus(b, status)
fb.InvitationAddGameId(b, gameID) fb.InvitationAddGameId(b, gameID)
+23 -4
View File
@@ -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 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.goto('/');
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // auto-match 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'); const toggle = page.getByLabel('Multiple words per turn');
await expect(toggle).toBeVisible(); await expect(toggle).toBeVisible();
await expect(toggle).not.toBeChecked(); 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 }) => { test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => {
+14 -1
View File
@@ -790,6 +790,7 @@
{:else} {:else}
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}> <button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if} 💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
</button> </button>
@@ -857,7 +858,7 @@
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span> <span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
{/if} {/if}
<span class="scores"> <span class="scores">
{#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}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
</span> </span>
</div> </div>
@@ -1103,6 +1104,18 @@
min-width: 64px; min-width: 64px;
text-align: right; 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 { .rack-row {
display: flex; display: flex;
flex: none; flex: none;
+21 -11
View File
@@ -66,35 +66,40 @@ turnTimeoutSecs():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
} }
moveCount():number { multipleWordsPerTurn():boolean {
const offset = this.bb!.__offset(this.bb_pos, 18); 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; return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
} }
endReason():string|null endReason():string|null
endReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null endReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
endReason(optionalEncoding?:any):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; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
seats(index: number, obj?:SeatView):SeatView|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; return offset ? (obj || new SeatView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
} }
seatsLength():number { 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; return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
} }
lastActivityUnix():bigint { 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'); return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
} }
static startGameView(builder:flatbuffers.Builder) { static startGameView(builder:flatbuffers.Builder) {
builder.startObject(11); builder.startObject(12);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -125,16 +130,20 @@ static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) {
builder.addFieldInt32(6, turnTimeoutSecs, 0); builder.addFieldInt32(6, turnTimeoutSecs, 0);
} }
static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) {
builder.addFieldInt8(7, +multipleWordsPerTurn, +false);
}
static addMoveCount(builder:flatbuffers.Builder, moveCount:number) { 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) { 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) { 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 { 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) { 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 { static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
@@ -158,7 +167,7 @@ static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
return 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.startGameView(builder);
GameView.addId(builder, idOffset); GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset); GameView.addVariant(builder, variantOffset);
@@ -167,6 +176,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addPlayers(builder, players); GameView.addPlayers(builder, players);
GameView.addToMove(builder, toMove); GameView.addToMove(builder, toMove);
GameView.addTurnTimeoutSecs(builder, turnTimeoutSecs); GameView.addTurnTimeoutSecs(builder, turnTimeoutSecs);
GameView.addMultipleWordsPerTurn(builder, multipleWordsPerTurn);
GameView.addMoveCount(builder, moveCount); GameView.addMoveCount(builder, moveCount);
GameView.addEndReason(builder, endReasonOffset); GameView.addEndReason(builder, endReasonOffset);
GameView.addSeats(builder, seatsOffset); GameView.addSeats(builder, seatsOffset);
+18 -9
View File
@@ -68,34 +68,39 @@ hintsPerPlayer():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; 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():string|null
dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dropoutTiles(optionalEncoding?:any):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; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
status():string|null status():string|null
status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
status(optionalEncoding?:any):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; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
gameId():string|null gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):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; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
expiresAtUnix():bigint { 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'); return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
} }
static startInvitation(builder:flatbuffers.Builder) { static startInvitation(builder:flatbuffers.Builder) {
builder.startObject(11); builder.startObject(12);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -138,20 +143,24 @@ static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) {
builder.addFieldInt32(6, hintsPerPlayer, 0); 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) { 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) { 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) { 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) { 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 { static endInvitation(builder:flatbuffers.Builder):flatbuffers.Offset {
+3
View File
@@ -238,6 +238,7 @@ function decodeGameView(g: fb.GameView): GameView {
players: g.players(), players: g.players(),
toMove: g.toMove(), toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(), turnTimeoutSecs: g.turnTimeoutSecs(),
multipleWordsPerTurn: g.multipleWordsPerTurn(),
moveCount: g.moveCount(), moveCount: g.moveCount(),
endReason: s(g.endReason()), endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()), lastActivityUnix: Number(g.lastActivityUnix()),
@@ -686,6 +687,7 @@ function decodeInvitationTable(i: fb.Invitation): Invitation {
turnTimeoutSecs: i.turnTimeoutSecs(), turnTimeoutSecs: i.turnTimeoutSecs(),
hintsAllowed: i.hintsAllowed(), hintsAllowed: i.hintsAllowed(),
hintsPerPlayer: i.hintsPerPlayer(), hintsPerPlayer: i.hintsPerPlayer(),
multipleWordsPerTurn: i.multipleWordsPerTurn(),
dropoutTiles: s(i.dropoutTiles()), dropoutTiles: s(i.dropoutTiles()),
status: s(i.status()), status: s(i.status()),
gameId: s(i.gameId()), gameId: s(i.gameId()),
@@ -721,6 +723,7 @@ function emptyGame(): GameView {
players: 0, players: 0,
toMove: 0, toMove: 0,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -12,6 +12,7 @@ function gameView(moveCount: number, over = false): GameView {
players: 2, players: 2,
toMove: 1, toMove: 1,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount, moveCount,
endReason: over ? 'standard' : '', endReason: over ? 'standard' : '',
lastActivityUnix: 0, lastActivityUnix: 0,
+2
View File
@@ -69,6 +69,7 @@ export const en = {
'game.dropGame': 'Drop game', 'game.dropGame': 'Drop game',
'game.previewWords': '{words}: {n}', 'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Not a legal move', 'game.previewIllegal': 'Not a legal move',
'game.oneWordRule': 'One word per turn',
'game.chooseBlank': 'Choose a letter for the blank', 'game.chooseBlank': 'Choose a letter for the blank',
'game.exchangeTitle': 'Select tiles to exchange', 'game.exchangeTitle': 'Select tiles to exchange',
'game.exchangeConfirm': 'Exchange {n}', 'game.exchangeConfirm': 'Exchange {n}',
@@ -233,6 +234,7 @@ export const en = {
'new.moveTime': 'Move time', 'new.moveTime': 'Move time',
'new.hintsPerPlayer': 'Hints per player', 'new.hintsPerPlayer': 'Hints per player',
'new.multipleWordsPerTurn': 'Multiple words per turn', 'new.multipleWordsPerTurn': 'Multiple words per turn',
'new.start': 'Start game',
'new.invited': 'Invitation sent.', 'new.invited': 'Invitation sent.',
'new.noFriends': 'Add friends first to invite them.', 'new.noFriends': 'Add friends first to invite them.',
+2
View File
@@ -70,6 +70,7 @@ export const ru: Record<MessageKey, string> = {
'game.dropGame': 'Покинуть игру', 'game.dropGame': 'Покинуть игру',
'game.previewWords': '{words}: {n}', 'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Недопустимый ход', 'game.previewIllegal': 'Недопустимый ход',
'game.oneWordRule': 'Одно слово за ход',
'game.chooseBlank': 'Выберите букву для бланка', 'game.chooseBlank': 'Выберите букву для бланка',
'game.exchangeTitle': 'Выберите фишки для обмена', 'game.exchangeTitle': 'Выберите фишки для обмена',
'game.exchangeConfirm': 'Обменять {n}', 'game.exchangeConfirm': 'Обменять {n}',
@@ -234,6 +235,7 @@ export const ru: Record<MessageKey, string> = {
'new.moveTime': 'Время на ход', 'new.moveTime': 'Время на ход',
'new.hintsPerPlayer': 'Подсказок на игрока', 'new.hintsPerPlayer': 'Подсказок на игрока',
'new.multipleWordsPerTurn': 'Несколько слов за ход', 'new.multipleWordsPerTurn': 'Несколько слов за ход',
'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.', 'new.invited': 'Приглашение отправлено.',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
+1
View File
@@ -21,6 +21,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
players: 2, players: 2,
toMove, toMove,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix, lastActivityUnix,
+3 -1
View File
@@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient {
} }
// --- lobby --- // --- lobby ---
async lobbyEnqueue(variant: Variant, _multipleWords: boolean): Promise<MatchResult> { async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
// Simulate a 10s-style robot substitution, sped up: match found shortly. // Simulate a 10s-style robot substitution, sped up: match found shortly.
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const g: MockGame = { const g: MockGame = {
@@ -154,6 +154,7 @@ export class MockGateway implements GatewayClient {
players: 2, players: 2,
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: multipleWords,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
@@ -442,6 +443,7 @@ export class MockGateway implements GatewayClient {
turnTimeoutSecs: settings.turnTimeoutSecs, turnTimeoutSecs: settings.turnTimeoutSecs,
hintsAllowed: settings.hintsAllowed, hintsAllowed: settings.hintsAllowed,
hintsPerPlayer: settings.hintsPerPlayer, hintsPerPlayer: settings.hintsPerPlayer,
multipleWordsPerTurn: settings.multipleWordsPerTurn,
dropoutTiles: settings.dropoutTiles, dropoutTiles: settings.dropoutTiles,
status: 'pending', status: 'pending',
gameId: '', gameId: '',
+4
View File
@@ -61,6 +61,7 @@ export function mockInvitations(): Invitation[] {
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
hintsAllowed: true, hintsAllowed: true,
hintsPerPlayer: 1, hintsPerPlayer: 1,
multipleWordsPerTurn: true,
dropoutTiles: 'remove', dropoutTiles: 'remove',
status: 'pending', status: 'pending',
gameId: '', gameId: '',
@@ -141,6 +142,7 @@ function activeGame(): MockGame {
players: 2, players: 2,
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
moveCount: G1_MOVES.length, moveCount: G1_MOVES.length,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
@@ -175,6 +177,7 @@ function finishedG2(): MockGame {
players: 2, players: 2,
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
moveCount: 2, moveCount: 2,
endReason: 'normal', endReason: 'normal',
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
@@ -210,6 +213,7 @@ function finishedG3(): MockGame {
players: 2, players: 2,
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: false,
moveCount: 1, moveCount: 1,
endReason: 'resignation', endReason: 'resignation',
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
+4
View File
@@ -38,6 +38,8 @@ export interface GameView {
players: number; players: number;
toMove: number; toMove: number;
turnTimeoutSecs: number; turnTimeoutSecs: number;
/** true = standard Scrabble; false = the single-word rule (Russian games). */
multipleWordsPerTurn: boolean;
moveCount: number; moveCount: number;
endReason: string; endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ /** 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; turnTimeoutSecs: number;
hintsAllowed: boolean; hintsAllowed: boolean;
hintsPerPlayer: number; hintsPerPlayer: number;
/** true = standard Scrabble; false = the single-word rule (Russian games). */
multipleWordsPerTurn: boolean;
dropoutTiles: string; dropoutTiles: string;
status: string; status: string;
gameId: string; gameId: string;
+1
View File
@@ -20,6 +20,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
players: seats.length, players: seats.length,
toMove, toMove,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -160,6 +160,7 @@
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span> <span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span> <span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span>
{/if} {/if}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span> </span>
<span class="acts"> <span class="acts">
{#if inv.inviter.accountId === myId} {#if inv.inviter.accountId === myId}
+29 -8
View File
@@ -24,7 +24,12 @@
// "Multiple words per turn" off is the single-word rule; it is offered for Russian games // "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. // only (English is always standard and shows no toggle). Shared by both flows.
let multipleWords = $state(false); 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<Variant | ''>('');
$effect(() => {
if (variants.length === 1 && !selectedAuto) selectedAuto = variants[0].id;
});
const timeouts = [ const timeouts = [
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, { secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
{ secs: 1800, key: 'time.minutes' as MessageKey, n: 30 }, { secs: 1800, key: 'time.minutes' as MessageKey, n: 30 },
@@ -182,15 +187,14 @@
{#if mode === 'auto'} {#if mode === 'auto'}
<p class="subtitle">{t('new.subtitle')}</p> <p class="subtitle">{t('new.subtitle')}</p>
{#if autoHasRussian}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<div class="variants"> <div class="variants">
{#each variants as v (v.id)} {#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}> <button
class="variant"
class:selected={selectedAuto === v.id}
onclick={() => (selectedAuto = v.id)}
disabled={!connection.online}
>
<span class="vmain"> <span class="vmain">
<span class="vname">{t(v.label)}</span> <span class="vname">{t(v.label)}</span>
{#if VARIANT_FLAG[v.id]} {#if VARIANT_FLAG[v.id]}
@@ -203,7 +207,18 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if selectedAuto && supportsMultipleWordsToggle(selectedAuto)}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<button
class="invite"
disabled={!selectedAuto || !connection.online}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
{:else if friends.length === 0} {:else if friends.length === 0}
<p class="subtitle">{t('new.noFriends')}</p> <p class="subtitle">{t('new.noFriends')}</p>
{:else} {:else}
@@ -310,6 +325,12 @@
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); 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 { .movelimit {
margin: 0; margin: 0;
text-align: center; text-align: center;