Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e01faae28a |
@@ -267,8 +267,8 @@ jobs:
|
||||
TELEGRAM_TEST_ENV: "true"
|
||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
|
||||
VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
|
||||
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
|
||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||
|
||||
@@ -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**
|
||||
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.
|
||||
- **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram
|
||||
link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries
|
||||
a channel **username**, the landing builds `https://t.me/<name>`; the connector keeps the matching
|
||||
`..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app)
|
||||
and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto").
|
||||
The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
|
||||
- **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle,
|
||||
scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5
|
||||
(touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a
|
||||
rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board
|
||||
cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the
|
||||
board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram
|
||||
fullscreen** no longer hides our header under its native nav — the whole header drops below the
|
||||
content-safe-area top inset (title and the right-aligned menu both clear the nav), via
|
||||
`--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way
|
||||
to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our
|
||||
own header and simply push it clear.)
|
||||
- **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games
|
||||
into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last
|
||||
activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a
|
||||
compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB
|
||||
`GameView` gained `last_activity_unix` (the turn start while active, the finish time once
|
||||
finished). The in-game **"add to friends"** item is now **server-derived** (new `GET
|
||||
/user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested —
|
||||
pending **or** declined, which both read as "request sent") so it is correct across reloads, shows
|
||||
a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers:
|
||||
`RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify
|
||||
sub-kind, decline) to the **original requester**, whose open game re-derives its friend state.
|
||||
Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent
|
||||
reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
|
||||
- **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists
|
||||
posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
|
||||
oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,36 +14,9 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
"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
|
||||
// state through a real game 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
|
||||
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||
func TestAdminListMessages(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||
// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
|
||||
// client may use to scope its refresh.
|
||||
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
|
||||
// scope its refresh.
|
||||
func Notification(userID uuid.UUID, kind string) Intent {
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
k := b.CreateString(kind)
|
||||
|
||||
@@ -34,9 +34,6 @@ const (
|
||||
const (
|
||||
NotifyFriendRequest = "friend_request"
|
||||
NotifyFriendAdded = "friend_added"
|
||||
// NotifyFriendDeclined tells the original requester their request was declined, so a
|
||||
// game screen watching that opponent re-derives its "add to friends" state.
|
||||
NotifyFriendDeclined = "friend_declined"
|
||||
NotifyInvitation = "invitation"
|
||||
NotifyGameStarted = "game_started"
|
||||
)
|
||||
|
||||
@@ -92,9 +92,6 @@ type gameDTO struct {
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||
// game, the finish time once finished (Stage 17).
|
||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||
Seats []seatDTO `json:"seats"`
|
||||
}
|
||||
|
||||
@@ -192,10 +189,6 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
||||
IsWinner: s.IsWinner,
|
||||
})
|
||||
}
|
||||
last := g.TurnStartedAt
|
||||
if g.FinishedAt != nil {
|
||||
last = *g.FinishedAt
|
||||
}
|
||||
return gameDTO{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
@@ -206,7 +199,6 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
LastActivityUnix: last.Unix(),
|
||||
Seats: seats,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/games/:id/nudge", s.handleNudge)
|
||||
u.GET("/friends", s.handleListFriends)
|
||||
u.GET("/friends/incoming", s.handleIncomingRequests)
|
||||
u.GET("/friends/outgoing", s.handleOutgoingRequests)
|
||||
u.POST("/friends/request", s.handleFriendRequest)
|
||||
u.POST("/friends/respond", s.handleFriendRespond)
|
||||
u.POST("/friends/cancel", s.handleFriendCancel)
|
||||
|
||||
@@ -31,12 +31,6 @@ type incomingListDTO struct {
|
||||
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).
|
||||
type friendCodeDTO struct {
|
||||
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)})
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
|
||||
@@ -124,14 +124,6 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -164,14 +156,6 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI
|
||||
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
|
||||
// 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) {
|
||||
@@ -310,29 +294,6 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c
|
||||
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.
|
||||
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
||||
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
||||
# --- UI build args (baked into the gateway image) ---------------------------
|
||||
VITE_TELEGRAM_BOT_ID=
|
||||
VITE_TELEGRAM_LINK=
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
|
||||
VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot
|
||||
VITE_TELEGRAM_LINK_RU= # landing "Play in Telegram" link, Russian bot
|
||||
VITE_GATEWAY_URL=
|
||||
|
||||
# --- Gateway ----------------------------------------------------------------
|
||||
|
||||
+2
-2
@@ -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). |
|
||||
| `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_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_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_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
|
||||
| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
|
||||
| `VITE_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
|
||||
|
||||
@@ -78,8 +78,8 @@ services:
|
||||
args:
|
||||
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
|
||||
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
|
||||
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
|
||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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**
|
||||
(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,
|
||||
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
|
||||
on answering one (accept → friend-added, decline → friend-declined — to the original
|
||||
requester, so a game screen watching that opponent re-derives its "add to friends" state,
|
||||
Stage 17), and on an invitation create or its game start). Event payloads are FlatBuffers-encoded by
|
||||
friend-added, invitation or game-started; emitted on a friend-request and invitation
|
||||
create and on an invitation's game start). Event payloads are FlatBuffers-encoded by
|
||||
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
|
||||
badge** (incoming friend requests + open invitations), the client polls on lobby
|
||||
|
||||
+4
-12
@@ -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
|
||||
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
|
||||
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||
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).
|
||||
|
||||
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
||||
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.
|
||||
|
||||
### Lobby & matchmaking *(Stage 4 / 15)*
|
||||
Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
|
||||
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||||
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||||
opponent-turn and finished games are most-recent first; it renders as a compact,
|
||||
line-separated list (Stage 17). The game types offered on **New Game** are
|
||||
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
|
||||
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
|
||||
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
|
||||
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
|
||||
friendship. In a game, an **add to friends** item for each opponent mirrors the live
|
||||
relationship: it reads *request sent* (disabled) while a request is pending or was
|
||||
declined, and *in friends* once accepted — updating in place the moment the opponent
|
||||
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
|
||||
friendship. Block globally — switch off incoming chat
|
||||
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
|
||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||
|
||||
+4
-12
@@ -23,9 +23,8 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||
и ограничивает частоту повторов.
|
||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||
тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по
|
||||
адресам `/app/` (веб) и `/telegram/` (Telegram Mini App).
|
||||
|
||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||
@@ -59,11 +58,7 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
### Лобби и подбор *(Stage 4 / 15)*
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
|
||||
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
||||
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
||||
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
||||
@@ -117,10 +112,7 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
|
||||
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие
|
||||
чат и/или заявки —
|
||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||
|
||||
+4
-4
@@ -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").
|
||||
ARG VITE_TELEGRAM_BOT_ID=
|
||||
ARG VITE_TELEGRAM_LINK=
|
||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
|
||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
|
||||
ARG VITE_TELEGRAM_LINK_EN=
|
||||
ARG VITE_TELEGRAM_LINK_RU=
|
||||
ARG VITE_GATEWAY_URL=
|
||||
ARG VITE_APP_VERSION=
|
||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
|
||||
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
|
||||
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
|
||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ type GameResp struct {
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||
Seats []SeatResp `json:"seats"`
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,6 @@ type IncomingListResp struct {
|
||||
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.
|
||||
type FriendCodeResp struct {
|
||||
Code string `json:"code"`
|
||||
@@ -140,14 +134,6 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR
|
||||
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.
|
||||
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
||||
var out FriendCodeResp
|
||||
|
||||
@@ -357,7 +357,6 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
|
||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||
fb.GameViewAddEndReason(b, endReason)
|
||||
fb.GameViewAddSeats(b, seats)
|
||||
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||
return fb.GameViewEnd(b)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,6 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte {
|
||||
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.
|
||||
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
const (
|
||||
MsgFriendsList = "friends.list"
|
||||
MsgFriendsIncoming = "friends.incoming"
|
||||
MsgFriendsOutgoing = "friends.outgoing"
|
||||
MsgFriendRequest = "friends.request"
|
||||
MsgFriendRespond = "friends.respond"
|
||||
MsgFriendCancel = "friends.cancel"
|
||||
@@ -38,7 +37,6 @@ const (
|
||||
func registerStage8(r *Registry, backend *backendclient.Client) {
|
||||
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(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[MsgFriendRespond] = Op{Handler: friendRespondHandler(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 {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
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) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/games" {
|
||||
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()
|
||||
|
||||
@@ -177,9 +177,6 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
||||
if string(g.Id()) != "g-1" {
|
||||
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
|
||||
g.Seats(&seat, 1)
|
||||
if string(seat.DisplayName()) != "Ann" {
|
||||
|
||||
+2
-13
@@ -66,9 +66,6 @@ table GameView {
|
||||
move_count:int;
|
||||
end_reason:string;
|
||||
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).
|
||||
@@ -392,13 +389,6 @@ table IncomingRequestList {
|
||||
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).
|
||||
table FriendCode {
|
||||
code:string;
|
||||
@@ -502,9 +492,8 @@ table MatchFoundEvent {
|
||||
|
||||
// NotificationEvent is a lightweight "something changed, re-poll" signal that
|
||||
// drives the lobby badge (incoming friend requests, invitations). kind is a sub-
|
||||
// discriminator ("friend_request", "friend_added", "friend_declined", "invitation",
|
||||
// "game_started"); the client re-fetches its lobby counters (and, for a requester
|
||||
// watching a game, its friend state) on any of them.
|
||||
// discriminator ("friend_request", "friend_added", "invitation", "game_started");
|
||||
// the client re-fetches its lobby counters on any of them.
|
||||
table NotificationEvent {
|
||||
kind:string;
|
||||
}
|
||||
|
||||
@@ -149,20 +149,8 @@ func (rcv *GameView) SeatsLength() int {
|
||||
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) {
|
||||
builder.StartObject(11)
|
||||
builder.StartObject(10)
|
||||
}
|
||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||
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 {
|
||||
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 {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
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
|
||||
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).
|
||||
|
||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||
|
||||
+7
-14
@@ -1,21 +1,14 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// 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.
|
||||
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
|
||||
// the game SPA moves to /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
|
||||
test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => {
|
||||
await page.goto('/landing.html');
|
||||
|
||||
// The tagline renders (English in the default test browser).
|
||||
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
||||
// The primary call to action opens the web app mount.
|
||||
await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/');
|
||||
|
||||
// The language dropdown switches the copy to Russian.
|
||||
await page.getByRole('button', { name: 'Language' }).click();
|
||||
await page.getByRole('menuitem', { name: /Русский/ }).click();
|
||||
await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible();
|
||||
|
||||
// The theme toggle flips the document theme (ephemeral, light<->dark).
|
||||
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
await page.getByRole('button', { name: 'Theme' }).click();
|
||||
const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
expect(after).not.toBe(before);
|
||||
// The language switch flips the copy to Russian (reusing the app i18n).
|
||||
await page.getByRole('button', { name: 'Русский' }).click();
|
||||
await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
|
||||
|
||||
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/ });
|
||||
await expect(activeRow).toBeVisible();
|
||||
await activeRow.click();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures';
|
||||
async function loginLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
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> {
|
||||
@@ -107,7 +107,7 @@ test('play with friends: a game type is required to send an invitation', async (
|
||||
await expect(send).toBeEnabled();
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
|
||||
await page.goto('/');
|
||||
|
||||
// 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.
|
||||
await expect
|
||||
|
||||
+10
-12
@@ -1,16 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР">
|
||||
<rect width="24" height="16" fill="#cc0000"/>
|
||||
<!-- five-pointed star (scaled up ~25% around its centre per review) -->
|
||||
<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"/>
|
||||
<g fill="none" stroke="#ffd700" stroke-linecap="round" stroke-linejoin="round" transform="translate(6.8 6) scale(0.667) translate(-6.8 -6)">
|
||||
<!-- sickle: a crescent blade + short handle, mirrored across a diagonal through its centre
|
||||
so it reads as the canonical sickle (blade sweeping down-right); the hammer is untouched -->
|
||||
<g transform="matrix(0 1 1 0 -2.8 2.8)">
|
||||
<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 stroke-width="0.6" d="M8.1 6.0 l 0.85 -0.95"/>
|
||||
</g>
|
||||
<!-- hammer: handle (down-right) + head (a short bar) at ~90°, crossing the sickle -->
|
||||
<path stroke-width="0.78" d="M4.6 8.4 L 8.4 12.9"/>
|
||||
<path stroke-width="0.78" d="M3.25 9.05 L 5.95 7.05"/>
|
||||
<!-- five-pointed star (filled, slightly smaller) -->
|
||||
<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"/>
|
||||
<!-- schematic hammer & sickle (a sketch, thin strokes) -->
|
||||
<g fill="none" stroke="#ffd700" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- sickle: an elongated semicircle blade with a short handle -->
|
||||
<path d="M8.2 7.4a3 3 0 1 1-3.3 3.9"/>
|
||||
<path d="M4.9 11.3l-.8.7"/>
|
||||
<!-- hammer: a T-shape (handle + head) crossing the sickle -->
|
||||
<path d="M5.1 11 8.1 8"/>
|
||||
<path d="M7.2 7.1 9 8.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 760 B |
+82
-96
@@ -1,84 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { applyTheme } from './lib/theme';
|
||||
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
|
||||
import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme';
|
||||
import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } from './lib/i18n/index.svelte';
|
||||
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
||||
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
|
||||
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
|
||||
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
|
||||
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
|
||||
// Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA
|
||||
// (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but
|
||||
// not the app store — so it stays light (no gateway, auth or live stream).
|
||||
|
||||
let theme = $state<'light' | 'dark'>('light');
|
||||
let langOpen = $state(false);
|
||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||
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> = {};
|
||||
|
||||
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
|
||||
const tgLink = $derived(telegramChannelLink(i18n.locale));
|
||||
const locales: { code: Locale; label: string }[] = [
|
||||
{ code: 'en', label: '🇬🇧 English' },
|
||||
{ code: 'ru', label: '🇷🇺 Русский' },
|
||||
];
|
||||
|
||||
function systemTheme(): 'light' | 'dark' {
|
||||
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
// The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h).
|
||||
const about = $derived(aboutContent(i18n.locale, 24));
|
||||
const tgLink = $derived(telegramBotLink(i18n.locale));
|
||||
|
||||
onMount(async () => {
|
||||
prefs = await loadPrefs();
|
||||
theme = systemTheme();
|
||||
theme = prefs.theme ?? 'auto';
|
||||
applyTheme(theme);
|
||||
applyReduceMotion(prefs.reduceMotion ?? false);
|
||||
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
||||
});
|
||||
|
||||
function toggleTheme(): void {
|
||||
theme = theme === 'light' ? 'dark' : 'light';
|
||||
applyTheme(theme); // ephemeral — deliberately not persisted
|
||||
}
|
||||
|
||||
function chooseLocale(lc: Locale): void {
|
||||
setLocale(lc);
|
||||
langOpen = false;
|
||||
// Persist the language only, keeping the app's other prefs (notably its own persisted theme).
|
||||
function persist(): void {
|
||||
// savePrefs takes the full set, so keep the labels/lines the app may have stored.
|
||||
void savePrefs({
|
||||
theme: prefs.theme ?? 'auto',
|
||||
locale: lc,
|
||||
theme,
|
||||
locale: i18n.locale,
|
||||
reduceMotion: prefs.reduceMotion ?? false,
|
||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||
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>
|
||||
|
||||
<main class="landing">
|
||||
<header class="bar">
|
||||
<div class="lang">
|
||||
<button class="icon" aria-label="Language" aria-expanded={langOpen} onclick={() => (langOpen = !langOpen)}>🌐</button>
|
||||
{#if langOpen}
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button class="backdrop" onclick={() => (langOpen = false)}></button>
|
||||
<div class="menu" role="menu">
|
||||
{#each locales as l (l.code)}
|
||||
<button role="menuitem" class:on={i18n.locale === l.code} onclick={() => chooseLocale(l.code)}>{l.label}</button>
|
||||
<div class="seg">
|
||||
{#each locales as lc (lc)}
|
||||
<button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}>
|
||||
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="seg">
|
||||
{#each themes as th (th)}
|
||||
<button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
|
||||
{t(themeLabel[th])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="icon" aria-label="Theme" onclick={toggleTheme}>{theme === 'light' ? '☼' : '☾'}</button>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<h1>{about.title}</h1>
|
||||
<p class="tagline">{t('landing.tagline')}</p>
|
||||
<div class="cta">
|
||||
<a class="play primary" href="/app/">{t('landing.playWeb')}</a>
|
||||
{#if tgLink}
|
||||
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
|
||||
<a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer">
|
||||
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
||||
{t('landing.playTelegram')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info">
|
||||
@@ -120,65 +125,32 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lang {
|
||||
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);
|
||||
.seg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
overflow: hidden;
|
||||
gap: 6px;
|
||||
}
|
||||
.menu button {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
.opt {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.menu button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.menu button.on {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
.opt.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.hero {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
padding: 24px 0 8px;
|
||||
}
|
||||
.hero h1 {
|
||||
@@ -192,20 +164,33 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.cta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.play {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 24px;
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.play.primary {
|
||||
background: var(--accent);
|
||||
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;
|
||||
}
|
||||
.info {
|
||||
@@ -240,6 +225,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text);
|
||||
}
|
||||
.ft {
|
||||
margin-top: auto;
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
--radius-sm: 6px;
|
||||
--gap: 8px;
|
||||
--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,
|
||||
"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);
|
||||
|
||||
@@ -89,11 +89,4 @@
|
||||
transform: rotate(45deg);
|
||||
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>
|
||||
|
||||
@@ -277,9 +277,6 @@
|
||||
}
|
||||
.cell.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
|
||||
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
|
||||
|
||||
+11
-69
@@ -60,7 +60,7 @@
|
||||
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
|
||||
let resignOpen = $state(false);
|
||||
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>();
|
||||
let cooling = $state(false);
|
||||
@@ -70,11 +70,7 @@
|
||||
const premium = $derived(premiumGrid(variant));
|
||||
const ctr = $derived(centre(variant));
|
||||
const pendingMap = $derived(
|
||||
new Map(
|
||||
placement.pending
|
||||
.filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col))
|
||||
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
|
||||
),
|
||||
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
|
||||
);
|
||||
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
|
||||
@@ -189,7 +185,6 @@
|
||||
rackIds = cached.view.rack.map((_, i) => i);
|
||||
}
|
||||
void load();
|
||||
void loadFriends();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -202,9 +197,6 @@
|
||||
} 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 === '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.
|
||||
@@ -236,9 +228,6 @@
|
||||
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
|
||||
let reorderDragId = $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;
|
||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||
@@ -272,10 +261,10 @@
|
||||
if (busy || gameOver) return;
|
||||
beginDrag({ from: 'rack', index }, e);
|
||||
}
|
||||
// A placed (pending) tile can be dragged to relocate it on the board or back to the rack —
|
||||
// works zoomed too (the tile has touch-action:none, so its drag wins over the board pan).
|
||||
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when
|
||||
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
|
||||
function onBoardDown(e: PointerEvent, row: number, col: number) {
|
||||
if (busy || gameOver) return;
|
||||
if (busy || zoomed || gameOver) return;
|
||||
beginDrag({ from: 'board', row, col }, e);
|
||||
}
|
||||
function cellUnder(x: number, y: number): { row: number; col: number } | null {
|
||||
@@ -294,7 +283,6 @@
|
||||
function clearReorder() {
|
||||
reorderDragId = null;
|
||||
reorderTo = null;
|
||||
draggingPend = null;
|
||||
}
|
||||
// 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.
|
||||
@@ -327,11 +315,9 @@
|
||||
const src = downInfo.src;
|
||||
const 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' };
|
||||
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a
|
||||
// placed board tile is likewise lifted off its cell while relocated.
|
||||
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).
|
||||
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
|
||||
// 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) {
|
||||
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
|
||||
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) {
|
||||
// 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);
|
||||
selected = null;
|
||||
recompute();
|
||||
scheduleDraftSave();
|
||||
}
|
||||
@@ -434,22 +416,6 @@
|
||||
}
|
||||
function onRecall(row: number, col: number) {
|
||||
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();
|
||||
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>());
|
||||
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) {
|
||||
try {
|
||||
await gateway.friendRequest(accountId);
|
||||
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
|
||||
requested = new Set([...requested, accountId]);
|
||||
showToast(t('friends.requestSent'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@@ -729,9 +677,7 @@
|
||||
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
||||
...(!app.profile?.isGuest
|
||||
? opponents.map((s) =>
|
||||
friends.has(s.accountId)
|
||||
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true }
|
||||
: requested.has(s.accountId)
|
||||
requested.has(s.accountId)
|
||||
? { label: t('game.requestSent'), onclick: noop, disabled: true }
|
||||
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
|
||||
)
|
||||
@@ -868,7 +814,7 @@
|
||||
</Screen>
|
||||
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1146,10 +1092,6 @@
|
||||
pointer-events: none;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
|
||||
@@ -91,10 +91,6 @@
|
||||
font-size: 1.4rem;
|
||||
touch-action: 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 {
|
||||
outline: 3px solid var(--accent);
|
||||
|
||||
@@ -44,7 +44,6 @@ export { MoveResult } from './scrabblefb/move-result.js';
|
||||
export { NotificationEvent } from './scrabblefb/notification-event.js';
|
||||
export { NudgeEvent } from './scrabblefb/nudge-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 { Profile } from './scrabblefb/profile.js';
|
||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
||||
|
||||
@@ -88,13 +88,8 @@ seatsLength():number {
|
||||
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) {
|
||||
builder.startObject(11);
|
||||
builder.startObject(10);
|
||||
}
|
||||
|
||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||
@@ -149,16 +144,12 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
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 {
|
||||
const offset = builder.endObject();
|
||||
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.addId(builder, idOffset);
|
||||
GameView.addVariant(builder, variantOffset);
|
||||
@@ -170,7 +161,6 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
||||
GameView.addMoveCount(builder, moveCount);
|
||||
GameView.addEndReason(builder, endReasonOffset);
|
||||
GameView.addSeats(builder, seatsOffset);
|
||||
GameView.addLastActivityUnix(builder, lastActivityUnix);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
insideTelegram,
|
||||
onTelegramPath,
|
||||
telegramColorScheme,
|
||||
telegramContentSafeAreaTop,
|
||||
telegramDisableVerticalSwipes,
|
||||
telegramHaptic,
|
||||
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> {
|
||||
const prefs = await loadPrefs();
|
||||
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
|
||||
// fighting tile drag / board scroll.
|
||||
syncTelegramChrome();
|
||||
syncTelegramSafeArea();
|
||||
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
||||
telegramDisableVerticalSwipes();
|
||||
try {
|
||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||
|
||||
@@ -98,8 +98,6 @@ export interface GatewayClient {
|
||||
// --- friends (Stage 8) ---
|
||||
friendsList(): 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>;
|
||||
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
||||
friendCancel(accountId: string): Promise<void>;
|
||||
|
||||
@@ -249,7 +249,6 @@ function decodeGameView(g: fb.GameView): GameView {
|
||||
turnTimeoutSecs: g.turnTimeoutSecs(),
|
||||
moveCount: g.moveCount(),
|
||||
endReason: s(g.endReason()),
|
||||
lastActivityUnix: Number(g.lastActivityUnix()),
|
||||
seats,
|
||||
};
|
||||
}
|
||||
@@ -588,16 +587,6 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
|
||||
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[] {
|
||||
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
@@ -689,7 +678,6 @@ function emptyGame(): GameView {
|
||||
turnTimeoutSecs: 0,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix: 0,
|
||||
seats: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ export const en = {
|
||||
'about.version': 'Version {v}',
|
||||
|
||||
'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',
|
||||
|
||||
'lang.en': 'English',
|
||||
@@ -241,7 +242,6 @@ export const en = {
|
||||
'game.exportGcg': 'Export GCG',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
'game.requestSent': 'Request sent',
|
||||
'game.alreadyFriends': '✓ In friends',
|
||||
|
||||
'time.minutes': '{n} min',
|
||||
'time.hours': '{n} h',
|
||||
|
||||
@@ -154,6 +154,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'about.version': 'Версия {v}',
|
||||
|
||||
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
||||
'landing.playWeb': 'Играть в браузере',
|
||||
'landing.playTelegram': 'Играть в Telegram',
|
||||
|
||||
'lang.en': 'English',
|
||||
@@ -242,7 +243,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
'game.requestSent': 'Запрос отправлен',
|
||||
'game.alreadyFriends': '✓ В друзьях',
|
||||
|
||||
'time.minutes': '{n} мин',
|
||||
'time.hours': '{n} ч',
|
||||
|
||||
+12
-12
@@ -1,20 +1,20 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { telegramChannelLink } from './landing';
|
||||
import { telegramBotLink } from './landing';
|
||||
|
||||
describe('telegramChannelLink', () => {
|
||||
describe('telegramBotLink', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it('builds the per-language t.me link from the channel name', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
|
||||
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
|
||||
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
|
||||
it('returns the per-language bot link when configured', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game');
|
||||
expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game');
|
||||
expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game');
|
||||
});
|
||||
|
||||
it('returns null when the locale channel is unset or blank', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
|
||||
expect(telegramChannelLink('en')).toBeNull();
|
||||
expect(telegramChannelLink('ru')).toBeNull();
|
||||
it('returns null when the locale link is unset or blank', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', '');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' ');
|
||||
expect(telegramBotLink('en')).toBeNull();
|
||||
expect(telegramBotLink('ru')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+9
-13
@@ -1,20 +1,16 @@
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
|
||||
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
|
||||
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
|
||||
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
|
||||
* (the id to post, the name to link). A leading "@" is tolerated.
|
||||
* telegramBotLink returns the t.me link for the locale's game bot, or null when it is not
|
||||
* configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU)
|
||||
* because the test and prod contours run different bots (different usernames), so the link
|
||||
* cannot be hardcoded.
|
||||
*/
|
||||
export function telegramChannelLink(locale: Locale): string | null {
|
||||
const raw =
|
||||
locale === 'ru'
|
||||
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
|
||||
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
|
||||
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
|
||||
return name ? `https://t.me/${name}` : null;
|
||||
export function telegramBotLink(locale: Locale): string | null {
|
||||
const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN;
|
||||
const link = (raw as string | undefined)?.trim();
|
||||
return link ? link : null;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -90,7 +90,6 @@ export class MockGateway implements GatewayClient {
|
||||
private pendingMatch: string | null = null;
|
||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||
private outgoing: AccountRef[] = [];
|
||||
private blocks: AccountRef[] = [];
|
||||
private invitations: Invitation[] = mockInvitations();
|
||||
private readonly stats: Stats = { ...MOCK_STATS };
|
||||
@@ -156,7 +155,6 @@ export class MockGateway implements GatewayClient {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||
seats: [
|
||||
{ seat: 0, accountId: ME, displayName: 'You', 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[]> {
|
||||
return this.incoming.map((f) => ({ ...f }));
|
||||
}
|
||||
async friendsOutgoing(): Promise<AccountRef[]> {
|
||||
return this.outgoing.map((f) => ({ ...f }));
|
||||
}
|
||||
async friendRequest(accountId: string): Promise<void> {
|
||||
// The real backend requires a shared game; the mock records the outgoing request so
|
||||
// the game's "add to friends" item reads as sent across reloads.
|
||||
if (!this.outgoing.some((o) => o.accountId === accountId)) {
|
||||
this.outgoing.push({ accountId, displayName: this.nameFor(accountId) });
|
||||
}
|
||||
async friendRequest(_accountId: string): Promise<void> {
|
||||
// The real backend requires a shared game; the mock simply acknowledges.
|
||||
}
|
||||
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
|
||||
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
|
||||
|
||||
@@ -43,9 +43,10 @@ export const PROFILE: 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.
|
||||
// Ann is the active game's opponent but deliberately not a friend, so the in-game
|
||||
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
|
||||
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
|
||||
export const MOCK_FRIENDS: AccountRef[] = [
|
||||
{ accountId: 'ann', displayName: 'Ann' },
|
||||
{ accountId: 'kaya', displayName: 'Kaya' },
|
||||
];
|
||||
|
||||
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
||||
|
||||
@@ -143,7 +144,6 @@ function activeGame(): MockGame {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: G1_MOVES.length,
|
||||
endReason: '',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
|
||||
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
|
||||
},
|
||||
moves: G1_MOVES,
|
||||
@@ -177,7 +177,6 @@ function finishedG2(): MockGame {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 2,
|
||||
endReason: 'normal',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
|
||||
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
|
||||
},
|
||||
moves: [
|
||||
@@ -212,7 +211,6 @@ function finishedG3(): MockGame {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 1,
|
||||
endReason: 'resignation',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
|
||||
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
|
||||
},
|
||||
moves: [
|
||||
|
||||
@@ -40,8 +40,6 @@ export interface GameView {
|
||||
turnTimeoutSecs: number;
|
||||
moveCount: number;
|
||||
endReason: string;
|
||||
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
|
||||
lastActivityUnix: number;
|
||||
seats: Seat[];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
turnTimeoutSecs: 0,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix: 0,
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ interface TelegramWebApp {
|
||||
initDataUnsafe?: { start_param?: string };
|
||||
themeParams?: TelegramThemeParams;
|
||||
colorScheme?: 'light' | 'dark';
|
||||
isFullscreen?: boolean;
|
||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||
ready?: () => void;
|
||||
expand?: () => 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* it does not fight tile drag-and-drop or the board's vertical scroll.
|
||||
|
||||
@@ -137,9 +137,6 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async friendsIncoming() {
|
||||
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
|
||||
},
|
||||
async friendsOutgoing() {
|
||||
return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty()));
|
||||
},
|
||||
async friendRequest(accountId) {
|
||||
await exec('friends.request', codec.encodeTarget(accountId));
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { resultBadge } from '../lib/result';
|
||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||
import { groupGames } from '../lib/lobbysort';
|
||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||
|
||||
let games = $state<GameView[]>([]);
|
||||
@@ -47,7 +46,8 @@
|
||||
});
|
||||
|
||||
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 {
|
||||
return g.seats
|
||||
@@ -129,26 +129,25 @@
|
||||
</section>
|
||||
{/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}
|
||||
<section>
|
||||
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
|
||||
<div class="list">
|
||||
<h2>{t(group.h as 'lobby.activeGames')}</h2>
|
||||
{#each group.list as g (g.id)}
|
||||
{@const b = resultBadge(g, myId)}
|
||||
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
||||
<span class="info">
|
||||
<span class="who">{opponents(g) || '—'}</span>
|
||||
<span class="sub">{scoreline(g)}</span>
|
||||
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
|
||||
</span>
|
||||
<span class="emoji">{resultBadge(g, myId).emoji}</span>
|
||||
<span class="emoji">{b.emoji}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !games.length && !invitations.length}
|
||||
{#if !active.length && !finished.length && !invitations.length}
|
||||
<p class="empty">{t('lobby.noActive')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -187,6 +186,7 @@
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
.row,
|
||||
.invite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -202,31 +202,6 @@
|
||||
border-radius: var(--radius);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user