5 Commits

Author SHA1 Message Date
Ilia Denisov 356f490546 Stage 17 round 6 (#18, PR D): admin Messages moderation section
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m14s
A new /_gm/messages console page lists posted chat messages (nudges
excluded) newest-first — time, source (guest/robot/oldest identity kind),
sender (linked to the user card), IP, body, game (linked to the game card)
— searchable by sender name / external-id glob masks and pinnable to one
game (?game=) or sender (?user=), linked from the game and user cards.

The list query lives in social (raw SQL, kind='message', source via a SQL
CASE), reusing the now-exported account.LikePattern. Server-rendered
adminconsole MessagesView + messages.gohtml, 50/page via the shared pager.

Tests: adminconsole render case; backend integration AdminListMessages
(real Postgres) — nudge exclusion, game/sender pins, glob masks, source.
Docs: ARCHITECTURE section 8 chat moderation, PLAN round-6.
2026-06-08 20:10:27 +02:00
Ilia Denisov 6b6baf5710 Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Lobby: group the my-games list into your-turn / opponent-turn / finished
(empty sections hidden), ordered by last activity (your-turn oldest-first,
the other two newest-first), as a compact line-separated list. gameDTO and
FB GameView gain last_activity_unix (turn start while active, finish time
once finished); a pure lib/lobbysort.ts holds the grouping/ordering.

Friends: the in-game 'add to friends' item is now server-derived via a new
GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with
a pending OR declined request (both read as 'request sent'), so it is correct
across reloads; it shows a disabled '✓ in friends' once accepted. It
live-updates when the opponent answers: RespondFriendRequest now publishes
friend_added (accept) / friend_declined (new notify sub-kind, decline) to the
original requester, whose open game re-derives its friend state.

Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests;
backend integration ListOutgoingRequests + respond-publishes-to-requester;
e2e updated for the new lobby section labels + a non-friend active opponent.
Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
2026-06-08 19:23:48 +02:00
Ilia Denisov b720907db2 Review fixes #2: bigger flag star, TG header below nav, board-tile relocation
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Addressing the review on #23:
- Flag star scaled up ~25% (the hammer&sickle emblem unchanged, kept clear of it).
- TG fullscreen header: drop the WHOLE header below the content-safe-area top
  inset (the hamburger stays to the right of the title), instead of pinning the
  hamburger to the physical top edge.
- DnD: a placed (pending) tile can now be relocated by dragging it to another
  board cell (board->board); it lifts off its source cell while dragged; and it
  can be grabbed even on the zoomed board (touch-action:none on the pending
  cell, so the drag wins over the board pan). The manual-selection blue frame
  now clears on recall.
2026-06-08 18:23:10 +02:00
Ilia Denisov 34385240b9 Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Backlog item 2 of ~4 (owner review pass):
- USSR flag emblem redrawn (canonical hammer & sickle, scaled down 1.5x
  below the star).
- Touch drag-and-drop: enlarge the drag ghost 1.5x on touch only (the finger
  hides the tile); suppress the iOS tap-highlight that lingered on a rack tile
  sliding into a dragged tile's slot.
- Telegram fullscreen: its native nav no longer hides our header -- the header
  drops below the content-safe-area top inset and the menu (hamburger) lifts
  into the nav band, centred (--tg-content-top from the SDK inset + a
  tg-fullscreen class; new telegram.ts helper + app wiring).

Tests: UI check/test:unit/build + full e2e (60) green. The iOS tap-highlight
fix and the TG-fullscreen layout want on-device verification on the deploy.
2026-06-08 17:11:10 +02:00
Ilia Denisov 3fd279cf8c Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 7s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Owner review-pass rework of the landing page:
- Rename the per-language Telegram link build var
  VITE_TELEGRAM_LINK_EN/_RU -> VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU
  (it carries a channel username; the landing builds https://t.me/<name> --
  the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*).
- Language switcher -> a globe icon dropdown (flags + names), saved + synced
  to the app prefs.
- Theme switcher -> a sun/moon icon toggle, ephemeral (follows the system
  scheme, no auto, never persisted) -- galaxy-game style.
- Drop the "Play in browser" CTA (no standalone-web onboarding yet).

Docs: FUNCTIONAL(+ru), PLAN, deploy + ui READMEs.
2026-06-08 16:40:07 +02:00
66 changed files with 1316 additions and 238 deletions
+2 -2
View File
@@ -267,8 +267,8 @@ jobs:
TELEGRAM_TEST_ENV: "true" TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }} VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }} GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
# Unset vars render empty -> the compose ":-" defaults apply. # Unset vars render empty -> the compose ":-" defaults apply.
+39
View File
@@ -1348,6 +1348,45 @@ provided cert) at the contour caddy; prod VPN; rollback.
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s** timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
the owner's **external network** (the server is sub-ms end-to-end) — not a regression. the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
- **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram
link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries
a channel **username**, the landing builds `https://t.me/<name>`; the connector keeps the matching
`..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app)
and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto").
The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
- **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle,
scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5
(touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a
rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board
cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the
board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram
fullscreen** no longer hides our header under its native nav — the whole header drops below the
content-safe-area top inset (title and the right-aligned menu both clear the nav), via
`--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way
to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our
own header and simply push it clear.)
- **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games
into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last
activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a
compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB
`GameView` gained `last_activity_unix` (the turn start while active, the finish time once
finished). The in-game **"add to friends"** item is now **server-derived** (new `GET
/user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested —
pending **or** declined, which both read as "request sent") so it is correct across reloads, shows
a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers:
`RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify
sub-kind, decline) to the **original requester**, whose open game re-derives its friend state.
Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent
reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
- **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists
posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by
sender name / external-id glob masks and pinnable to one game (`?game=`) or sender (`?user=`),
linked from the game and user cards. Server-rendered (`adminconsole` `MessagesView` +
`messages.gohtml`, 50/page via the shared pager); the list query lives in `social` (raw SQL,
`kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern`
glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the
Users section), a top-level nav entry plus the card deep-links.
## Deferred TODOs (cross-stage) ## Deferred TODOs (cross-stage)
+4 -4
View File
@@ -51,11 +51,11 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
func userListWhere(f UserFilter) (string, []any) { func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots} args := []any{f.Robots}
where := robotExists + ` = $1` where := robotExists + ` = $1`
if name := likePattern(f.NameMask); name != "" { if name := LikePattern(f.NameMask); name != "" {
args = append(args, name) args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
} }
if ext := likePattern(f.ExternalIDMask); ext != "" { if ext := LikePattern(f.ExternalIDMask); ext != "" {
args = append(args, ext) args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
} }
@@ -95,9 +95,9 @@ func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
return n, nil return n, nil
} }
// likePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern, // LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "". // escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
func likePattern(mask string) string { func LikePattern(mask string) string {
mask = strings.TrimSpace(mask) mask = strings.TrimSpace(mask)
if mask == "" { if mask == "" {
return "" return ""
@@ -26,6 +26,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"}, {"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, {"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"}, {"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"}, {"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
@@ -16,6 +16,7 @@
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a> <a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a> <a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a> <a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a> <a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a> <a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a> <a href="/_gm/grafana/">Grafana ↗</a>
@@ -1,7 +1,7 @@
{{define "content" -}} {{define "content" -}}
{{with .Data}} {{with .Data}}
<h1>Game {{.ID}}</h1> <h1>Game {{.ID}}</h1>
<nav class="subnav"><a href="/_gm/games">&laquo; games</a></nav> <nav class="subnav"><a href="/_gm/games">&laquo; games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
<section class="panel"><h2>Summary</h2> <section class="panel"><h2>Summary</h2>
<ul class="kv"> <ul class="kv">
<li><b>Variant</b> {{.Variant}}</li> <li><b>Variant</b> {{.Variant}}</li>
@@ -0,0 +1,37 @@
{{define "content" -}}
<h1>Messages</h1>
{{with .Data}}
<form class="form" method="get" action="/_gm/messages">
{{if .GameID}}<input type="hidden" name="game" value="{{.GameID}}">{{end}}
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<button type="submit">Filter</button>
</form>
{{if or .GameID .UserID}}
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
{{end}}
<table class="list">
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Game</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.CreatedAt}}</td>
<td>{{.Source}}</td>
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
<td>{{.IP}}</td>
<td>{{.Body}}</td>
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -1,7 +1,7 @@
{{define "content" -}} {{define "content" -}}
{{with .Data}} {{with .Data}}
<h1>{{.DisplayName}}</h1> <h1>{{.DisplayName}}</h1>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a></nav> <nav class="subnav"><a href="/_gm/users">&laquo; users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
<div class="cards"> <div class="cards">
<section class="panel"><h2>Account</h2> <section class="panel"><h2>Account</h2>
<ul class="kv"> <ul class="kv">
+26
View File
@@ -73,6 +73,32 @@ type UserRow struct {
MoveMax string MoveMax string
} }
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
// game or user card); FilterQuery is the active filters encoded for the pager links.
type MessagesView struct {
Items []MessageRow
Pager Pager
NameMask string
ExtMask string
GameID string
UserID string
FilterQuery string
}
// MessageRow is one chat message in the moderation list: its sender (linked to the user
// card), source, IP, body, game (linked to the game card) and time.
type MessageRow struct {
ID string
SenderID string
SenderName string
Source string
IP string
Body string
GameID string
CreatedAt string
}
// UserDetailView is one account with its stats, identities and recent games. // UserDetailView is one account with its stats, identities and recent games.
type UserDetailView struct { type UserDetailView struct {
ID string ID string
+169
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -14,9 +15,36 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
fb "scrabble/pkg/fbs/scrabblefb"
) )
// capturePublisher records every published intent for assertions on live events.
type capturePublisher struct {
mu sync.Mutex
intents []notify.Intent
}
func (c *capturePublisher) Publish(in ...notify.Intent) {
c.mu.Lock()
defer c.mu.Unlock()
c.intents = append(c.intents, in...)
}
// notified reports whether a Notification with the given sub-kind was published to user.
func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, in := range c.intents {
if in.UserID == user && in.Kind == notify.KindNotification &&
string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub {
return true
}
}
return false
}
// newSocialService builds a social service over the shared pool, reading game // newSocialService builds a social service over the shared pool, reading game
// state through a real game service. // state through a real game service.
func newSocialService() *social.Service { func newSocialService() *social.Service {
@@ -383,3 +411,144 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err) t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
} }
} }
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
// still "sent"); a lazily expired pending one drops (it may be re-sent).
func TestListOutgoingRequests(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
// Pending: outgoing for the requester, not the addressee.
_, s1 := newGameWithSeats(t, 2)
a, b := s1[0], s1[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b {
t.Fatalf("outgoing pending = %v, want [b]", got)
}
if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 {
t.Fatalf("addressee outgoing = %v, want none", got)
}
// Accepted: a friendship, no longer an outgoing request.
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 {
t.Fatalf("outgoing after accept = %v, want none", got)
}
// Declined: stays outgoing (reads as sent; cannot re-send).
_, s2 := newGameWithSeats(t, 2)
c, d := s2[0], s2[1]
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
t.Fatalf("send2: %v", err)
}
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
t.Fatalf("decline: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d {
t.Fatalf("outgoing after decline = %v, want [d]", got)
}
// Lazily expired pending: omitted (may be re-sent).
_, s3 := newGameWithSeats(t, 2)
e, f := s3[0], s3[1]
if err := svc.SendFriendRequest(ctx, e, f); err != nil {
t.Fatalf("send3: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil {
t.Fatalf("backdate: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 {
t.Fatalf("expired outgoing = %v, want none", got)
}
}
// TestRespondPublishesToRequester checks that answering a request notifies the original
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
// friend_declined, so a game screen watching that opponent re-derives its friend state.
func TestRespondPublishesToRequester(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
_, s1 := newGameWithSeats(t, 2)
a, b := s1[0], s1[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if !pub.notified(a, notify.NotifyFriendAdded) {
t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded)
}
_, s2 := newGameWithSeats(t, 2)
c, d := s2[0], s2[1]
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
t.Fatalf("send2: %v", err)
}
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
t.Fatalf("decline: %v", err)
}
if !pub.notified(c, notify.NotifyFriendDeclined) {
t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined)
}
}
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.9"); err != nil {
t.Fatalf("post: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
t.Fatalf("nudge: %v", err)
}
// Pinned to the game: the message is listed; the nudge (kind=nudge) is excluded.
msgs, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID}, 50, 0)
if err != nil {
t.Fatalf("admin list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("game messages = %d, want 1 (nudge excluded)", len(msgs))
}
if m := msgs[0]; m.Body != "good luck" || m.SenderID != seats[0] || m.SenderIP != "203.0.113.9" {
t.Fatalf("message = %+v, want body=good luck sender=seat0 ip=203.0.113.9", m)
}
if msgs[0].Source != "telegram" { // provisionAccount provisions a telegram identity
t.Errorf("source = %q, want telegram", msgs[0].Source)
}
if n, _ := svc.AdminCountMessages(ctx, social.AdminMessageFilter{GameID: gameID}); n != 1 {
t.Errorf("count = %d, want 1", n)
}
// Sender pin: seat 0 has the message; seat 1 has only a nudge.
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{SenderID: seats[0]}, 50, 0); len(got) == 0 {
t.Error("sender=seat0 returned nothing")
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, SenderID: seats[1]}, 50, 0); len(got) != 0 {
t.Errorf("sender=seat1 has only a nudge, got %d messages", len(got))
}
// Sender glob masks: the telegram external id matches "tg-*"; bogus masks exclude.
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "tg-*"}, 50, 0); len(got) != 1 {
t.Errorf("ext mask tg-* = %d, want 1", len(got))
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "zzz-*"}, 50, 0); len(got) != 0 {
t.Errorf("ext mask zzz-* = %d, want 0", len(got))
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, NameMask: "zzz-no-such-*"}, 50, 0); len(got) != 0 {
t.Errorf("name mask miss = %d, want 0", len(got))
}
}
+2 -2
View File
@@ -85,8 +85,8 @@ func MatchFound(userID, gameID uuid.UUID) Intent {
// Notification is a lightweight "re-poll" signal to userID that a friend request or // Notification is a lightweight "re-poll" signal to userID that a friend request or
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest, // invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to // NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
// scope its refresh. // client may use to scope its refresh.
func Notification(userID uuid.UUID, kind string) Intent { func Notification(userID uuid.UUID, kind string) Intent {
b := flatbuffers.NewBuilder(32) b := flatbuffers.NewBuilder(32)
k := b.CreateString(kind) k := b.CreateString(kind)
+3
View File
@@ -34,6 +34,9 @@ const (
const ( const (
NotifyFriendRequest = "friend_request" NotifyFriendRequest = "friend_request"
NotifyFriendAdded = "friend_added" NotifyFriendAdded = "friend_added"
// NotifyFriendDeclined tells the original requester their request was declined, so a
// game screen watching that opponent re-derives its "add to friends" state.
NotifyFriendDeclined = "friend_declined"
NotifyInvitation = "invitation" NotifyInvitation = "invitation"
NotifyGameStarted = "game_started" NotifyGameStarted = "game_started"
) )
+8
View File
@@ -92,6 +92,9 @@ type gameDTO struct {
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"` MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"` 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 (Stage 17).
LastActivityUnix int64 `json:"last_activity_unix"`
Seats []seatDTO `json:"seats"` Seats []seatDTO `json:"seats"`
} }
@@ -189,6 +192,10 @@ func gameDTOFromGame(g game.Game) gameDTO {
IsWinner: s.IsWinner, IsWinner: s.IsWinner,
}) })
} }
last := g.TurnStartedAt
if g.FinishedAt != nil {
last = *g.FinishedAt
}
return gameDTO{ return gameDTO{
ID: g.ID.String(), ID: g.ID.String(),
Variant: g.Variant.String(), Variant: g.Variant.String(),
@@ -199,6 +206,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
LastActivityUnix: last.Unix(),
Seats: seats, Seats: seats,
} }
} }
+1
View File
@@ -87,6 +87,7 @@ func (s *Server) registerRoutes() {
u.POST("/games/:id/nudge", s.handleNudge) u.POST("/games/:id/nudge", s.handleNudge)
u.GET("/friends", s.handleListFriends) u.GET("/friends", s.handleListFriends)
u.GET("/friends/incoming", s.handleIncomingRequests) u.GET("/friends/incoming", s.handleIncomingRequests)
u.GET("/friends/outgoing", s.handleOutgoingRequests)
u.POST("/friends/request", s.handleFriendRequest) u.POST("/friends/request", s.handleFriendRequest)
u.POST("/friends/respond", s.handleFriendRespond) u.POST("/friends/respond", s.handleFriendRespond)
u.POST("/friends/cancel", s.handleFriendCancel) u.POST("/friends/cancel", s.handleFriendCancel)
@@ -19,6 +19,7 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
) )
// adminPageSize is the page size of the admin console's paginated lists. // adminPageSize is the page size of the admin console's paginated lists.
@@ -51,6 +52,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/complaints", s.consoleComplaints) gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail) gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint) gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
gm.GET("/messages", s.consoleMessages)
gm.GET("/dictionary", s.consoleDictionary) gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/reload", s.consoleReloadDictionary) gm.POST("/dictionary/reload", s.consoleReloadDictionary)
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
@@ -130,6 +132,60 @@ func (s *Server) consoleUsers(c *gin.Context) {
s.renderConsole(c, "users", "users", "Users", view) s.renderConsole(c, "users", "users", "Users", view)
} }
// consoleMessages renders the paginated chat-message moderation list, optionally pinned to
// one game (?game=) or sender (?user=) and filtered by sender glob masks (?name / ?ext).
func (s *Server) consoleMessages(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
}
total, _ := s.social.AdminCountMessages(ctx, filter)
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
if filter.GameID != uuid.Nil {
q.Set("game", filter.GameID.String())
}
if filter.SenderID != uuid.Nil {
q.Set("user", filter.SenderID.String())
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
view := adminconsole.MessagesView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask,
ExtMask: filter.ExtMask,
FilterQuery: q.Encode(),
}
if filter.GameID != uuid.Nil {
view.GameID = filter.GameID.String()
}
if filter.SenderID != uuid.Nil {
view.UserID = filter.SenderID.String()
}
for _, m := range items {
view.Items = append(view.Items, adminconsole.MessageRow{
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
Source: m.Source, IP: m.SenderIP, Body: m.Body,
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt),
})
}
s.renderConsole(c, "messages", "messages", "Messages", view)
}
// consoleUserDetail renders one account with its stats, identities and games. // consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) { func (s *Server) consoleUserDetail(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
@@ -31,6 +31,12 @@ type incomingListDTO struct {
Requests []accountRefDTO `json:"requests"` Requests []accountRefDTO `json:"requests"`
} }
// outgoingListDTO is the addressees the caller has already requested (a live pending
// request or one the addressee declined) and therefore cannot re-request.
type outgoingListDTO struct {
Requests []accountRefDTO `json:"requests"`
}
// friendCodeDTO is a freshly issued one-time friend code (returned once). // friendCodeDTO is a freshly issued one-time friend code (returned once).
type friendCodeDTO struct { type friendCodeDTO struct {
Code string `json:"code"` Code string `json:"code"`
@@ -218,6 +224,22 @@ func (s *Server) handleIncomingRequests(c *gin.Context) {
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)}) c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
} }
// handleOutgoingRequests returns the addressees the caller has already requested
// (pending or declined) and cannot re-request.
func (s *Server) handleOutgoingRequests(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, outgoingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
}
// handleIssueFriendCode issues a one-time add-a-friend code for the caller. // handleIssueFriendCode issues a one-time add-a-friend code for the caller.
func (s *Server) handleIssueFriendCode(c *gin.Context) { func (s *Server) handleIssueFriendCode(c *gin.Context) {
uid, ok := userID(c) uid, ok := userID(c)
+113
View File
@@ -0,0 +1,113 @@
package social
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// AdminMessage is one chat message in the admin moderation list (Stage 17): the message
// plus its sender's resolved display name and source, for the operator console.
type AdminMessage struct {
ID uuid.UUID
GameID uuid.UUID
SenderID uuid.UUID
SenderName string
// Source is the sender's account kind: "guest", "robot", or its oldest identity kind
// (e.g. "email", "telegram"); "—" when it has none.
Source string
Body string
SenderIP string
CreatedAt time.Time
}
// AdminMessageFilter narrows the admin message list. A nil GameID/SenderID leaves that
// field unfiltered; NameMask/ExtMask are glob masks (account.LikePattern) matched
// case-insensitively against the sender's display name / any identity's external id.
type AdminMessageFilter struct {
GameID uuid.UUID
SenderID uuid.UUID
NameMask string
ExtMask string
}
// AdminListMessages returns the filtered chat messages — real messages only, nudges
// excluded — newest first, paginated, for the admin moderation console.
func (svc *Service) AdminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
return svc.store.adminListMessages(ctx, f, limit, offset)
}
// AdminCountMessages counts the filtered chat messages, for the admin list pager.
func (svc *Service) AdminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) {
return svc.store.adminCountMessages(ctx, f)
}
// adminMessageSource is the SQL CASE projecting a sender's source: guest, robot, or its
// oldest identity kind ("—" when it has none).
const adminMessageSource = `CASE
WHEN a.is_guest THEN 'guest'
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
END`
// adminMessageWhere builds the shared WHERE clause and its positional args (from $1).
// Only real messages are listed; nudges are excluded.
func adminMessageWhere(f AdminMessageFilter) (string, []any) {
where := `m.kind = 'message'`
var args []any
if f.GameID != uuid.Nil {
args = append(args, f.GameID)
where += fmt.Sprintf(` AND m.game_id = $%d`, len(args))
}
if f.SenderID != uuid.Nil {
args = append(args, f.SenderID)
where += fmt.Sprintf(` AND m.sender_id = $%d`, len(args))
}
if name := account.LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := account.LikePattern(f.ExtMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
func (s *Store) adminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
where, args := adminMessageWhere(f)
q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at
FROM backend.chat_messages m
JOIN backend.accounts a ON a.account_id = m.sender_id
WHERE ` + where +
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("social: admin list messages: %w", err)
}
defer rows.Close()
var out []AdminMessage
for rows.Next() {
var m AdminMessage
if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("social: scan admin message: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) adminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) {
where, args := adminMessageWhere(f)
var n int
q := `SELECT COUNT(*) FROM backend.chat_messages m JOIN backend.accounts a ON a.account_id = m.sender_id WHERE ` + where
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("social: admin count messages: %w", err)
}
return n, nil
}
+39
View File
@@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
if !ok { if !ok {
return ErrRequestNotFound return ErrRequestNotFound
} }
// Tell the original requester their request was answered, so a game screen watching
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
// -> stays "request sent").
if accept {
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded))
} else {
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined))
}
return nil return nil
} }
@@ -156,6 +164,14 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
} }
// ListOutgoingRequests returns the account IDs the caller has already requested and
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
// permanently declined. The game's "add to friends" item reads it to stay disabled
// across reloads (a declined request reads identically to a still-pending one).
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
}
// loadEdges returns every friendship row between a and b in either direction (at // loadEdges returns every friendship row between a and b in either direction (at
// most one per direction). It feeds SendFriendRequest's re-send classification. // most one per direction). It feeds SendFriendRequest's re-send classification.
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) { func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
@@ -294,6 +310,29 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c
return out, nil return out, nil
} }
// listOutgoingRequests returns the addressees of the caller's requests that block a
// re-send: a live (created after cutoff) pending request, or a permanently declined
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.AddresseeID).
FROM(table.Friendships).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
OR(table.Friendships.Status.EQ(postgres.String(friendPending)).
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list outgoing requests: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.AddresseeID)
}
return out, nil
}
// edgeEither matches a friendship row between a and b in either direction. // edgeEither matches a friendship row between a and b in either direction.
func edgeEither(a, b uuid.UUID) postgres.BoolExpression { func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))). return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
+2 -2
View File
@@ -25,8 +25,8 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) --------------------------- # --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= VITE_TELEGRAM_LINK=
VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot
VITE_TELEGRAM_LINK_RU= # landing "Play in Telegram" link, Russian bot VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
VITE_GATEWAY_URL= VITE_GATEWAY_URL=
# --- Gateway ---------------------------------------------------------------- # --- Gateway ----------------------------------------------------------------
+2 -2
View File
@@ -84,8 +84,8 @@ connector **fails at boot** if both are empty.
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). | | `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). |
| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
The five `VITE_*` are **build-args** baked into the gateway image at build time, so The five `VITE_*` are **build-args** baked into the gateway image at build time, so
+2 -2
View File
@@ -78,8 +78,8 @@ services:
args: args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
+9 -3
View File
@@ -375,7 +375,11 @@ English game the Latin pool.
lightly obfuscated forms) are rejected, since the chat is for quick reactions, lightly obfuscated forms) are rejected, since the chat is for quick reactions,
not contact exchange. Each message stores the sender's IP (forwarded by the not contact exchange. Each message stores the sender's IP (forwarded by the
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post, gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
and messages from a blocked sender are hidden from the viewer. and messages from a blocked sender are hidden from the viewer. The operator console
has a **Messages** section (Stage 17) that lists posted messages (nudges excluded)
newest-first with the sender's resolved name, **source** (guest / robot / oldest
identity kind), IP and game, searchable by sender name / external-id glob masks and
pinnable to one game or sender (linked from the game and user cards).
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting - **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
the opponent may nudge **once per hour per game**; it is not allowed on one's own the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery is wired with the gateway / platform turn. The platform-native delivery is wired with the gateway / platform
@@ -473,8 +477,10 @@ including the mover**, so the mover's own other devices and their lobby refresh
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge** in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **match-found** (from the matchmaker, §8), and **notify** (from the social service), **match-found** (from the matchmaker, §8), and **notify**
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request, (Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, invitation or game-started; emitted on a friend-request and invitation friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
create and on an invitation's game start). Event payloads are FlatBuffers-encoded by on answering one (accept → friend-added, decline → friend-declined — to the original
requester, so a game screen watching that opponent re-derives its "add to friends" state,
Stage 17), and on an invitation create or its game start). Event payloads are FlatBuffers-encoded by
the backend and forwarded verbatim. A client that is not currently streaming falls the backend and forwarded verbatim. A client that is not currently streaming falls
back to the matchmaker's `Poll` for match-found and, for the lobby **notification back to the matchmaker's `Poll` for match-found and, for the lobby **notification
badge** (incoming friend requests + open invitations), the client polls on lobby badge** (incoming friend requests + open invitations), the client polls on lobby
+12 -4
View File
@@ -22,8 +22,9 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
costs nothing when the rack has no legal move. The word-check accepts only the costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats. variant's alphabet, remembers answers within the session and rate-limits repeats.
A public **landing page** at the site root introduces the game, switches language and A public **landing page** at the site root introduces the game, switches language and
theme, and links into the web app or the matching Telegram bot; the game itself runs at theme, and links to the matching per-language Telegram channel; the game itself runs at
`/app/` (web) and `/telegram/` (the Telegram Mini App). `/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
(it follows the system scheme, not the saved preference); its language choice is saved.
### Identity & sessions *(Stage 1 / 6 / 9 / 15)* ### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
A player arrives from a platform (Telegram first), via email login, or as an A player arrives from a platform (Telegram first), via email login, or as an
@@ -57,7 +58,11 @@ account is kept and the guest's games move into it. A merge is blocked only whil
two accounts share a game still in progress. two accounts share a game still in progress.
### Lobby & matchmaking *(Stage 4 / 15)* ### Lobby & matchmaking *(Stage 4 / 15)*
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
orders them so the games awaiting your move come first, the longest-waiting on top, while
opponent-turn and finished games are most-recent first; it renders as a compact,
line-separated list (Stage 17). The game types offered on **New Game** are
limited to the languages the player's sign-in service supports (English → Scrabble; limited to the languages the player's sign-in service supports (English → Scrabble;
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
unrestricted). Variants are shown by their **display name** — both Scrabble variants read unrestricted). Variants are shown by their **display name** — both Scrabble variants read
@@ -110,7 +115,10 @@ digits, valid for twelve hours), or send a **request to someone you have played
with** — they accept, ignore it (a request lapses after thirty days and can then be with** — they accept, ignore it (a request lapses after thirty days and can then be
re-sent), or decline (a decline blocks further requests from you until they hand you re-sent), or decline (a decline blocks further requests from you until they hand you
a code). Cancelling your own pending request withdraws it; unfriending removes the a code). Cancelling your own pending request withdraws it; unfriending removes the
friendship. Block globally — switch off incoming chat friendship. In a game, an **add to friends** item for each opponent mirrors the live
relationship: it reads *request sent* (disabled) while a request is pending or was
declined, and *in friends* once accepted — updating in place the moment the opponent
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
and/or friend requests — and block individual players (a per-user block hides that and/or friend requests — and block individual players (a per-user block hides that
person's chat and stops requests and game invitations both ways; it also ends any person's chat and stops requests and game invitations both ways; it also ends any
existing friendship). Per-game chat is for quick reactions: messages are short existing friendship). Per-game chat is for quick reactions: messages are short
+12 -4
View File
@@ -23,8 +23,9 @@ top-1 подсказку, безлимитную проверку слова с
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов. и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
адресам `/app/` (веб) и `/telegram/` (Telegram Mini App). `/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется.
### Личность и сессии *(Stage 1 / 6 / 9 / 15)* ### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как Игрок приходит с платформы (сначала Telegram), через email-вход или как
@@ -58,7 +59,11 @@ Mini App** авторизует по подписанным `initData` плат
запрещено, только пока у аккаунтов есть общая незавершённая игра. запрещено, только пока у аккаунтов есть общая незавершённая игра.
### Лобби и подбор *(Stage 4 / 15)* ### Лобби и подбор *(Stage 4 / 15)*
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра** Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
@@ -112,7 +117,10 @@ Mini App** авторизует по подписанным `initData` плат
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
чат и/или заявки — чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
+4 -4
View File
@@ -20,14 +20,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev"). # VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK= ARG VITE_TELEGRAM_LINK=
ARG VITE_TELEGRAM_LINK_EN= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
ARG VITE_TELEGRAM_LINK_RU= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
ARG VITE_GATEWAY_URL= ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION= ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \ VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \ VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION VITE_APP_VERSION=$VITE_APP_VERSION
+1
View File
@@ -102,6 +102,7 @@ type GameResp struct {
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"` MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"` EndReason string `json:"end_reason"`
LastActivityUnix int64 `json:"last_activity_unix"`
Seats []SeatResp `json:"seats"` Seats []SeatResp `json:"seats"`
} }
@@ -25,6 +25,12 @@ type IncomingListResp struct {
Requests []AccountRefResp `json:"requests"` Requests []AccountRefResp `json:"requests"`
} }
// OutgoingListResp is the addressees the caller has already requested (a live pending
// request or one the addressee declined) and cannot re-request.
type OutgoingListResp struct {
Requests []AccountRefResp `json:"requests"`
}
// FriendCodeResp is a freshly issued one-time friend code. // FriendCodeResp is a freshly issued one-time friend code.
type FriendCodeResp struct { type FriendCodeResp struct {
Code string `json:"code"` Code string `json:"code"`
@@ -134,6 +140,14 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR
return out, err return out, err
} }
// ListOutgoing returns the addressees the caller has already requested (pending or
// declined) and cannot re-request.
func (c *Client) ListOutgoing(ctx context.Context, userID string) (OutgoingListResp, error) {
var out OutgoingListResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/outgoing", userID, "", nil, &out)
return out, err
}
// IssueFriendCode issues a one-time friend code for the caller. // IssueFriendCode issues a one-time friend code for the caller.
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) { func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
var out FriendCodeResp var out FriendCodeResp
+1
View File
@@ -357,6 +357,7 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
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)
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
return fb.GameViewEnd(b) return fb.GameViewEnd(b)
} }
@@ -54,6 +54,16 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte {
return b.FinishedBytes() return b.FinishedBytes()
} }
// encodeOutgoingList builds an OutgoingRequestList payload.
func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector)
fb.OutgoingRequestListStart(b)
fb.OutgoingRequestListAddRequests(b, v)
b.Finish(fb.OutgoingRequestListEnd(b))
return b.FinishedBytes()
}
// encodeBlockList builds a BlockList payload. // encodeBlockList builds a BlockList payload.
func encodeBlockList(r backendclient.BlockListResp) []byte { func encodeBlockList(r backendclient.BlockListResp) []byte {
b := flatbuffers.NewBuilder(256) b := flatbuffers.NewBuilder(256)
@@ -13,6 +13,7 @@ import (
const ( const (
MsgFriendsList = "friends.list" MsgFriendsList = "friends.list"
MsgFriendsIncoming = "friends.incoming" MsgFriendsIncoming = "friends.incoming"
MsgFriendsOutgoing = "friends.outgoing"
MsgFriendRequest = "friends.request" MsgFriendRequest = "friends.request"
MsgFriendRespond = "friends.respond" MsgFriendRespond = "friends.respond"
MsgFriendCancel = "friends.cancel" MsgFriendCancel = "friends.cancel"
@@ -37,6 +38,7 @@ const (
func registerStage8(r *Registry, backend *backendclient.Client) { func registerStage8(r *Registry, backend *backendclient.Client) {
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true} r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true} r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true}
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true} r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true} r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true} r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
@@ -78,6 +80,16 @@ func friendsIncomingHandler(backend *backendclient.Client) Handler {
} }
} }
func friendsOutgoingHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListOutgoing(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeOutgoingList(res), nil
}
}
func friendRequestHandler(backend *backendclient.Client) Handler { func friendRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0) in := fb.GetRootAsTargetRequest(req.Payload, 0)
@@ -54,6 +54,35 @@ func TestFriendsListRoundTripDecodesNames(t *testing.T) {
} }
} }
func TestFriendsOutgoingRoundTrip(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/friends/outgoing" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}]}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, ok := reg.Lookup(transcode.MsgFriendsOutgoing)
if !ok {
t.Fatal("friends.outgoing not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
ol := fb.GetRootAsOutgoingRequestList(payload, 0)
if ol.RequestsLength() != 1 {
t.Fatalf("outgoing length = %d, want 1", ol.RequestsLength())
}
var ref fb.AccountRef
ol.Requests(&ref, 0)
if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" {
t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName())
}
}
func TestFriendRequestForwardsTarget(t *testing.T) { func TestFriendRequestForwardsTarget(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-User-ID"); got != "u-1" { if got := r.Header.Get("X-User-ID"); got != "u-1" {
+4 -1
View File
@@ -158,7 +158,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if r.URL.Path != "/api/v1/user/games" { if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path) t.Errorf("unexpected path %q", r.URL.Path)
} }
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
}) })
defer cleanup() defer cleanup()
@@ -177,6 +177,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if string(g.Id()) != "g-1" { if string(g.Id()) != "g-1" {
t.Errorf("game id = %q, want g-1", g.Id()) t.Errorf("game id = %q, want g-1", g.Id())
} }
if g.LastActivityUnix() != 1717000000 {
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
}
var seat fb.SeatView var seat fb.SeatView
g.Seats(&seat, 1) g.Seats(&seat, 1)
if string(seat.DisplayName()) != "Ann" { if string(seat.DisplayName()) != "Ann" {
+13 -2
View File
@@ -66,6 +66,9 @@ table GameView {
move_count:int; move_count:int;
end_reason:string; end_reason:string;
seats:[SeatView]; seats:[SeatView];
// last_activity_unix is the lobby sort key: the current turn's start for an active
// game, the finish time for a finished one (Stage 17).
last_activity_unix:long;
} }
// MoveRecord is one decoded move (a committed play, or a hint preview). // MoveRecord is one decoded move (a committed play, or a hint preview).
@@ -389,6 +392,13 @@ table IncomingRequestList {
requests:[AccountRef]; requests:[AccountRef];
} }
// OutgoingRequestList is the accounts the caller has already requested and cannot
// (re-)request: a live pending request or one the addressee declined. The game's
// "add to friends" item reads it to stay disabled across reloads (Stage 17).
table OutgoingRequestList {
requests:[AccountRef];
}
// FriendCode is a freshly issued one-time add-a-friend code (returned once). // FriendCode is a freshly issued one-time add-a-friend code (returned once).
table FriendCode { table FriendCode {
code:string; code:string;
@@ -492,8 +502,9 @@ table MatchFoundEvent {
// NotificationEvent is a lightweight "something changed, re-poll" signal that // NotificationEvent is a lightweight "something changed, re-poll" signal that
// drives the lobby badge (incoming friend requests, invitations). kind is a sub- // drives the lobby badge (incoming friend requests, invitations). kind is a sub-
// discriminator ("friend_request", "friend_added", "invitation", "game_started"); // discriminator ("friend_request", "friend_added", "friend_declined", "invitation",
// the client re-fetches its lobby counters on any of them. // "game_started"); the client re-fetches its lobby counters (and, for a requester
// watching a game, its friend state) on any of them.
table NotificationEvent { table NotificationEvent {
kind:string; kind:string;
} }
+16 -1
View File
@@ -149,8 +149,20 @@ func (rcv *GameView) SeatsLength() int {
return 0 return 0
} }
func (rcv *GameView) LastActivityUnix() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *GameView) MutateLastActivityUnix(n int64) bool {
return rcv._tab.MutateInt64Slot(24, n)
}
func GameViewStart(builder *flatbuffers.Builder) { func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(10) builder.StartObject(11)
} }
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,6 +197,9 @@ func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT)
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) {
builder.PrependInt64Slot(10, lastActivityUnix, 0)
}
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type OutgoingRequestList struct {
_tab flatbuffers.Table
}
func GetRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &OutgoingRequestList{}
x.Init(buf, n+offset)
return x
}
func FinishOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &OutgoingRequestList{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *OutgoingRequestList) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *OutgoingRequestList) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *OutgoingRequestList) Requests(obj *AccountRef, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *OutgoingRequestList) RequestsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func OutgoingRequestListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0)
}
func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func OutgoingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+1 -1
View File
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11) gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
are the per-language "Play in Telegram" links shown on the landing page (Stage 17). are the per-language "Play in Telegram" links shown on the landing page (Stage 17).
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
+14 -7
View File
@@ -1,14 +1,21 @@
import { expect, test } from './fixtures'; import { expect, test } from './fixtures';
// The landing page is a separate Vite entry (landing.html), served at "/" in production while // The landing page is a separate Vite entry (landing.html), served at "/" in production while
// the game SPA moves to /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html. // the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => { test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
await page.goto('/landing.html'); await page.goto('/landing.html');
// The primary call to action opens the web app mount. // The tagline renders (English in the default test browser).
await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/'); await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
// The language switch flips the copy to Russian (reusing the app i18n). // The language dropdown switches the copy to Russian.
await page.getByRole('button', { name: 'Русский' }).click(); await page.getByRole('button', { name: 'Language' }).click();
await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible(); await page.getByRole('menuitem', { name: /Русский/ }).click();
await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible();
// The theme toggle flips the document theme (ephemeral, light<->dark).
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
await page.getByRole('button', { name: 'Theme' }).click();
const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
expect(after).not.toBe(before);
}); });
+1 -1
View File
@@ -8,7 +8,7 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Active games')).toBeVisible(); await expect(page.getByText('Your turn')).toBeVisible();
const activeRow = page.getByRole('button', { name: /Ann/ }); const activeRow = page.getByRole('button', { name: /Ann/ });
await expect(activeRow).toBeVisible(); await expect(activeRow).toBeVisible();
await activeRow.click(); await activeRow.click();
+2 -2
View File
@@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures';
async function loginLobby(page: Page): Promise<void> { async function loginLobby(page: Page): Promise<void> {
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Active games')).toBeVisible(); await expect(page.getByText('Your turn')).toBeVisible();
} }
async function openFriends(page: Page): Promise<void> { async function openFriends(page: Page): Promise<void> {
@@ -107,7 +107,7 @@ test('play with friends: a game type is required to send an invitation', async (
await expect(send).toBeEnabled(); await expect(send).toBeEnabled();
await send.click(); // the mock creates it and returns to the lobby await send.click(); // the mock creates it and returns to the lobby
await expect(page.getByText('Active games')).toBeVisible(); await expect(page.getByText('Your turn')).toBeVisible();
}); });
test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => { test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => {
+1 -1
View File
@@ -27,7 +27,7 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
await page.goto('/'); await page.goto('/');
// No guest-login click: the Mini App authenticates from initData and lands on the lobby. // No guest-login click: the Mini App authenticates from initData and lands on the lobby.
await expect(page.getByText('Active games')).toBeVisible(); await expect(page.getByText('Your turn')).toBeVisible();
// The Telegram themeParams override the background token at runtime. // The Telegram themeParams override the background token at runtime.
await expect await expect
+12 -10
View File
@@ -1,14 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР">
<rect width="24" height="16" fill="#cc0000"/> <rect width="24" height="16" fill="#cc0000"/>
<!-- five-pointed star (filled, slightly smaller) --> <!-- five-pointed star (scaled up ~25% around its centre per review) -->
<path fill="#ffd700" d="M6 2.4l.78 1.6 1.76.26-1.27 1.24.3 1.75L6 6.63l-1.57.82.3-1.75L3.46 4.5l1.76-.26z"/> <path fill="#ffd700" transform="translate(6 3.17) scale(1.25) translate(-6 -3.17)" d="M6 1.9 L6.32 2.86 7.33 2.87 6.51 3.47 6.82 4.43 6 3.84 5.18 4.43 5.49 3.47 4.67 2.87 5.68 2.86 Z"/>
<!-- schematic hammer & sickle (a sketch, thin strokes) --> <g fill="none" stroke="#ffd700" stroke-linecap="round" stroke-linejoin="round" transform="translate(6.8 6) scale(0.667) translate(-6.8 -6)">
<g fill="none" stroke="#ffd700" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"> <!-- sickle: a crescent blade + short handle, mirrored across a diagonal through its centre
<!-- sickle: an elongated semicircle blade with a short handle --> so it reads as the canonical sickle (blade sweeping down-right); the hammer is untouched -->
<path d="M8.2 7.4a3 3 0 1 1-3.3 3.9"/> <g transform="matrix(0 1 1 0 -2.8 2.8)">
<path d="M4.9 11.3l-.8.7"/> <path stroke-width="0.6" d="M8.1 6.0 C 10.7 6.9 10.9 11.3 7.2 13.3 C 5.1 14.5 2.9 13.2 2.7 10.9"/>
<!-- hammer: a T-shape (handle + head) crossing the sickle --> <path stroke-width="0.6" d="M8.1 6.0 l 0.85 -0.95"/>
<path d="M5.1 11 8.1 8"/> </g>
<path d="M7.2 7.1 9 8.9"/> <!-- hammer: handle (down-right) + head (a short bar) at ~90°, crossing the sickle -->
<path stroke-width="0.78" d="M4.6 8.4 L 8.4 12.9"/>
<path stroke-width="0.78" d="M3.25 9.05 L 5.95 7.05"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 1.2 KiB

+96 -82
View File
@@ -1,89 +1,84 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme'; import { applyTheme } from './lib/theme';
import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } from './lib/i18n/index.svelte'; import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
import { loadPrefs, savePrefs, type Prefs } from './lib/session'; import { loadPrefs, savePrefs, type Prefs } from './lib/session';
import { aboutContent } from './lib/aboutContent'; import { aboutContent } from './lib/aboutContent';
import { telegramBotLink } from './lib/landing'; import { telegramChannelLink } from './lib/landing';
// Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA // Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and
// (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but // /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
// not the app store — so it stays light (no gateway, auth or live stream). // stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
const themes: ThemePref[] = ['auto', 'light', 'dark']; let theme = $state<'light' | 'dark'>('light');
const themeLabel: Record<ThemePref, MessageKey> = { let langOpen = $state(false);
auto: 'settings.themeAuto',
light: 'settings.themeLight',
dark: 'settings.themeDark',
};
const locales: Locale[] = ['en', 'ru'];
let theme = $state<ThemePref>('auto');
let prefs: Partial<Prefs> = {}; let prefs: Partial<Prefs> = {};
// The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h). const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
const about = $derived(aboutContent(i18n.locale, 24)); const tgLink = $derived(telegramChannelLink(i18n.locale));
const tgLink = $derived(telegramBotLink(i18n.locale)); const locales: { code: Locale; label: string }[] = [
{ code: 'en', label: '🇬🇧 English' },
{ code: 'ru', label: '🇷🇺 Русский' },
];
function systemTheme(): 'light' | 'dark' {
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
onMount(async () => { onMount(async () => {
prefs = await loadPrefs(); prefs = await loadPrefs();
theme = prefs.theme ?? 'auto'; theme = systemTheme();
applyTheme(theme); applyTheme(theme);
applyReduceMotion(prefs.reduceMotion ?? false);
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en')); setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
}); });
function persist(): void { function toggleTheme(): void {
// savePrefs takes the full set, so keep the labels/lines the app may have stored. theme = theme === 'light' ? 'dark' : 'light';
applyTheme(theme); // ephemeral — deliberately not persisted
}
function chooseLocale(lc: Locale): void {
setLocale(lc);
langOpen = false;
// Persist the language only, keeping the app's other prefs (notably its own persisted theme).
void savePrefs({ void savePrefs({
theme, theme: prefs.theme ?? 'auto',
locale: i18n.locale, locale: lc,
reduceMotion: prefs.reduceMotion ?? false, reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner', boardLabels: prefs.boardLabels ?? 'beginner',
boardLines: prefs.boardLines ?? false, boardLines: prefs.boardLines ?? false,
}); });
} prefs = { ...prefs, locale: lc };
function chooseTheme(th: ThemePref): void {
theme = th;
applyTheme(th);
persist();
}
function chooseLocale(lc: Locale): void {
setLocale(lc);
persist();
} }
</script> </script>
<main class="landing"> <main class="landing">
<header class="bar"> <header class="bar">
<div class="seg"> <div class="lang">
{#each locales as lc (lc)} <button class="icon" aria-label="Language" aria-expanded={langOpen} onclick={() => (langOpen = !langOpen)}>🌐</button>
<button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}> {#if langOpen}
{t(lc === 'en' ? 'lang.en' : 'lang.ru')} <!-- svelte-ignore a11y_consider_explicit_label -->
</button> <button class="backdrop" onclick={() => (langOpen = false)}></button>
<div class="menu" role="menu">
{#each locales as l (l.code)}
<button role="menuitem" class:on={i18n.locale === l.code} onclick={() => chooseLocale(l.code)}>{l.label}</button>
{/each} {/each}
</div> </div>
<div class="seg"> {/if}
{#each themes as th (th)}
<button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div> </div>
<button class="icon" aria-label="Theme" onclick={toggleTheme}>{theme === 'light' ? '☼' : '☾'}</button>
</header> </header>
<section class="hero"> <section class="hero">
<h1>{about.title}</h1> <h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p> <p class="tagline">{t('landing.tagline')}</p>
<div class="cta">
<a class="play primary" href="/app/">{t('landing.playWeb')}</a>
{#if tgLink} {#if tgLink}
<a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer"> <a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
<img src="telegram-logo.svg" alt="" width="22" height="22" /> <img src="telegram-logo.svg" alt="" width="22" height="22" />
{t('landing.playTelegram')} {t('landing.playTelegram')}
</a> </a>
{/if} {/if}
</div>
</section> </section>
<section class="info"> <section class="info">
@@ -125,32 +120,65 @@
.bar { .bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 10px; align-items: center;
flex-wrap: wrap;
} }
.seg { .lang {
display: flex; position: relative;
gap: 6px;
} }
.opt { .icon {
padding: 7px 12px; min-width: 40px;
border: 1px solid var(--border); min-height: 40px;
background: var(--surface); display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
background: transparent;
color: var(--text); color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
user-select: none; cursor: pointer;
font-size: 0.85rem;
} }
.opt.active { .backdrop {
background: var(--accent); position: fixed;
color: var(--accent-text); inset: 0;
border-color: var(--accent); z-index: 8;
background: none;
border: none;
}
.menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 150px;
overflow: hidden;
}
.menu button {
text-align: left;
padding: 10px 14px;
background: none;
border: none;
color: var(--text);
white-space: nowrap;
}
.menu button:hover {
background: var(--surface-2);
}
.menu button.on {
color: var(--accent);
font-weight: 600;
} }
.hero { .hero {
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 16px;
padding: 24px 0 8px; padding: 24px 0 8px;
} }
.hero h1 { .hero h1 {
@@ -164,33 +192,20 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 1.05rem; font-size: 1.05rem;
} }
.cta {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 6px;
}
.play { .play {
align-self: center;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
padding: 12px 22px; padding: 12px 24px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
border: 1px solid var(--border);
}
.play.primary {
background: var(--accent); background: var(--accent);
color: var(--accent-text); color: var(--accent-text);
border-color: var(--accent); margin-top: 6px;
} }
.play.tg { .play img {
background: var(--surface);
color: var(--text);
}
.play.tg img {
display: block; display: block;
} }
.info { .info {
@@ -225,7 +240,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
color: var(--text);
} }
.ft { .ft {
margin-top: auto; margin-top: auto;
+3
View File
@@ -41,6 +41,9 @@
--radius-sm: 6px; --radius-sm: 6px;
--gap: 8px; --gap: 8px;
--pad: 12px; --pad: 12px;
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
content-safe-area inset (Stage 17), 0 elsewhere. */
--tg-content-top: 0px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif; "Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06); --shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
+7
View File
@@ -89,4 +89,11 @@
transform: rotate(45deg); transform: rotate(45deg);
margin-left: 3px; margin-left: 3px;
} }
/* Telegram fullscreen: its native nav overlays the top of the viewport (height
--tg-content-top, set from the content-safe-area inset). Drop the header content below the
nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the
corners, leaving the centre clear (Stage 17). */
:global(html.tg-fullscreen) .bar {
padding-top: var(--tg-content-top);
}
</style> </style>
+3
View File
@@ -277,6 +277,9 @@
} }
.cell.pending { .cell.pending {
background: var(--tile-pending); background: var(--tile-pending);
/* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed
board) instead of the touch starting a board pan (Stage 17). */
touch-action: none;
} }
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
+69 -11
View File
@@ -60,7 +60,7 @@
let checkResult = $state<{ word: string; legal: boolean } | null>(null); let checkResult = $state<{ word: string; legal: boolean } | null>(null);
let resignOpen = $state(false); let resignOpen = $state(false);
let messages = $state<ChatMessage[]>([]); let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null); let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
const checkedWords = new Map<string, boolean>(); const checkedWords = new Map<string, boolean>();
let cooling = $state(false); let cooling = $state(false);
@@ -70,7 +70,11 @@
const premium = $derived(premiumGrid(variant)); const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant)); const ctr = $derived(centre(variant));
const pendingMap = $derived( const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])), new Map(
placement.pending
.filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col))
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
),
); );
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null); const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
// Highlight the last word with a dark tile bg; while placing, only the pending tiles // Highlight the last word with a dark tile bg; while placing, only the pending tiles
@@ -185,6 +189,7 @@
rackIds = cached.view.rack.map((_, i) => i); rackIds = cached.view.rack.map((_, i) => i);
} }
void load(); void load();
void loadFriends();
}); });
$effect(() => { $effect(() => {
@@ -197,6 +202,9 @@
} else if (e.kind === 'your_turn' && e.gameId === id) void load(); } else if (e.kind === 'your_turn' && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
// A request the player sent was answered (accepted -> now friends; declined -> stays
// "request sent"): re-derive the in-game friend state.
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
}); });
// Tick the nudge cooldown while the chat is open so the control re-enables on time. // Tick the nudge cooldown while the chat is open so the control re-enables on time.
@@ -228,6 +236,9 @@
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation. // (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
let reorderDragId = $state<number | null>(null); let reorderDragId = $state<number | null>(null);
let reorderTo = $state<number | null>(null); let reorderTo = $state<number | null>(null);
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
// hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17).
let draggingPend = $state<{ row: number; col: number } | null>(null);
let dragPointerId = -1; let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) { function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -261,10 +272,10 @@
if (busy || gameOver) return; if (busy || gameOver) return;
beginDrag({ from: 'rack', index }, e); beginDrag({ from: 'rack', index }, e);
} }
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when // A placed (pending) tile can be dragged to relocate it on the board or back to the rack —
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap. // works zoomed too (the tile has touch-action:none, so its drag wins over the board pan).
function onBoardDown(e: PointerEvent, row: number, col: number) { function onBoardDown(e: PointerEvent, row: number, col: number) {
if (busy || zoomed || gameOver) return; if (busy || gameOver) return;
beginDrag({ from: 'board', row, col }, e); beginDrag({ from: 'board', row, col }, e);
} }
function cellUnder(x: number, y: number): { row: number; col: number } | null { function cellUnder(x: number, y: number): { row: number; col: number } | null {
@@ -283,6 +294,7 @@
function clearReorder() { function clearReorder() {
reorderDragId = null; reorderDragId = null;
reorderTo = null; reorderTo = null;
draggingPend = null;
} }
// overRack reports whether y is within the rack's row (a small margin makes the target // overRack reports whether y is within the rack's row (a small margin makes the target
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles. // forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
@@ -315,9 +327,11 @@
const src = downInfo.src; const src = downInfo.src;
const letter = const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? ''; src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY }; drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch' };
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it). // A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a
// placed board tile is likewise lifted off its cell while relocated.
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null; reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null;
// No zoom on drag start: the player may still change their mind. Holding the tile // No zoom on drag start: the player may still change their mind. Holding the tile
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres. // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
} }
@@ -371,9 +385,13 @@
} else if (di.src.from === 'rack' && onRack && to != null) { } else if (di.src.from === 'rack' && onRack && to != null) {
// Dropped a rack tile back onto the rack → reorder it to the drop slot. // Dropped a rack tile back onto the rack → reorder it to the drop slot.
reorderRack(di.src.index, to); reorderRack(di.src.index, to);
} else if (di.src.from === 'board' && cell) {
// Dropped a placed tile on another board cell → relocate it there.
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
} else if (di.src.from === 'board' && onRack) { } else if (di.src.from === 'board' && onRack) {
// Dropped a pending tile back onto the rack → recall it to its original slot. // Dropped a placed tile back onto the rack → recall it to its original slot.
placement = recallAt(placement, di.src.row, di.src.col); placement = recallAt(placement, di.src.row, di.src.col);
selected = null;
recompute(); recompute();
scheduleDraftSave(); scheduleDraftSave();
} }
@@ -416,6 +434,22 @@
} }
function onRecall(row: number, col: number) { function onRecall(row: number, col: number) {
placement = recallAt(placement, row, col); placement = recallAt(placement, row, col);
selected = null;
recompute();
scheduleDraftSave();
}
// relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one
// (a board→board drag), keeping its rack slot and any blank letter (Stage 17).
function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) {
const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol);
if (!pt) return;
if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) {
return;
}
let p = recallAt(placement, fromRow, fromCol);
p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined);
placement = p;
focus = { row: toRow, col: toCol };
recompute(); recompute();
scheduleDraftSave(); scheduleDraftSave();
} }
@@ -651,13 +685,31 @@
} }
} }
// Friend state for the in-game "add to friends" item, derived from the server so it is
// correct across reloads and live-updates when a request is answered (Stage 17):
// `friends` are the caller's accepted friends; `requested` are the addressees already
// requested (pending or declined — both block a re-send and read as "request sent").
let friends = $state(new Set<string>());
let requested = $state(new Set<string>()); let requested = $state(new Set<string>());
const noop = () => {}; const noop = () => {};
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
async function loadFriends() {
if (app.profile?.isGuest) return;
try {
const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]);
friends = new Set(fl.map((f) => f.accountId));
requested = new Set(out.map((f) => f.accountId));
} catch {
/* best-effort */
}
}
async function addFriend(accountId: string) { async function addFriend(accountId: string) {
try { try {
await gateway.friendRequest(accountId); await gateway.friendRequest(accountId);
requested = new Set([...requested, accountId]); requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
showToast(t('friends.requestSent')); showToast(t('friends.requestSent'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -677,7 +729,9 @@
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []), ...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest ...(!app.profile?.isGuest
? opponents.map((s) => ? opponents.map((s) =>
requested.has(s.accountId) friends.has(s.accountId)
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true }
: requested.has(s.accountId)
? { label: t('game.requestSent'), onclick: noop, disabled: true } ? { label: t('game.requestSent'), onclick: noop, disabled: true }
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }, : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
) )
@@ -814,7 +868,7 @@
</Screen> </Screen>
{#if drag} {#if drag}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px"> <div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span> <span>{drag.blank ? '' : drag.letter}</span>
</div> </div>
{/if} {/if}
@@ -1092,6 +1146,10 @@
pointer-events: none; pointer-events: none;
z-index: 60; z-index: 60;
} }
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x (Stage 17). */
.ghost.touch {
transform: translate(-50%, -50%) scale(1.5);
}
.alpha { .alpha {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);
+4
View File
@@ -91,6 +91,10 @@
font-size: 1.4rem; font-size: 1.4rem;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
-webkit-user-select: none;
/* iOS shows a tap/active highlight that can linger on the neighbour sliding into a
dragged tile's slot (Stage 17); suppress it so only our own styles mark a tile. */
-webkit-tap-highlight-color: transparent;
} }
.tile.selected { .tile.selected {
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
+1
View File
@@ -44,6 +44,7 @@ export { MoveResult } from './scrabblefb/move-result.js';
export { NotificationEvent } from './scrabblefb/notification-event.js'; export { NotificationEvent } from './scrabblefb/notification-event.js';
export { NudgeEvent } from './scrabblefb/nudge-event.js'; export { NudgeEvent } from './scrabblefb/nudge-event.js';
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js'; export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
export { OutgoingRequestList } from './scrabblefb/outgoing-request-list.js';
export { PlayTile } from './scrabblefb/play-tile.js'; export { PlayTile } from './scrabblefb/play-tile.js';
export { Profile } from './scrabblefb/profile.js'; export { Profile } from './scrabblefb/profile.js';
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
+12 -2
View File
@@ -88,8 +88,13 @@ seatsLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
} }
lastActivityUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 24);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startGameView(builder:flatbuffers.Builder) { static startGameView(builder:flatbuffers.Builder) {
builder.startObject(10); builder.startObject(11);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -144,12 +149,16 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4); builder.startVector(4, numElems, 4);
} }
static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint) {
builder.addFieldInt64(10, lastActivityUnix, BigInt('0'));
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
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):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, 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);
@@ -161,6 +170,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addMoveCount(builder, moveCount); GameView.addMoveCount(builder, moveCount);
GameView.addEndReason(builder, endReasonOffset); GameView.addEndReason(builder, endReasonOffset);
GameView.addSeats(builder, seatsOffset); GameView.addSeats(builder, seatsOffset);
GameView.addLastActivityUnix(builder, lastActivityUnix);
return GameView.endGameView(builder); return GameView.endGameView(builder);
} }
} }
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class OutgoingRequestList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):OutgoingRequestList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList {
return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
requests(index: number, obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
requestsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startOutgoingRequestList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, requestsOffset, 0);
}
static createRequestsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startRequestsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endOutgoingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset {
OutgoingRequestList.startOutgoingRequestList(builder);
OutgoingRequestList.addRequests(builder, requestsOffset);
return OutgoingRequestList.endOutgoingRequestList(builder);
}
}
+17
View File
@@ -13,6 +13,7 @@ import {
insideTelegram, insideTelegram,
onTelegramPath, onTelegramPath,
telegramColorScheme, telegramColorScheme,
telegramContentSafeAreaTop,
telegramDisableVerticalSwipes, telegramDisableVerticalSwipes,
telegramHaptic, telegramHaptic,
telegramLaunch, telegramLaunch,
@@ -227,6 +228,19 @@ function syncTelegramChrome(): void {
); );
} }
/**
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
* `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its
* band (Stage 17). Called on launch and on Telegram's safe-area / fullscreen change events.
*/
function syncTelegramSafeArea(): void {
if (typeof document === 'undefined') return;
const top = telegramContentSafeAreaTop();
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
}
export async function bootstrap(): Promise<void> { export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
@@ -263,6 +277,9 @@ export async function bootstrap(): Promise<void> {
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
// fighting tile drag / board scroll. // fighting tile drag / board scroll.
syncTelegramChrome(); syncTelegramChrome();
syncTelegramSafeArea();
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
telegramDisableVerticalSwipes(); telegramDisableVerticalSwipes();
try { try {
await adoptSession(await gateway.authTelegram(launch.initData)); await adoptSession(await gateway.authTelegram(launch.initData));
+2
View File
@@ -98,6 +98,8 @@ export interface GatewayClient {
// --- friends (Stage 8) --- // --- friends (Stage 8) ---
friendsList(): Promise<AccountRef[]>; friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>; friendsIncoming(): Promise<AccountRef[]>;
/** Addressees the caller has already requested (pending or declined); cannot re-request. */
friendsOutgoing(): Promise<AccountRef[]>;
friendRequest(accountId: string): Promise<void>; friendRequest(accountId: string): Promise<void>;
friendRespond(requesterId: string, accept: boolean): Promise<void>; friendRespond(requesterId: string, accept: boolean): Promise<void>;
friendCancel(accountId: string): Promise<void>; friendCancel(accountId: string): Promise<void>;
+12
View File
@@ -249,6 +249,7 @@ function decodeGameView(g: fb.GameView): GameView {
turnTimeoutSecs: g.turnTimeoutSecs(), turnTimeoutSecs: g.turnTimeoutSecs(),
moveCount: g.moveCount(), moveCount: g.moveCount(),
endReason: s(g.endReason()), endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()),
seats, seats,
}; };
} }
@@ -587,6 +588,16 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
return out; return out;
} }
export function decodeOutgoingList(buf: Uint8Array): AccountRef[] {
const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.requestsLength(); i++) {
const r = l.requests(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeBlockList(buf: Uint8Array): AccountRef[] { export function decodeBlockList(buf: Uint8Array): AccountRef[] {
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf)); const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
const out: AccountRef[] = []; const out: AccountRef[] = [];
@@ -678,6 +689,7 @@ function emptyGame(): GameView {
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0,
seats: [], seats: [],
}; };
} }
+1 -1
View File
@@ -153,7 +153,6 @@ export const en = {
'about.version': 'Version {v}', 'about.version': 'Version {v}',
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', 'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
'landing.playWeb': 'Play in browser',
'landing.playTelegram': 'Play in Telegram', 'landing.playTelegram': 'Play in Telegram',
'lang.en': 'English', 'lang.en': 'English',
@@ -242,6 +241,7 @@ export const en = {
'game.exportGcg': 'Export GCG', 'game.exportGcg': 'Export GCG',
'game.gcgActiveOnly': 'Available once the game is finished.', 'game.gcgActiveOnly': 'Available once the game is finished.',
'game.requestSent': 'Request sent', 'game.requestSent': 'Request sent',
'game.alreadyFriends': '✓ In friends',
'time.minutes': '{n} min', 'time.minutes': '{n} min',
'time.hours': '{n} h', 'time.hours': '{n} h',
+1 -1
View File
@@ -154,7 +154,6 @@ export const ru: Record<MessageKey, string> = {
'about.version': 'Версия {v}', 'about.version': 'Версия {v}',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
'landing.playWeb': 'Играть в браузере',
'landing.playTelegram': 'Играть в Telegram', 'landing.playTelegram': 'Играть в Telegram',
'lang.en': 'English', 'lang.en': 'English',
@@ -243,6 +242,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG', 'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.requestSent': 'Запрос отправлен', 'game.requestSent': 'Запрос отправлен',
'game.alreadyFriends': '✓ В друзьях',
'time.minutes': '{n} мин', 'time.minutes': '{n} мин',
'time.hours': '{n} ч', 'time.hours': '{n} ч',
+12 -12
View File
@@ -1,20 +1,20 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { telegramBotLink } from './landing'; import { telegramChannelLink } from './landing';
describe('telegramBotLink', () => { describe('telegramChannelLink', () => {
afterEach(() => vi.unstubAllEnvs()); afterEach(() => vi.unstubAllEnvs());
it('returns the per-language bot link when configured', () => { it('builds the per-language t.me link from the channel name', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game'); vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game'); vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game'); expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game'); expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
}); });
it('returns null when the locale link is unset or blank', () => { it('returns null when the locale channel is unset or blank', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', ''); vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' '); vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
expect(telegramBotLink('en')).toBeNull(); expect(telegramChannelLink('en')).toBeNull();
expect(telegramBotLink('ru')).toBeNull(); expect(telegramChannelLink('ru')).toBeNull();
}); });
}); });
+13 -9
View File
@@ -1,16 +1,20 @@
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so // Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
// the per-language Telegram-bot link selection is unit-testable. // the per-language Telegram-channel link selection is unit-testable.
import type { Locale } from './i18n/index.svelte'; import type { Locale } from './i18n/index.svelte';
/** /**
* telegramBotLink returns the t.me link for the locale's game bot, or null when it is not * telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
* configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU) * not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
* because the test and prod contours run different bots (different usernames), so the link * / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
* cannot be hardcoded. * channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
* (the id to post, the name to link). A leading "@" is tolerated.
*/ */
export function telegramBotLink(locale: Locale): string | null { export function telegramChannelLink(locale: Locale): string | null {
const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN; const raw =
const link = (raw as string | undefined)?.trim(); locale === 'ru'
return link ? link : null; ? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
return name ? `https://t.me/${name}` : null;
} }
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { groupGames, isMyTurn } from './lobbysort';
import type { GameView, Seat } from './model';
const ME = 'me';
const seat = (s: number, accountId: string): Seat => ({
seat: s,
accountId,
displayName: accountId,
score: 0,
hintsUsed: 0,
isWinner: false,
});
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
return {
id,
variant: 'english',
dictVersion: 'v1',
status,
players: 2,
toMove,
turnTimeoutSecs: 0,
moveCount: 0,
endReason: '',
lastActivityUnix,
seats: [seat(0, ME), seat(1, 'opp')],
};
}
describe('groupGames', () => {
it('partitions into your-turn, their-turn and finished', () => {
const g = groupGames(
[
game('a', 'active', 0, 100), // toMove 0 == my seat -> my turn
game('b', 'active', 1, 100), // their turn
game('c', 'finished', 0, 100),
],
ME,
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
expect(g.finished.map((x) => x.id)).toEqual(['c']);
});
it('orders your-turn oldest-first, the other two newest-first', () => {
const g = groupGames(
[
game('y_new', 'active', 0, 200),
game('y_old', 'active', 0, 100),
game('t_new', 'active', 1, 200),
game('t_old', 'active', 1, 100),
game('f_new', 'finished', 0, 200),
game('f_old', 'finished', 0, 100),
],
ME,
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
});
it('isMyTurn is false for a finished game even at my seat', () => {
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three
// sections — games awaiting the caller's move, games awaiting the opponent, and finished
// games — each ordered by last activity: your-turn oldest-first (the longest-neglected on
// top), the other two newest-first.
import type { GameView } from './model';
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
export function isMyTurn(game: GameView, myId: string): boolean {
const me = game.seats.find((s) => s.accountId === myId);
return game.status === 'active' && !!me && game.toMove === me.seat;
}
/** LobbyGroups holds the three ordered lobby sections. */
export interface LobbyGroups {
yourTurn: GameView[];
theirTurn: GameView[];
finished: GameView[];
}
/**
* groupGames partitions games for myId into the three lobby sections and orders each: the
* your-turn games by ascending last activity (the longest-waiting first), the opponent-turn
* and finished games by descending last activity (the most recent first).
*/
export function groupGames(games: GameView[], myId: string): LobbyGroups {
const yourTurn: GameView[] = [];
const theirTurn: GameView[] = [];
const finished: GameView[] = [];
for (const g of games) {
if (g.status !== 'active') finished.push(g);
else if (isMyTurn(g, myId)) yourTurn.push(g);
else theirTurn.push(g);
}
yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix);
theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
return { yourTurn, theirTurn, finished };
}
+11 -2
View File
@@ -90,6 +90,7 @@ export class MockGateway implements GatewayClient {
private pendingMatch: string | null = null; private pendingMatch: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f })); private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f })); private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
private outgoing: AccountRef[] = [];
private blocks: AccountRef[] = []; private blocks: AccountRef[] = [];
private invitations: Invitation[] = mockInvitations(); private invitations: Invitation[] = mockInvitations();
private readonly stats: Stats = { ...MOCK_STATS }; private readonly stats: Stats = { ...MOCK_STATS };
@@ -155,6 +156,7 @@ export class MockGateway implements GatewayClient {
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
seats: [ seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
@@ -372,8 +374,15 @@ export class MockGateway implements GatewayClient {
async friendsIncoming(): Promise<AccountRef[]> { async friendsIncoming(): Promise<AccountRef[]> {
return this.incoming.map((f) => ({ ...f })); return this.incoming.map((f) => ({ ...f }));
} }
async friendRequest(_accountId: string): Promise<void> { async friendsOutgoing(): Promise<AccountRef[]> {
// The real backend requires a shared game; the mock simply acknowledges. return this.outgoing.map((f) => ({ ...f }));
}
async friendRequest(accountId: string): Promise<void> {
// The real backend requires a shared game; the mock records the outgoing request so
// the game's "add to friends" item reads as sent across reloads.
if (!this.outgoing.some((o) => o.accountId === accountId)) {
this.outgoing.push({ accountId, displayName: this.nameFor(accountId) });
}
} }
async friendRespond(requesterId: string, accept: boolean): Promise<void> { async friendRespond(requesterId: string, accept: boolean): Promise<void> {
const i = this.incoming.findIndex((r) => r.accountId === requesterId); const i = this.incoming.findIndex((r) => r.accountId === requesterId);
+6 -4
View File
@@ -43,10 +43,9 @@ export const PROFILE: Profile = {
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile // Seed social/account data for the mock (pnpm start + Playwright). The mock profile
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable. // is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
export const MOCK_FRIENDS: AccountRef[] = [ // Ann is the active game's opponent but deliberately not a friend, so the in-game
{ accountId: 'ann', displayName: 'Ann' }, // "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
{ accountId: 'kaya', displayName: 'Kaya' }, export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
];
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }]; export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
@@ -144,6 +143,7 @@ function activeGame(): MockGame {
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
moveCount: G1_MOVES.length, moveCount: G1_MOVES.length,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)], seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
}, },
moves: G1_MOVES, moves: G1_MOVES,
@@ -177,6 +177,7 @@ function finishedG2(): MockGame {
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
moveCount: 2, moveCount: 2,
endReason: 'normal', endReason: 'normal',
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)], seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
}, },
moves: [ moves: [
@@ -211,6 +212,7 @@ function finishedG3(): MockGame {
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
moveCount: 1, moveCount: 1,
endReason: 'resignation', endReason: 'resignation',
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)], seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
}, },
moves: [ moves: [
+2
View File
@@ -40,6 +40,8 @@ export interface GameView {
turnTimeoutSecs: number; turnTimeoutSecs: number;
moveCount: number; moveCount: number;
endReason: string; endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
lastActivityUnix: number;
seats: Seat[]; seats: Seat[];
} }
+1
View File
@@ -22,6 +22,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0,
seats, seats,
}; };
} }
+11
View File
@@ -10,6 +10,8 @@ interface TelegramWebApp {
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
isFullscreen?: boolean;
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
@@ -99,6 +101,15 @@ export function telegramSetChrome(header: string, background: string, bottom: st
if (bottom) w?.setBottomBarColor?.(bottom); if (bottom) w?.setBottomBarColor?.(bottom);
} }
/**
* telegramContentSafeAreaTop returns the height (px) Telegram's own UI overlays at the top of
* the viewport in fullscreen (its nav band; the content-safe area, Bot API 8.0). It is 0
* outside Telegram or on clients predating it, so callers can pad/position defensively.
*/
export function telegramContentSafeAreaTop(): number {
return webApp()?.contentSafeAreaInset?.top ?? 0;
}
/** /**
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so * telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
* it does not fight tile drag-and-drop or the board's vertical scroll. * it does not fight tile drag-and-drop or the board's vertical scroll.
+3
View File
@@ -137,6 +137,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async friendsIncoming() { async friendsIncoming() {
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty())); return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
}, },
async friendsOutgoing() {
return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty()));
},
async friendRequest(accountId) { async friendRequest(accountId) {
await exec('friends.request', codec.encodeTarget(accountId)); await exec('friends.request', codec.encodeTarget(accountId));
}, },
+34 -9
View File
@@ -9,6 +9,7 @@
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result'; import { resultBadge } from '../lib/result';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { groupGames } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
let games = $state<GameView[]>([]); let games = $state<GameView[]>([]);
@@ -46,8 +47,7 @@
}); });
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const active = $derived(games.filter((g) => g.status === 'active')); const groups = $derived(groupGames(games, myId));
const finished = $derived(games.filter((g) => g.status !== 'active'));
function opponents(g: GameView): string { function opponents(g: GameView): string {
return g.seats return g.seats
@@ -129,25 +129,26 @@
</section> </section>
{/if} {/if}
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)} {#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)}
{#if group.list.length} {#if group.list.length}
<section> <section>
<h2>{t(group.h as 'lobby.activeGames')}</h2> <h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list">
{#each group.list as g (g.id)} {#each group.list as g (g.id)}
{@const b = resultBadge(g, myId)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}> <button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="info"> <span class="info">
<span class="who">{opponents(g) || '—'}</span> <span class="who">{opponents(g) || '—'}</span>
<span class="sub">{t(b.key)} · {scoreline(g)}</span> <span class="sub">{scoreline(g)}</span>
</span> </span>
<span class="emoji">{b.emoji}</span> <span class="emoji">{resultBadge(g, myId).emoji}</span>
</button> </button>
{/each} {/each}
</div>
</section> </section>
{/if} {/if}
{/each} {/each}
{#if !active.length && !finished.length && !invitations.length} {#if !games.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p> <p class="empty">{t('lobby.noActive')}</p>
{/if} {/if}
</div> </div>
@@ -186,7 +187,6 @@
font-size: 0.9rem; font-size: 0.9rem;
margin: 0; margin: 0;
} }
.row,
.invite { .invite {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -202,6 +202,31 @@
border-radius: var(--radius); border-radius: var(--radius);
user-select: none; user-select: none;
} }
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows (Stage 17). */
.list {
display: flex;
flex-direction: column;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 10px 6px;
border: none;
background: none;
color: var(--text);
user-select: none;
}
.row + .row {
border-top: 1px solid var(--border);
}
.row:active {
background: var(--surface-2);
}
.info { .info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;