Compare commits

..

1 Commits

Author SHA1 Message Date
Ilia Denisov e01faae28a Stage 17 round 6 (#18, PR D): admin Messages moderation section
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) Has been skipped
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 19:58:55 +02:00
57 changed files with 231 additions and 1011 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_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }} VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }} VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_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.
-30
View File
@@ -1348,36 +1348,6 @@ 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 - **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 / posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by
-118
View File
@@ -6,7 +6,6 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -15,36 +14,9 @@ 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 {
@@ -412,96 +384,6 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
} }
} }
// 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 // 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. // (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) { func TestAdminListMessages(t *testing.T) {
+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, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the // NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
// client may use to scope its refresh. // 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)
+2 -5
View File
@@ -34,11 +34,8 @@ 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 NotifyInvitation = "invitation"
// game screen watching that opponent re-derives its "add to friends" state. NotifyGameStarted = "game_started"
NotifyFriendDeclined = "friend_declined"
NotifyInvitation = "invitation"
NotifyGameStarted = "game_started"
) )
// Intent is one live event destined for a single user. Payload is the // Intent is one live event destined for a single user. Payload is the
+20 -28
View File
@@ -83,19 +83,16 @@ type seatDTO struct {
// gameDTO is the shared game summary. // gameDTO is the shared game summary.
type gameDTO struct { type gameDTO struct {
ID string `json:"id"` ID string `json:"id"`
Variant string `json:"variant"` Variant string `json:"variant"`
DictVersion string `json:"dict_version"` DictVersion string `json:"dict_version"`
Status string `json:"status"` Status string `json:"status"`
Players int `json:"players"` Players int `json:"players"`
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"` 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 Seats []seatDTO `json:"seats"`
// game, the finish time once finished (Stage 17).
LastActivityUnix int64 `json:"last_activity_unix"`
Seats []seatDTO `json:"seats"`
} }
// moveResultDTO is the outcome of a committed move. // moveResultDTO is the outcome of a committed move.
@@ -192,22 +189,17 @@ 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(),
DictVersion: g.DictVersion, DictVersion: g.DictVersion,
Status: g.Status, Status: g.Status,
Players: g.Players, Players: g.Players,
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
LastActivityUnix: last.Unix(), Seats: seats,
Seats: seats,
} }
} }
-1
View File
@@ -87,7 +87,6 @@ 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)
@@ -31,12 +31,6 @@ 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"`
@@ -224,22 +218,6 @@ 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)
-39
View File
@@ -124,14 +124,6 @@ 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
} }
@@ -164,14 +156,6 @@ 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) {
@@ -310,29 +294,6 @@ 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_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot VITE_TELEGRAM_LINK_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_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_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_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_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_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_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-} VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_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
+2 -4
View File
@@ -477,10 +477,8 @@ 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, friend-declined, invitation or game-started; emitted on a friend-request, friend-added, invitation or game-started; emitted on a friend-request and invitation
on answering one (accept → friend-added, decline → friend-declined — to the original create and on an invitation's game start). Event payloads are FlatBuffers-encoded by
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
+4 -12
View File
@@ -22,9 +22,8 @@ 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 to the matching per-language Telegram channel; the game itself runs at theme, and links into the web app or the matching Telegram bot; the game itself runs at
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral `/app/` (web) and `/telegram/` (the Telegram Mini App).
(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
@@ -58,11 +57,7 @@ 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 **my games** list groups games into three Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
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
@@ -115,10 +110,7 @@ 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. In a game, an **add to friends** item for each opponent mirrors the live friendship. Block globally — switch off incoming chat
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
+4 -12
View File
@@ -23,9 +23,8 @@ 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-вход или как
@@ -59,11 +58,7 @@ Mini App** авторизует по подписанным `initData` плат
запрещено, только пока у аккаунтов есть общая незавершённая игра. запрещено, только пока у аккаунтов есть общая незавершённая игра.
### Лобби и подбор *(Stage 4 / 15)* ### Лобби и подбор *(Stage 4 / 15)*
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции — Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
@@ -117,10 +112,7 @@ 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_GAME_CHANNEL_NAME_EN= ARG VITE_TELEGRAM_LINK_EN=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= ARG VITE_TELEGRAM_LINK_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_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \ VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \ VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_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
+10 -11
View File
@@ -93,17 +93,16 @@ type SeatResp struct {
// GameResp is the shared game summary. // GameResp is the shared game summary.
type GameResp struct { type GameResp struct {
ID string `json:"id"` ID string `json:"id"`
Variant string `json:"variant"` Variant string `json:"variant"`
DictVersion string `json:"dict_version"` DictVersion string `json:"dict_version"`
Status string `json:"status"` Status string `json:"status"`
Players int `json:"players"` Players int `json:"players"`
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"` 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"`
} }
// MoveResultResp is the outcome of a committed move. // MoveResultResp is the outcome of a committed move.
@@ -25,12 +25,6 @@ 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"`
@@ -140,14 +134,6 @@ 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,7 +357,6 @@ 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,16 +54,6 @@ 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,7 +13,6 @@ 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"
@@ -38,7 +37,6 @@ 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}
@@ -80,16 +78,6 @@ 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,35 +54,6 @@ 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" {
+1 -4
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,"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}]}]}`)) _, _ = 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}]}]}`))
}) })
defer cleanup() defer cleanup()
@@ -177,9 +177,6 @@ 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" {
+2 -13
View File
@@ -66,9 +66,6 @@ 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).
@@ -392,13 +389,6 @@ 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;
@@ -502,9 +492,8 @@ 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", "friend_declined", "invitation", // discriminator ("friend_request", "friend_added", "invitation", "game_started");
// "game_started"); the client re-fetches its lobby counters (and, for a requester // the client re-fetches its lobby counters on any of them.
// watching a game, its friend state) on any of them.
table NotificationEvent { table NotificationEvent {
kind:string; kind:string;
} }
+1 -16
View File
@@ -149,20 +149,8 @@ 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(11) builder.StartObject(10)
} }
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)
@@ -197,9 +185,6 @@ 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
@@ -1,75 +0,0 @@
// 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_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_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
+7 -14
View File
@@ -1,21 +1,14 @@
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 lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html. // the game SPA moves to /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => { test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => {
await page.goto('/landing.html'); await page.goto('/landing.html');
// The tagline renders (English in the default test browser). // The primary call to action opens the web app mount.
await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/');
// The language dropdown switches the copy to Russian. // The language switch flips the copy to Russian (reusing the app i18n).
await page.getByRole('button', { name: 'Language' }).click(); await page.getByRole('button', { name: 'Русский' }).click();
await page.getByRole('menuitem', { name: /Русский/ }).click(); await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible();
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('Your turn')).toBeVisible(); await expect(page.getByText('Active games')).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('Your turn')).toBeVisible(); await expect(page.getByText('Active games')).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('Your turn')).toBeVisible(); await expect(page.getByText('Active games')).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('Your turn')).toBeVisible(); await expect(page.getByText('Active games')).toBeVisible();
// The Telegram themeParams override the background token at runtime. // The Telegram themeParams override the background token at runtime.
await expect await expect
+10 -12
View File
@@ -1,16 +1,14 @@
<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 (scaled up ~25% around its centre per review) --> <!-- five-pointed star (filled, slightly smaller) -->
<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"/> <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"/>
<g fill="none" stroke="#ffd700" stroke-linecap="round" stroke-linejoin="round" transform="translate(6.8 6) scale(0.667) translate(-6.8 -6)"> <!-- schematic hammer & sickle (a sketch, thin strokes) -->
<!-- sickle: a crescent blade + short handle, mirrored across a diagonal through its centre <g fill="none" stroke="#ffd700" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round">
so it reads as the canonical sickle (blade sweeping down-right); the hammer is untouched --> <!-- sickle: an elongated semicircle blade with a short handle -->
<g transform="matrix(0 1 1 0 -2.8 2.8)"> <path d="M8.2 7.4a3 3 0 1 1-3.3 3.9"/>
<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"/> <path d="M4.9 11.3l-.8.7"/>
<path stroke-width="0.6" d="M8.1 6.0 l 0.85 -0.95"/> <!-- hammer: a T-shape (handle + head) crossing the sickle -->
</g> <path d="M5.1 11 8.1 8"/>
<!-- hammer: handle (down-right) + head (a short bar) at ~90°, crossing the sickle --> <path d="M7.2 7.1 9 8.9"/>
<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: 1.2 KiB

After

Width:  |  Height:  |  Size: 760 B

+89 -103
View File
@@ -1,84 +1,89 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { applyTheme } from './lib/theme'; import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme';
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte'; import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } 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 { telegramChannelLink } from './lib/landing'; import { telegramBotLink } from './lib/landing';
// Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and // Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it // (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system // not the app store — so it stays light (no gateway, auth or live stream).
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
let theme = $state<'light' | 'dark'>('light'); const themes: ThemePref[] = ['auto', 'light', 'dark'];
let langOpen = $state(false); const themeLabel: Record<ThemePref, MessageKey> = {
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> = {};
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock // The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h).
const tgLink = $derived(telegramChannelLink(i18n.locale)); const about = $derived(aboutContent(i18n.locale, 24));
const locales: { code: Locale; label: string }[] = [ const tgLink = $derived(telegramBotLink(i18n.locale));
{ 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 = systemTheme(); theme = prefs.theme ?? 'auto';
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 toggleTheme(): void { function persist(): void {
theme = theme === 'light' ? 'dark' : 'light'; // savePrefs takes the full set, so keep the labels/lines the app may have stored.
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: prefs.theme ?? 'auto', theme,
locale: lc, locale: i18n.locale,
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="lang"> <div class="seg">
<button class="icon" aria-label="Language" aria-expanded={langOpen} onclick={() => (langOpen = !langOpen)}>🌐</button> {#each locales as lc (lc)}
{#if langOpen} <button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}>
<!-- svelte-ignore a11y_consider_explicit_label --> {t(lc === 'en' ? 'lang.en' : 'lang.ru')}
<button class="backdrop" onclick={() => (langOpen = false)}></button> </button>
<div class="menu" role="menu"> {/each}
{#each locales as l (l.code)} </div>
<button role="menuitem" class:on={i18n.locale === l.code} onclick={() => chooseLocale(l.code)}>{l.label}</button> <div class="seg">
{/each} {#each themes as th (th)}
</div> <button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
{/if} {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>
{#if tgLink} <div class="cta">
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer"> <a class="play primary" href="/app/">{t('landing.playWeb')}</a>
<img src="telegram-logo.svg" alt="" width="22" height="22" /> {#if tgLink}
{t('landing.playTelegram')} <a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer">
</a> <img src="telegram-logo.svg" alt="" width="22" height="22" />
{/if} {t('landing.playTelegram')}
</a>
{/if}
</div>
</section> </section>
<section class="info"> <section class="info">
@@ -120,65 +125,32 @@
.bar { .bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; gap: 10px;
flex-wrap: wrap;
} }
.lang { .seg {
position: relative;
}
.icon {
min-width: 40px;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
background: transparent;
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.backdrop {
position: fixed;
inset: 0;
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; display: flex;
flex-direction: column; gap: 6px;
min-width: 150px;
overflow: hidden;
} }
.menu button { .opt {
text-align: left; padding: 7px 12px;
padding: 10px 14px; border: 1px solid var(--border);
background: none; background: var(--surface);
border: none;
color: var(--text); color: var(--text);
white-space: nowrap; border-radius: var(--radius-sm);
user-select: none;
font-size: 0.85rem;
} }
.menu button:hover { .opt.active {
background: var(--surface-2); background: var(--accent);
} color: var(--accent-text);
.menu button.on { border-color: var(--accent);
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: 16px; gap: 14px;
padding: 24px 0 8px; padding: 24px 0 8px;
} }
.hero h1 { .hero h1 {
@@ -192,20 +164,33 @@
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 24px; padding: 12px 22px;
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);
margin-top: 6px; border-color: var(--accent);
} }
.play img { .play.tg {
background: var(--surface);
color: var(--text);
}
.play.tg img {
display: block; display: block;
} }
.info { .info {
@@ -240,6 +225,7 @@
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,9 +41,6 @@
--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,11 +89,4 @@
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,9 +277,6 @@
} }
.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
+13 -71
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; touch: boolean } | null>(null); let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const checkedWords = new Map<string, boolean>(); const checkedWords = new Map<string, boolean>();
let cooling = $state(false); let cooling = $state(false);
@@ -70,11 +70,7 @@
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( new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
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
@@ -189,7 +185,6 @@
rackIds = cached.view.rack.map((_, i) => i); rackIds = cached.view.rack.map((_, i) => i);
} }
void load(); void load();
void loadFriends();
}); });
$effect(() => { $effect(() => {
@@ -202,9 +197,6 @@
} 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.
@@ -236,9 +228,6 @@
// (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) {
@@ -272,10 +261,10 @@
if (busy || gameOver) return; if (busy || gameOver) return;
beginDrag({ from: 'rack', index }, e); beginDrag({ from: 'rack', index }, e);
} }
// A placed (pending) tile can be dragged to relocate it on the board or back to the rack — // A pending tile can be dragged back to the rack, but only on the unzoomed board: when
// works zoomed too (the tile has touch-action:none, so its drag wins over the board pan). // zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
function onBoardDown(e: PointerEvent, row: number, col: number) { function onBoardDown(e: PointerEvent, row: number, col: number) {
if (busy || gameOver) return; if (busy || zoomed || 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 {
@@ -294,7 +283,6 @@
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.
@@ -327,11 +315,9 @@
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, touch: e.pointerType === 'touch' }; drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a // A rack tile is lifted out of the rack while dragged (the ghost stands in for it).
// 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.
} }
@@ -385,13 +371,9 @@
} 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 placed tile back onto the rack → recall it to its original slot. // Dropped a pending 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();
} }
@@ -434,22 +416,6 @@
} }
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();
} }
@@ -685,31 +651,13 @@
} }
} }
// 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]); // optimistic; reconciled by loadFriends requested = new Set([...requested, accountId]);
showToast(t('friends.requestSent')); showToast(t('friends.requestSent'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -729,11 +677,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) =>
friends.has(s.accountId) requested.has(s.accountId)
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true } ? { label: t('game.requestSent'), onclick: noop, disabled: true }
: requested.has(s.accountId) : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
? { label: t('game.requestSent'), onclick: noop, disabled: true }
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
) )
: []), : []),
...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]), ...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
@@ -868,7 +814,7 @@
</Screen> </Screen>
{#if drag} {#if drag}
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px"> <div class="ghost" 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}
@@ -1146,10 +1092,6 @@
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,10 +91,6 @@
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,7 +44,6 @@ 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';
+2 -12
View File
@@ -88,13 +88,8 @@ 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(11); builder.startObject(10);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -149,16 +144,12 @@ 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, lastActivityUnix:bigint):flatbuffers.Offset { static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset):flatbuffers.Offset {
GameView.startGameView(builder); GameView.startGameView(builder);
GameView.addId(builder, idOffset); GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset); GameView.addVariant(builder, variantOffset);
@@ -170,7 +161,6 @@ 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);
} }
} }
@@ -1,66 +0,0 @@
// 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,7 +13,6 @@ import {
insideTelegram, insideTelegram,
onTelegramPath, onTelegramPath,
telegramColorScheme, telegramColorScheme,
telegramContentSafeAreaTop,
telegramDisableVerticalSwipes, telegramDisableVerticalSwipes,
telegramHaptic, telegramHaptic,
telegramLaunch, telegramLaunch,
@@ -228,19 +227,6 @@ 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';
@@ -277,9 +263,6 @@ 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,8 +98,6 @@ 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,7 +249,6 @@ 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,
}; };
} }
@@ -588,16 +587,6 @@ 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[] = [];
@@ -689,7 +678,6 @@ function emptyGame(): GameView {
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0,
seats: [], seats: [],
}; };
} }
+1 -1
View File
@@ -153,6 +153,7 @@ 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',
@@ -241,7 +242,6 @@ 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,6 +154,7 @@ 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',
@@ -242,7 +243,6 @@ 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 { telegramChannelLink } from './landing'; import { telegramBotLink } from './landing';
describe('telegramChannelLink', () => { describe('telegramBotLink', () => {
afterEach(() => vi.unstubAllEnvs()); afterEach(() => vi.unstubAllEnvs());
it('builds the per-language t.me link from the channel name', () => { it('returns the per-language bot link when configured', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game'); vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game');
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game');
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game'); expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game');
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game'); expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game');
}); });
it('returns null when the locale channel is unset or blank', () => { it('returns null when the locale link is unset or blank', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', ''); vi.stubEnv('VITE_TELEGRAM_LINK_EN', '');
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' '); vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' ');
expect(telegramChannelLink('en')).toBeNull(); expect(telegramBotLink('en')).toBeNull();
expect(telegramChannelLink('ru')).toBeNull(); expect(telegramBotLink('ru')).toBeNull();
}); });
}); });
+9 -13
View File
@@ -1,20 +1,16 @@
// 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-channel link selection is unit-testable. // the per-language Telegram-bot link selection is unit-testable.
import type { Locale } from './i18n/index.svelte'; import type { Locale } from './i18n/index.svelte';
/** /**
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is * telegramBotLink returns the t.me link for the locale's game bot, or null when it is not
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN * configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU)
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different * because the test and prod contours run different bots (different usernames), so the link
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_* * cannot be hardcoded.
* (the id to post, the name to link). A leading "@" is tolerated.
*/ */
export function telegramChannelLink(locale: Locale): string | null { export function telegramBotLink(locale: Locale): string | null {
const raw = const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN;
locale === 'ru' const link = (raw as string | undefined)?.trim();
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU return link ? link : null;
: 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
@@ -1,68 +0,0 @@
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
@@ -1,39 +0,0 @@
// 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 };
}
+2 -11
View File
@@ -90,7 +90,6 @@ 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 };
@@ -156,7 +155,6 @@ 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 },
@@ -374,15 +372,8 @@ 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 friendsOutgoing(): Promise<AccountRef[]> { async friendRequest(_accountId: string): Promise<void> {
return this.outgoing.map((f) => ({ ...f })); // The real backend requires a shared game; the mock simply acknowledges.
}
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);
+4 -6
View File
@@ -43,9 +43,10 @@ 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.
// Ann is the active game's opponent but deliberately not a friend, so the in-game export const MOCK_FRIENDS: AccountRef[] = [
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend. { accountId: 'ann', displayName: 'Ann' },
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }]; { accountId: 'kaya', displayName: 'Kaya' },
];
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }]; export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
@@ -143,7 +144,6 @@ 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,7 +177,6 @@ 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: [
@@ -212,7 +211,6 @@ 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,8 +40,6 @@ 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,7 +22,6 @@ 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,8 +10,6 @@ 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;
@@ -101,15 +99,6 @@ 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,9 +137,6 @@ 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));
}, },
+16 -41
View File
@@ -9,7 +9,6 @@
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[]>([]);
@@ -47,7 +46,8 @@
}); });
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId)); const active = $derived(games.filter((g) => g.status === 'active'));
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,26 +129,25 @@
</section> </section>
{/if} {/if}
{#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)} {#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
{#if group.list.length} {#if group.list.length}
<section> <section>
<h2>{t(group.h as 'lobby.yourTurn')}</h2> <h2>{t(group.h as 'lobby.activeGames')}</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">{scoreline(g)}</span> <span class="sub">{t(b.key)} · {scoreline(g)}</span>
</span> </span>
<span class="emoji">{resultBadge(g, myId).emoji}</span> <span class="emoji">{b.emoji}</span>
</button> </button>
{/each} {/each}
</div>
</section> </section>
{/if} {/if}
{/each} {/each}
{#if !games.length && !invitations.length} {#if !active.length && !finished.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p> <p class="empty">{t('lobby.noActive')}</p>
{/if} {/if}
</div> </div>
@@ -187,6 +186,7 @@
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,31 +202,6 @@
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;