Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 356f490546 | |||
| 6b6baf5710 | |||
| b720907db2 | |||
| 34385240b9 | |||
| 3fd279cf8c |
@@ -267,8 +267,8 @@ jobs:
|
|||||||
TELEGRAM_TEST_ENV: "true"
|
TELEGRAM_TEST_ENV: "true"
|
||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
|
||||||
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||||
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||||
|
|||||||
@@ -1348,6 +1348,36 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
|||||||
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
|
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
|
||||||
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
|
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
|
||||||
the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
|
the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
|
||||||
|
- **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram
|
||||||
|
link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries
|
||||||
|
a channel **username**, the landing builds `https://t.me/<name>`; the connector keeps the matching
|
||||||
|
`..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app)
|
||||||
|
and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto").
|
||||||
|
The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
|
||||||
|
- **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle,
|
||||||
|
scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5
|
||||||
|
(touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a
|
||||||
|
rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board
|
||||||
|
cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the
|
||||||
|
board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram
|
||||||
|
fullscreen** no longer hides our header under its native nav — the whole header drops below the
|
||||||
|
content-safe-area top inset (title and the right-aligned menu both clear the nav), via
|
||||||
|
`--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way
|
||||||
|
to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our
|
||||||
|
own header and simply push it clear.)
|
||||||
|
- **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games
|
||||||
|
into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last
|
||||||
|
activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a
|
||||||
|
compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB
|
||||||
|
`GameView` gained `last_activity_unix` (the turn start while active, the finish time once
|
||||||
|
finished). The in-game **"add to friends"** item is now **server-derived** (new `GET
|
||||||
|
/user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested —
|
||||||
|
pending **or** declined, which both read as "request sent") so it is correct across reloads, shows
|
||||||
|
a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers:
|
||||||
|
`RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify
|
||||||
|
sub-kind, decline) to the **original requester**, whose open game re-derives its friend state.
|
||||||
|
Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent
|
||||||
|
reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
|
||||||
- **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists
|
- **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists
|
||||||
posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
|
posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
|
||||||
oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by
|
oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +15,36 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// capturePublisher records every published intent for assertions on live events.
|
||||||
|
type capturePublisher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
intents []notify.Intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capturePublisher) Publish(in ...notify.Intent) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.intents = append(c.intents, in...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notified reports whether a Notification with the given sub-kind was published to user.
|
||||||
|
func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for _, in := range c.intents {
|
||||||
|
if in.UserID == user && in.Kind == notify.KindNotification &&
|
||||||
|
string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// newSocialService builds a social service over the shared pool, reading game
|
// newSocialService builds a social service over the shared pool, reading game
|
||||||
// state through a real game service.
|
// state through a real game service.
|
||||||
func newSocialService() *social.Service {
|
func newSocialService() *social.Service {
|
||||||
@@ -384,6 +412,96 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
||||||
|
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
|
||||||
|
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
||||||
|
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
||||||
|
func TestListOutgoingRequests(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
|
||||||
|
// Pending: outgoing for the requester, not the addressee.
|
||||||
|
_, s1 := newGameWithSeats(t, 2)
|
||||||
|
a, b := s1[0], s1[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b {
|
||||||
|
t.Fatalf("outgoing pending = %v, want [b]", got)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 {
|
||||||
|
t.Fatalf("addressee outgoing = %v, want none", got)
|
||||||
|
}
|
||||||
|
// Accepted: a friendship, no longer an outgoing request.
|
||||||
|
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||||
|
t.Fatalf("accept: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 {
|
||||||
|
t.Fatalf("outgoing after accept = %v, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declined: stays outgoing (reads as sent; cannot re-send).
|
||||||
|
_, s2 := newGameWithSeats(t, 2)
|
||||||
|
c, d := s2[0], s2[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||||
|
t.Fatalf("send2: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||||
|
t.Fatalf("decline: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d {
|
||||||
|
t.Fatalf("outgoing after decline = %v, want [d]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily expired pending: omitted (may be re-sent).
|
||||||
|
_, s3 := newGameWithSeats(t, 2)
|
||||||
|
e, f := s3[0], s3[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, e, f); err != nil {
|
||||||
|
t.Fatalf("send3: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 {
|
||||||
|
t.Fatalf("expired outgoing = %v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
||||||
|
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
|
||||||
|
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
||||||
|
func TestRespondPublishesToRequester(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
pub := &capturePublisher{}
|
||||||
|
svc.SetNotifier(pub)
|
||||||
|
|
||||||
|
_, s1 := newGameWithSeats(t, 2)
|
||||||
|
a, b := s1[0], s1[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||||
|
t.Fatalf("accept: %v", err)
|
||||||
|
}
|
||||||
|
if !pub.notified(a, notify.NotifyFriendAdded) {
|
||||||
|
t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, s2 := newGameWithSeats(t, 2)
|
||||||
|
c, d := s2[0], s2[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||||
|
t.Fatalf("send2: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||||
|
t.Fatalf("decline: %v", err)
|
||||||
|
}
|
||||||
|
if !pub.notified(c, notify.NotifyFriendDeclined) {
|
||||||
|
t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
|
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
|
||||||
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||||
func TestAdminListMessages(t *testing.T) {
|
func TestAdminListMessages(t *testing.T) {
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ func MatchFound(userID, gameID uuid.UUID) Intent {
|
|||||||
|
|
||||||
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
||||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||||
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
|
// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
|
||||||
// scope its refresh.
|
// client may use to scope its refresh.
|
||||||
func Notification(userID uuid.UUID, kind string) Intent {
|
func Notification(userID uuid.UUID, kind string) Intent {
|
||||||
b := flatbuffers.NewBuilder(32)
|
b := flatbuffers.NewBuilder(32)
|
||||||
k := b.CreateString(kind)
|
k := b.CreateString(kind)
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const (
|
|||||||
const (
|
const (
|
||||||
NotifyFriendRequest = "friend_request"
|
NotifyFriendRequest = "friend_request"
|
||||||
NotifyFriendAdded = "friend_added"
|
NotifyFriendAdded = "friend_added"
|
||||||
|
// NotifyFriendDeclined tells the original requester their request was declined, so a
|
||||||
|
// game screen watching that opponent re-derives its "add to friends" state.
|
||||||
|
NotifyFriendDeclined = "friend_declined"
|
||||||
NotifyInvitation = "invitation"
|
NotifyInvitation = "invitation"
|
||||||
NotifyGameStarted = "game_started"
|
NotifyGameStarted = "game_started"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ type gameDTO struct {
|
|||||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
|
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||||
|
// game, the finish time once finished (Stage 17).
|
||||||
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
Seats []seatDTO `json:"seats"`
|
Seats []seatDTO `json:"seats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +192,10 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
IsWinner: s.IsWinner,
|
IsWinner: s.IsWinner,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
last := g.TurnStartedAt
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
last = *g.FinishedAt
|
||||||
|
}
|
||||||
return gameDTO{
|
return gameDTO{
|
||||||
ID: g.ID.String(),
|
ID: g.ID.String(),
|
||||||
Variant: g.Variant.String(),
|
Variant: g.Variant.String(),
|
||||||
@@ -199,6 +206,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
|
LastActivityUnix: last.Unix(),
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/games/:id/nudge", s.handleNudge)
|
u.POST("/games/:id/nudge", s.handleNudge)
|
||||||
u.GET("/friends", s.handleListFriends)
|
u.GET("/friends", s.handleListFriends)
|
||||||
u.GET("/friends/incoming", s.handleIncomingRequests)
|
u.GET("/friends/incoming", s.handleIncomingRequests)
|
||||||
|
u.GET("/friends/outgoing", s.handleOutgoingRequests)
|
||||||
u.POST("/friends/request", s.handleFriendRequest)
|
u.POST("/friends/request", s.handleFriendRequest)
|
||||||
u.POST("/friends/respond", s.handleFriendRespond)
|
u.POST("/friends/respond", s.handleFriendRespond)
|
||||||
u.POST("/friends/cancel", s.handleFriendCancel)
|
u.POST("/friends/cancel", s.handleFriendCancel)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type incomingListDTO struct {
|
|||||||
Requests []accountRefDTO `json:"requests"`
|
Requests []accountRefDTO `json:"requests"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// outgoingListDTO is the addressees the caller has already requested (a live pending
|
||||||
|
// request or one the addressee declined) and therefore cannot re-request.
|
||||||
|
type outgoingListDTO struct {
|
||||||
|
Requests []accountRefDTO `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
// friendCodeDTO is a freshly issued one-time friend code (returned once).
|
// friendCodeDTO is a freshly issued one-time friend code (returned once).
|
||||||
type friendCodeDTO struct {
|
type friendCodeDTO struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -218,6 +224,22 @@ func (s *Server) handleIncomingRequests(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOutgoingRequests returns the addressees the caller has already requested
|
||||||
|
// (pending or declined) and cannot re-request.
|
||||||
|
func (s *Server) handleOutgoingRequests(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, outgoingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
||||||
|
}
|
||||||
|
|
||||||
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
|
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
|
||||||
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ErrRequestNotFound
|
return ErrRequestNotFound
|
||||||
}
|
}
|
||||||
|
// Tell the original requester their request was answered, so a game screen watching
|
||||||
|
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
|
||||||
|
// -> stays "request sent").
|
||||||
|
if accept {
|
||||||
|
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded))
|
||||||
|
} else {
|
||||||
|
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +164,14 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI
|
|||||||
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
||||||
|
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
|
||||||
|
// permanently declined. The game's "add to friends" item reads it to stay disabled
|
||||||
|
// across reloads (a declined request reads identically to a still-pending one).
|
||||||
|
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||||
|
return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||||
|
}
|
||||||
|
|
||||||
// loadEdges returns every friendship row between a and b in either direction (at
|
// loadEdges returns every friendship row between a and b in either direction (at
|
||||||
// most one per direction). It feeds SendFriendRequest's re-send classification.
|
// most one per direction). It feeds SendFriendRequest's re-send classification.
|
||||||
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
|
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
|
||||||
@@ -294,6 +310,29 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listOutgoingRequests returns the addressees of the caller's requests that block a
|
||||||
|
// re-send: a live (created after cutoff) pending request, or a permanently declined
|
||||||
|
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
|
||||||
|
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
|
||||||
|
stmt := postgres.SELECT(table.Friendships.AddresseeID).
|
||||||
|
FROM(table.Friendships).
|
||||||
|
WHERE(
|
||||||
|
table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
|
||||||
|
OR(table.Friendships.Status.EQ(postgres.String(friendPending)).
|
||||||
|
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))),
|
||||||
|
)
|
||||||
|
var rows []model.Friendships
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("social: list outgoing requests: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]uuid.UUID, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, r.AddresseeID)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// edgeEither matches a friendship row between a and b in either direction.
|
// edgeEither matches a friendship row between a and b in either direction.
|
||||||
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
||||||
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
|||||||
# --- UI build args (baked into the gateway image) ---------------------------
|
# --- UI build args (baked into the gateway image) ---------------------------
|
||||||
VITE_TELEGRAM_BOT_ID=
|
VITE_TELEGRAM_BOT_ID=
|
||||||
VITE_TELEGRAM_LINK=
|
VITE_TELEGRAM_LINK=
|
||||||
VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot
|
||||||
VITE_TELEGRAM_LINK_RU= # landing "Play in Telegram" link, Russian bot
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
|
||||||
VITE_GATEWAY_URL=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Gateway ----------------------------------------------------------------
|
# --- Gateway ----------------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -84,8 +84,8 @@ connector **fails at boot** if both are empty.
|
|||||||
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
|
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
|
||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). |
|
||||||
| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
|
||||||
| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
|
|
||||||
The five `VITE_*` are **build-args** baked into the gateway image at build time, so
|
The five `VITE_*` are **build-args** baked into the gateway image at build time, so
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ services:
|
|||||||
args:
|
args:
|
||||||
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
|
||||||
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -477,8 +477,10 @@ including the mover**, so the mover's own other devices and their lobby refresh
|
|||||||
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
||||||
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
||||||
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||||
friend-added, invitation or game-started; emitted on a friend-request and invitation
|
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
|
||||||
create and on an invitation's game start). Event payloads are FlatBuffers-encoded by
|
on answering one (accept → friend-added, decline → friend-declined — to the original
|
||||||
|
requester, so a game screen watching that opponent re-derives its "add to friends" state,
|
||||||
|
Stage 17), and on an invitation create or its game start). Event payloads are FlatBuffers-encoded by
|
||||||
the backend and forwarded verbatim. A client that is not currently streaming falls
|
the backend and forwarded verbatim. A client that is not currently streaming falls
|
||||||
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
|
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
|
||||||
badge** (incoming friend requests + open invitations), the client polls on lobby
|
badge** (incoming friend requests + open invitations), the client polls on lobby
|
||||||
|
|||||||
+12
-4
@@ -22,8 +22,9 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
|
|||||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
A public **landing page** at the site root introduces the game, switches language and
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links into the web app or the matching Telegram bot; the game itself runs at
|
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
||||||
`/app/` (web) and `/telegram/` (the Telegram Mini App).
|
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||||||
|
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||||
|
|
||||||
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
||||||
A player arrives from a platform (Telegram first), via email login, or as an
|
A player arrives from a platform (Telegram first), via email login, or as an
|
||||||
@@ -57,7 +58,11 @@ account is kept and the guest's games move into it. A merge is blocked only whil
|
|||||||
two accounts share a game still in progress.
|
two accounts share a game still in progress.
|
||||||
|
|
||||||
### Lobby & matchmaking *(Stage 4 / 15)*
|
### Lobby & matchmaking *(Stage 4 / 15)*
|
||||||
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
|
Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
|
||||||
|
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||||||
|
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||||||
|
opponent-turn and finished games are most-recent first; it renders as a compact,
|
||||||
|
line-separated list (Stage 17). The game types offered on **New Game** are
|
||||||
limited to the languages the player's sign-in service supports (English → Scrabble;
|
limited to the languages the player's sign-in service supports (English → Scrabble;
|
||||||
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
|
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
|
||||||
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
|
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
|
||||||
@@ -110,7 +115,10 @@ digits, valid for twelve hours), or send a **request to someone you have played
|
|||||||
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||||||
re-sent), or decline (a decline blocks further requests from you until they hand you
|
re-sent), or decline (a decline blocks further requests from you until they hand you
|
||||||
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
||||||
friendship. Block globally — switch off incoming chat
|
friendship. In a game, an **add to friends** item for each opponent mirrors the live
|
||||||
|
relationship: it reads *request sent* (disabled) while a request is pending or was
|
||||||
|
declined, and *in friends* once accepted — updating in place the moment the opponent
|
||||||
|
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
|
||||||
and/or friend requests — and block individual players (a per-user block hides that
|
and/or friend requests — and block individual players (a per-user block hides that
|
||||||
person's chat and stops requests and game invitations both ways; it also ends any
|
person's chat and stops requests and game invitations both ways; it also ends any
|
||||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||||
|
|||||||
+12
-4
@@ -23,8 +23,9 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по
|
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||||
адресам `/app/` (веб) и `/telegram/` (Telegram Mini App).
|
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||||
|
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||||
|
|
||||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
||||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||||
@@ -58,7 +59,11 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||||
|
|
||||||
### Лобби и подбор *(Stage 4 / 15)*
|
### Лобби и подбор *(Stage 4 / 15)*
|
||||||
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
|
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||||
|
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||||
|
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||||
|
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||||
|
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
|
||||||
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
||||||
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
||||||
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
||||||
@@ -112,7 +117,10 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||||
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие
|
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||||
|
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||||
|
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||||
|
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
|
||||||
чат и/или заявки —
|
чат и/или заявки —
|
||||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||||
|
|||||||
+4
-4
@@ -20,14 +20,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
|||||||
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
||||||
ARG VITE_TELEGRAM_BOT_ID=
|
ARG VITE_TELEGRAM_BOT_ID=
|
||||||
ARG VITE_TELEGRAM_LINK=
|
ARG VITE_TELEGRAM_LINK=
|
||||||
ARG VITE_TELEGRAM_LINK_EN=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
|
||||||
ARG VITE_TELEGRAM_LINK_RU=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
|
||||||
ARG VITE_GATEWAY_URL=
|
ARG VITE_GATEWAY_URL=
|
||||||
ARG VITE_APP_VERSION=
|
ARG VITE_APP_VERSION=
|
||||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
|
||||||
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
|
||||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ type GameResp struct {
|
|||||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
Seats []SeatResp `json:"seats"`
|
Seats []SeatResp `json:"seats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type IncomingListResp struct {
|
|||||||
Requests []AccountRefResp `json:"requests"`
|
Requests []AccountRefResp `json:"requests"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OutgoingListResp is the addressees the caller has already requested (a live pending
|
||||||
|
// request or one the addressee declined) and cannot re-request.
|
||||||
|
type OutgoingListResp struct {
|
||||||
|
Requests []AccountRefResp `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
// FriendCodeResp is a freshly issued one-time friend code.
|
// FriendCodeResp is a freshly issued one-time friend code.
|
||||||
type FriendCodeResp struct {
|
type FriendCodeResp struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -134,6 +140,14 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListOutgoing returns the addressees the caller has already requested (pending or
|
||||||
|
// declined) and cannot re-request.
|
||||||
|
func (c *Client) ListOutgoing(ctx context.Context, userID string) (OutgoingListResp, error) {
|
||||||
|
var out OutgoingListResp
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/outgoing", userID, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// IssueFriendCode issues a one-time friend code for the caller.
|
// IssueFriendCode issues a one-time friend code for the caller.
|
||||||
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
||||||
var out FriendCodeResp
|
var out FriendCodeResp
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
|
|||||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||||
fb.GameViewAddEndReason(b, endReason)
|
fb.GameViewAddEndReason(b, endReason)
|
||||||
fb.GameViewAddSeats(b, seats)
|
fb.GameViewAddSeats(b, seats)
|
||||||
|
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeOutgoingList builds an OutgoingRequestList payload.
|
||||||
|
func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector)
|
||||||
|
fb.OutgoingRequestListStart(b)
|
||||||
|
fb.OutgoingRequestListAddRequests(b, v)
|
||||||
|
b.Finish(fb.OutgoingRequestListEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
// encodeBlockList builds a BlockList payload.
|
// encodeBlockList builds a BlockList payload.
|
||||||
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(256)
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
MsgFriendsList = "friends.list"
|
MsgFriendsList = "friends.list"
|
||||||
MsgFriendsIncoming = "friends.incoming"
|
MsgFriendsIncoming = "friends.incoming"
|
||||||
|
MsgFriendsOutgoing = "friends.outgoing"
|
||||||
MsgFriendRequest = "friends.request"
|
MsgFriendRequest = "friends.request"
|
||||||
MsgFriendRespond = "friends.respond"
|
MsgFriendRespond = "friends.respond"
|
||||||
MsgFriendCancel = "friends.cancel"
|
MsgFriendCancel = "friends.cancel"
|
||||||
@@ -37,6 +38,7 @@ const (
|
|||||||
func registerStage8(r *Registry, backend *backendclient.Client) {
|
func registerStage8(r *Registry, backend *backendclient.Client) {
|
||||||
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
|
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
|
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
|
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
|
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
|
||||||
@@ -78,6 +80,16 @@ func friendsIncomingHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func friendsOutgoingHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
res, err := backend.ListOutgoing(ctx, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeOutgoingList(res), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func friendRequestHandler(backend *backendclient.Client) Handler {
|
func friendRequestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsTargetRequest(req.Payload, 0)
|
in := fb.GetRootAsTargetRequest(req.Payload, 0)
|
||||||
|
|||||||
@@ -54,6 +54,35 @@ func TestFriendsListRoundTripDecodesNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFriendsOutgoingRoundTrip(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/user/friends/outgoing" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}]}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgFriendsOutgoing)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("friends.outgoing not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
ol := fb.GetRootAsOutgoingRequestList(payload, 0)
|
||||||
|
if ol.RequestsLength() != 1 {
|
||||||
|
t.Fatalf("outgoing length = %d, want 1", ol.RequestsLength())
|
||||||
|
}
|
||||||
|
var ref fb.AccountRef
|
||||||
|
ol.Requests(&ref, 0)
|
||||||
|
if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" {
|
||||||
|
t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFriendRequestForwardsTarget(t *testing.T) {
|
func TestFriendRequestForwardsTarget(t *testing.T) {
|
||||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games" {
|
if r.URL.Path != "/api/v1/user/games" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -177,6 +177,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if string(g.Id()) != "g-1" {
|
if string(g.Id()) != "g-1" {
|
||||||
t.Errorf("game id = %q, want g-1", g.Id())
|
t.Errorf("game id = %q, want g-1", g.Id())
|
||||||
}
|
}
|
||||||
|
if g.LastActivityUnix() != 1717000000 {
|
||||||
|
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
|
||||||
|
}
|
||||||
var seat fb.SeatView
|
var seat fb.SeatView
|
||||||
g.Seats(&seat, 1)
|
g.Seats(&seat, 1)
|
||||||
if string(seat.DisplayName()) != "Ann" {
|
if string(seat.DisplayName()) != "Ann" {
|
||||||
|
|||||||
+13
-2
@@ -66,6 +66,9 @@ table GameView {
|
|||||||
move_count:int;
|
move_count:int;
|
||||||
end_reason:string;
|
end_reason:string;
|
||||||
seats:[SeatView];
|
seats:[SeatView];
|
||||||
|
// last_activity_unix is the lobby sort key: the current turn's start for an active
|
||||||
|
// game, the finish time for a finished one (Stage 17).
|
||||||
|
last_activity_unix:long;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
||||||
@@ -389,6 +392,13 @@ table IncomingRequestList {
|
|||||||
requests:[AccountRef];
|
requests:[AccountRef];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OutgoingRequestList is the accounts the caller has already requested and cannot
|
||||||
|
// (re-)request: a live pending request or one the addressee declined. The game's
|
||||||
|
// "add to friends" item reads it to stay disabled across reloads (Stage 17).
|
||||||
|
table OutgoingRequestList {
|
||||||
|
requests:[AccountRef];
|
||||||
|
}
|
||||||
|
|
||||||
// FriendCode is a freshly issued one-time add-a-friend code (returned once).
|
// FriendCode is a freshly issued one-time add-a-friend code (returned once).
|
||||||
table FriendCode {
|
table FriendCode {
|
||||||
code:string;
|
code:string;
|
||||||
@@ -492,8 +502,9 @@ table MatchFoundEvent {
|
|||||||
|
|
||||||
// NotificationEvent is a lightweight "something changed, re-poll" signal that
|
// NotificationEvent is a lightweight "something changed, re-poll" signal that
|
||||||
// drives the lobby badge (incoming friend requests, invitations). kind is a sub-
|
// drives the lobby badge (incoming friend requests, invitations). kind is a sub-
|
||||||
// discriminator ("friend_request", "friend_added", "invitation", "game_started");
|
// discriminator ("friend_request", "friend_added", "friend_declined", "invitation",
|
||||||
// the client re-fetches its lobby counters on any of them.
|
// "game_started"); the client re-fetches its lobby counters (and, for a requester
|
||||||
|
// watching a game, its friend state) on any of them.
|
||||||
table NotificationEvent {
|
table NotificationEvent {
|
||||||
kind:string;
|
kind:string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,20 @@ func (rcv *GameView) SeatsLength() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) LastActivityUnix() int64 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) MutateLastActivityUnix(n int64) bool {
|
||||||
|
return rcv._tab.MutateInt64Slot(24, n)
|
||||||
|
}
|
||||||
|
|
||||||
func GameViewStart(builder *flatbuffers.Builder) {
|
func GameViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(10)
|
builder.StartObject(11)
|
||||||
}
|
}
|
||||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
||||||
@@ -185,6 +197,9 @@ func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT)
|
|||||||
func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
}
|
}
|
||||||
|
func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) {
|
||||||
|
builder.PrependInt64Slot(10, lastActivityUnix, 0)
|
||||||
|
}
|
||||||
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutgoingRequestList struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &OutgoingRequestList{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &OutgoingRequestList{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *OutgoingRequestList) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *OutgoingRequestList) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *OutgoingRequestList) Requests(obj *AccountRef, j int) bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
x := rcv._tab.Vector(o)
|
||||||
|
x += flatbuffers.UOffsetT(j) * 4
|
||||||
|
x = rcv._tab.Indirect(x)
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *OutgoingRequestList) RequestsLength() int {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.VectorLen(o)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func OutgoingRequestListStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(1)
|
||||||
|
}
|
||||||
|
func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0)
|
||||||
|
}
|
||||||
|
func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
}
|
||||||
|
func OutgoingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
+1
-1
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
|||||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
|
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
|
||||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||||
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU`
|
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
|
||||||
are the per-language "Play in Telegram" links shown on the landing page (Stage 17).
|
are the per-language "Play in Telegram" links shown on the landing page (Stage 17).
|
||||||
|
|
||||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||||
|
|||||||
+14
-7
@@ -1,14 +1,21 @@
|
|||||||
import { expect, test } from './fixtures';
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
|
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
|
||||||
// the game SPA moves to /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
|
// the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
|
||||||
test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => {
|
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
|
||||||
await page.goto('/landing.html');
|
await page.goto('/landing.html');
|
||||||
|
|
||||||
// The primary call to action opens the web app mount.
|
// The tagline renders (English in the default test browser).
|
||||||
await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/');
|
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
||||||
|
|
||||||
// The language switch flips the copy to Russian (reusing the app i18n).
|
// The language dropdown switches the copy to Russian.
|
||||||
await page.getByRole('button', { name: 'Русский' }).click();
|
await page.getByRole('button', { name: 'Language' }).click();
|
||||||
await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible();
|
await page.getByRole('menuitem', { name: /Русский/ }).click();
|
||||||
|
await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible();
|
||||||
|
|
||||||
|
// The theme toggle flips the document theme (ephemeral, light<->dark).
|
||||||
|
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
await page.getByRole('button', { name: 'Theme' }).click();
|
||||||
|
const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(after).not.toBe(before);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Active games')).toBeVisible();
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
const activeRow = page.getByRole('button', { name: /Ann/ });
|
const activeRow = page.getByRole('button', { name: /Ann/ });
|
||||||
await expect(activeRow).toBeVisible();
|
await expect(activeRow).toBeVisible();
|
||||||
await activeRow.click();
|
await activeRow.click();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures';
|
|||||||
async function loginLobby(page: Page): Promise<void> {
|
async function loginLobby(page: Page): Promise<void> {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
await expect(page.getByText('Active games')).toBeVisible();
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFriends(page: Page): Promise<void> {
|
async function openFriends(page: Page): Promise<void> {
|
||||||
@@ -107,7 +107,7 @@ test('play with friends: a game type is required to send an invitation', async (
|
|||||||
await expect(send).toBeEnabled();
|
await expect(send).toBeEnabled();
|
||||||
|
|
||||||
await send.click(); // the mock creates it and returns to the lobby
|
await send.click(); // the mock creates it and returns to the lobby
|
||||||
await expect(page.getByText('Active games')).toBeVisible();
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => {
|
test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// No guest-login click: the Mini App authenticates from initData and lands on the lobby.
|
// No guest-login click: the Mini App authenticates from initData and lands on the lobby.
|
||||||
await expect(page.getByText('Active games')).toBeVisible();
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
|
|
||||||
// The Telegram themeParams override the background token at runtime.
|
// The Telegram themeParams override the background token at runtime.
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
+12
-10
@@ -1,14 +1,16 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР">
|
||||||
<rect width="24" height="16" fill="#cc0000"/>
|
<rect width="24" height="16" fill="#cc0000"/>
|
||||||
<!-- five-pointed star (filled, slightly smaller) -->
|
<!-- five-pointed star (scaled up ~25% around its centre per review) -->
|
||||||
<path fill="#ffd700" d="M6 2.4l.78 1.6 1.76.26-1.27 1.24.3 1.75L6 6.63l-1.57.82.3-1.75L3.46 4.5l1.76-.26z"/>
|
<path fill="#ffd700" transform="translate(6 3.17) scale(1.25) translate(-6 -3.17)" d="M6 1.9 L6.32 2.86 7.33 2.87 6.51 3.47 6.82 4.43 6 3.84 5.18 4.43 5.49 3.47 4.67 2.87 5.68 2.86 Z"/>
|
||||||
<!-- schematic hammer & sickle (a sketch, thin strokes) -->
|
<g fill="none" stroke="#ffd700" stroke-linecap="round" stroke-linejoin="round" transform="translate(6.8 6) scale(0.667) translate(-6.8 -6)">
|
||||||
<g fill="none" stroke="#ffd700" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round">
|
<!-- sickle: a crescent blade + short handle, mirrored across a diagonal through its centre
|
||||||
<!-- sickle: an elongated semicircle blade with a short handle -->
|
so it reads as the canonical sickle (blade sweeping down-right); the hammer is untouched -->
|
||||||
<path d="M8.2 7.4a3 3 0 1 1-3.3 3.9"/>
|
<g transform="matrix(0 1 1 0 -2.8 2.8)">
|
||||||
<path d="M4.9 11.3l-.8.7"/>
|
<path stroke-width="0.6" d="M8.1 6.0 C 10.7 6.9 10.9 11.3 7.2 13.3 C 5.1 14.5 2.9 13.2 2.7 10.9"/>
|
||||||
<!-- hammer: a T-shape (handle + head) crossing the sickle -->
|
<path stroke-width="0.6" d="M8.1 6.0 l 0.85 -0.95"/>
|
||||||
<path d="M5.1 11 8.1 8"/>
|
</g>
|
||||||
<path d="M7.2 7.1 9 8.9"/>
|
<!-- hammer: handle (down-right) + head (a short bar) at ~90°, crossing the sickle -->
|
||||||
|
<path stroke-width="0.78" d="M4.6 8.4 L 8.4 12.9"/>
|
||||||
|
<path stroke-width="0.78" d="M3.25 9.05 L 5.95 7.05"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 760 B After Width: | Height: | Size: 1.2 KiB |
+96
-82
@@ -1,89 +1,84 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme';
|
import { applyTheme } from './lib/theme';
|
||||||
import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } from './lib/i18n/index.svelte';
|
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
|
||||||
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
||||||
import { aboutContent } from './lib/aboutContent';
|
import { aboutContent } from './lib/aboutContent';
|
||||||
import { telegramBotLink } from './lib/landing';
|
import { telegramChannelLink } from './lib/landing';
|
||||||
|
|
||||||
// Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA
|
// Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and
|
||||||
// (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but
|
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
|
||||||
// not the app store — so it stays light (no gateway, auth or live stream).
|
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
|
||||||
|
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
|
||||||
|
|
||||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
let theme = $state<'light' | 'dark'>('light');
|
||||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
let langOpen = $state(false);
|
||||||
auto: 'settings.themeAuto',
|
|
||||||
light: 'settings.themeLight',
|
|
||||||
dark: 'settings.themeDark',
|
|
||||||
};
|
|
||||||
const locales: Locale[] = ['en', 'ru'];
|
|
||||||
|
|
||||||
let theme = $state<ThemePref>('auto');
|
|
||||||
let prefs: Partial<Prefs> = {};
|
let prefs: Partial<Prefs> = {};
|
||||||
|
|
||||||
// The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h).
|
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
|
||||||
const about = $derived(aboutContent(i18n.locale, 24));
|
const tgLink = $derived(telegramChannelLink(i18n.locale));
|
||||||
const tgLink = $derived(telegramBotLink(i18n.locale));
|
const locales: { code: Locale; label: string }[] = [
|
||||||
|
{ code: 'en', label: '🇬🇧 English' },
|
||||||
|
{ code: 'ru', label: '🇷🇺 Русский' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function systemTheme(): 'light' | 'dark' {
|
||||||
|
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
prefs = await loadPrefs();
|
prefs = await loadPrefs();
|
||||||
theme = prefs.theme ?? 'auto';
|
theme = systemTheme();
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
applyReduceMotion(prefs.reduceMotion ?? false);
|
|
||||||
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
||||||
});
|
});
|
||||||
|
|
||||||
function persist(): void {
|
function toggleTheme(): void {
|
||||||
// savePrefs takes the full set, so keep the labels/lines the app may have stored.
|
theme = theme === 'light' ? 'dark' : 'light';
|
||||||
|
applyTheme(theme); // ephemeral — deliberately not persisted
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseLocale(lc: Locale): void {
|
||||||
|
setLocale(lc);
|
||||||
|
langOpen = false;
|
||||||
|
// Persist the language only, keeping the app's other prefs (notably its own persisted theme).
|
||||||
void savePrefs({
|
void savePrefs({
|
||||||
theme,
|
theme: prefs.theme ?? 'auto',
|
||||||
locale: i18n.locale,
|
locale: lc,
|
||||||
reduceMotion: prefs.reduceMotion ?? false,
|
reduceMotion: prefs.reduceMotion ?? false,
|
||||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||||
boardLines: prefs.boardLines ?? false,
|
boardLines: prefs.boardLines ?? false,
|
||||||
});
|
});
|
||||||
}
|
prefs = { ...prefs, locale: lc };
|
||||||
function chooseTheme(th: ThemePref): void {
|
|
||||||
theme = th;
|
|
||||||
applyTheme(th);
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
function chooseLocale(lc: Locale): void {
|
|
||||||
setLocale(lc);
|
|
||||||
persist();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="landing">
|
<main class="landing">
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="seg">
|
<div class="lang">
|
||||||
{#each locales as lc (lc)}
|
<button class="icon" aria-label="Language" aria-expanded={langOpen} onclick={() => (langOpen = !langOpen)}>🌐</button>
|
||||||
<button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}>
|
{#if langOpen}
|
||||||
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
</button>
|
<button class="backdrop" onclick={() => (langOpen = false)}></button>
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
{#each locales as l (l.code)}
|
||||||
|
<button role="menuitem" class:on={i18n.locale === l.code} onclick={() => chooseLocale(l.code)}>{l.label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="seg">
|
{/if}
|
||||||
{#each themes as th (th)}
|
|
||||||
<button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
|
|
||||||
{t(themeLabel[th])}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="icon" aria-label="Theme" onclick={toggleTheme}>{theme === 'light' ? '☼' : '☾'}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>{about.title}</h1>
|
<h1>{about.title}</h1>
|
||||||
<p class="tagline">{t('landing.tagline')}</p>
|
<p class="tagline">{t('landing.tagline')}</p>
|
||||||
<div class="cta">
|
|
||||||
<a class="play primary" href="/app/">{t('landing.playWeb')}</a>
|
|
||||||
{#if tgLink}
|
{#if tgLink}
|
||||||
<a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer">
|
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
|
||||||
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
||||||
{t('landing.playTelegram')}
|
{t('landing.playTelegram')}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info">
|
<section class="info">
|
||||||
@@ -125,32 +120,65 @@
|
|||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.seg {
|
.lang {
|
||||||
display: flex;
|
position: relative;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
.opt {
|
.icon {
|
||||||
padding: 7px 12px;
|
min-width: 40px;
|
||||||
border: 1px solid var(--border);
|
min-height: 40px;
|
||||||
background: var(--surface);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
user-select: none;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
.opt.active {
|
.backdrop {
|
||||||
background: var(--accent);
|
position: fixed;
|
||||||
color: var(--accent-text);
|
inset: 0;
|
||||||
border-color: var(--accent);
|
z-index: 8;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 9;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.menu button {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu button:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.menu button.on {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
padding: 24px 0 8px;
|
padding: 24px 0 8px;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
@@ -164,33 +192,20 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
.cta {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.play {
|
.play {
|
||||||
|
align-self: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 12px 22px;
|
padding: 12px 24px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.play.primary {
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
border-color: var(--accent);
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.play.tg {
|
.play img {
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.play.tg img {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.info {
|
.info {
|
||||||
@@ -225,7 +240,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
.ft {
|
.ft {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--gap: 8px;
|
--gap: 8px;
|
||||||
--pad: 12px;
|
--pad: 12px;
|
||||||
|
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
||||||
|
content-safe-area inset (Stage 17), 0 elsewhere. */
|
||||||
|
--tg-content-top: 0px;
|
||||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
"Noto Sans", "Liberation Sans", sans-serif;
|
"Noto Sans", "Liberation Sans", sans-serif;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -89,4 +89,11 @@
|
|||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
/* Telegram fullscreen: its native nav overlays the top of the viewport (height
|
||||||
|
--tg-content-top, set from the content-safe-area inset). Drop the header content below the
|
||||||
|
nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the
|
||||||
|
corners, leaving the centre clear (Stage 17). */
|
||||||
|
:global(html.tg-fullscreen) .bar {
|
||||||
|
padding-top: var(--tg-content-top);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -277,6 +277,9 @@
|
|||||||
}
|
}
|
||||||
.cell.pending {
|
.cell.pending {
|
||||||
background: var(--tile-pending);
|
background: var(--tile-pending);
|
||||||
|
/* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed
|
||||||
|
board) instead of the touch starting a board pan (Stage 17). */
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
|
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
|
||||||
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
|
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
|
||||||
|
|||||||
+69
-11
@@ -60,7 +60,7 @@
|
|||||||
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
|
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
|
||||||
let resignOpen = $state(false);
|
let resignOpen = $state(false);
|
||||||
let messages = $state<ChatMessage[]>([]);
|
let messages = $state<ChatMessage[]>([]);
|
||||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
|
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
|
||||||
|
|
||||||
const checkedWords = new Map<string, boolean>();
|
const checkedWords = new Map<string, boolean>();
|
||||||
let cooling = $state(false);
|
let cooling = $state(false);
|
||||||
@@ -70,7 +70,11 @@
|
|||||||
const premium = $derived(premiumGrid(variant));
|
const premium = $derived(premiumGrid(variant));
|
||||||
const ctr = $derived(centre(variant));
|
const ctr = $derived(centre(variant));
|
||||||
const pendingMap = $derived(
|
const pendingMap = $derived(
|
||||||
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
|
new Map(
|
||||||
|
placement.pending
|
||||||
|
.filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col))
|
||||||
|
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
|
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
|
||||||
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
|
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
|
||||||
@@ -185,6 +189,7 @@
|
|||||||
rackIds = cached.view.rack.map((_, i) => i);
|
rackIds = cached.view.rack.map((_, i) => i);
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
|
void loadFriends();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -197,6 +202,9 @@
|
|||||||
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
|
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
|
||||||
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
|
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
|
||||||
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
|
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
|
||||||
|
// A request the player sent was answered (accepted -> now friends; declined -> stays
|
||||||
|
// "request sent"): re-derive the in-game friend state.
|
||||||
|
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tick the nudge cooldown while the chat is open so the control re-enables on time.
|
// Tick the nudge cooldown while the chat is open so the control re-enables on time.
|
||||||
@@ -228,6 +236,9 @@
|
|||||||
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
|
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
|
||||||
let reorderDragId = $state<number | null>(null);
|
let reorderDragId = $state<number | null>(null);
|
||||||
let reorderTo = $state<number | null>(null);
|
let reorderTo = $state<number | null>(null);
|
||||||
|
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
|
||||||
|
// hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17).
|
||||||
|
let draggingPend = $state<{ row: number; col: number } | null>(null);
|
||||||
|
|
||||||
let dragPointerId = -1;
|
let dragPointerId = -1;
|
||||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||||
@@ -261,10 +272,10 @@
|
|||||||
if (busy || gameOver) return;
|
if (busy || gameOver) return;
|
||||||
beginDrag({ from: 'rack', index }, e);
|
beginDrag({ from: 'rack', index }, e);
|
||||||
}
|
}
|
||||||
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when
|
// A placed (pending) tile can be dragged to relocate it on the board or back to the rack —
|
||||||
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
|
// works zoomed too (the tile has touch-action:none, so its drag wins over the board pan).
|
||||||
function onBoardDown(e: PointerEvent, row: number, col: number) {
|
function onBoardDown(e: PointerEvent, row: number, col: number) {
|
||||||
if (busy || zoomed || gameOver) return;
|
if (busy || gameOver) return;
|
||||||
beginDrag({ from: 'board', row, col }, e);
|
beginDrag({ from: 'board', row, col }, e);
|
||||||
}
|
}
|
||||||
function cellUnder(x: number, y: number): { row: number; col: number } | null {
|
function cellUnder(x: number, y: number): { row: number; col: number } | null {
|
||||||
@@ -283,6 +294,7 @@
|
|||||||
function clearReorder() {
|
function clearReorder() {
|
||||||
reorderDragId = null;
|
reorderDragId = null;
|
||||||
reorderTo = null;
|
reorderTo = null;
|
||||||
|
draggingPend = null;
|
||||||
}
|
}
|
||||||
// overRack reports whether y is within the rack's row (a small margin makes the target
|
// overRack reports whether y is within the rack's row (a small margin makes the target
|
||||||
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
|
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
|
||||||
@@ -315,9 +327,11 @@
|
|||||||
const src = downInfo.src;
|
const src = downInfo.src;
|
||||||
const letter =
|
const letter =
|
||||||
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
|
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
|
||||||
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
|
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch' };
|
||||||
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it).
|
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a
|
||||||
|
// placed board tile is likewise lifted off its cell while relocated.
|
||||||
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
|
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
|
||||||
|
draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null;
|
||||||
// No zoom on drag start: the player may still change their mind. Holding the tile
|
// No zoom on drag start: the player may still change their mind. Holding the tile
|
||||||
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
|
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
|
||||||
}
|
}
|
||||||
@@ -371,9 +385,13 @@
|
|||||||
} else if (di.src.from === 'rack' && onRack && to != null) {
|
} else if (di.src.from === 'rack' && onRack && to != null) {
|
||||||
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
|
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
|
||||||
reorderRack(di.src.index, to);
|
reorderRack(di.src.index, to);
|
||||||
|
} else if (di.src.from === 'board' && cell) {
|
||||||
|
// Dropped a placed tile on another board cell → relocate it there.
|
||||||
|
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
|
||||||
} else if (di.src.from === 'board' && onRack) {
|
} else if (di.src.from === 'board' && onRack) {
|
||||||
// Dropped a pending tile back onto the rack → recall it to its original slot.
|
// Dropped a placed tile back onto the rack → recall it to its original slot.
|
||||||
placement = recallAt(placement, di.src.row, di.src.col);
|
placement = recallAt(placement, di.src.row, di.src.col);
|
||||||
|
selected = null;
|
||||||
recompute();
|
recompute();
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
}
|
}
|
||||||
@@ -416,6 +434,22 @@
|
|||||||
}
|
}
|
||||||
function onRecall(row: number, col: number) {
|
function onRecall(row: number, col: number) {
|
||||||
placement = recallAt(placement, row, col);
|
placement = recallAt(placement, row, col);
|
||||||
|
selected = null;
|
||||||
|
recompute();
|
||||||
|
scheduleDraftSave();
|
||||||
|
}
|
||||||
|
// relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one
|
||||||
|
// (a board→board drag), keeping its rack slot and any blank letter (Stage 17).
|
||||||
|
function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) {
|
||||||
|
const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol);
|
||||||
|
if (!pt) return;
|
||||||
|
if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let p = recallAt(placement, fromRow, fromCol);
|
||||||
|
p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined);
|
||||||
|
placement = p;
|
||||||
|
focus = { row: toRow, col: toCol };
|
||||||
recompute();
|
recompute();
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
}
|
}
|
||||||
@@ -651,13 +685,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Friend state for the in-game "add to friends" item, derived from the server so it is
|
||||||
|
// correct across reloads and live-updates when a request is answered (Stage 17):
|
||||||
|
// `friends` are the caller's accepted friends; `requested` are the addressees already
|
||||||
|
// requested (pending or declined — both block a re-send and read as "request sent").
|
||||||
|
let friends = $state(new Set<string>());
|
||||||
let requested = $state(new Set<string>());
|
let requested = $state(new Set<string>());
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
|
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
|
||||||
|
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
|
||||||
|
async function loadFriends() {
|
||||||
|
if (app.profile?.isGuest) return;
|
||||||
|
try {
|
||||||
|
const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]);
|
||||||
|
friends = new Set(fl.map((f) => f.accountId));
|
||||||
|
requested = new Set(out.map((f) => f.accountId));
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addFriend(accountId: string) {
|
async function addFriend(accountId: string) {
|
||||||
try {
|
try {
|
||||||
await gateway.friendRequest(accountId);
|
await gateway.friendRequest(accountId);
|
||||||
requested = new Set([...requested, accountId]);
|
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
|
||||||
showToast(t('friends.requestSent'));
|
showToast(t('friends.requestSent'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -677,7 +729,9 @@
|
|||||||
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
||||||
...(!app.profile?.isGuest
|
...(!app.profile?.isGuest
|
||||||
? opponents.map((s) =>
|
? opponents.map((s) =>
|
||||||
requested.has(s.accountId)
|
friends.has(s.accountId)
|
||||||
|
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true }
|
||||||
|
: requested.has(s.accountId)
|
||||||
? { label: t('game.requestSent'), onclick: noop, disabled: true }
|
? { label: t('game.requestSent'), onclick: noop, disabled: true }
|
||||||
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
|
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
|
||||||
)
|
)
|
||||||
@@ -814,7 +868,7 @@
|
|||||||
</Screen>
|
</Screen>
|
||||||
|
|
||||||
{#if drag}
|
{#if drag}
|
||||||
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
|
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
||||||
<span>{drag.blank ? '' : drag.letter}</span>
|
<span>{drag.blank ? '' : drag.letter}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1092,6 +1146,10 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
}
|
}
|
||||||
|
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x (Stage 17). */
|
||||||
|
.ghost.touch {
|
||||||
|
transform: translate(-50%, -50%) scale(1.5);
|
||||||
|
}
|
||||||
.alpha {
|
.alpha {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
|||||||
@@ -91,6 +91,10 @@
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
/* iOS shows a tap/active highlight that can linger on the neighbour sliding into a
|
||||||
|
dragged tile's slot (Stage 17); suppress it so only our own styles mark a tile. */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
.tile.selected {
|
.tile.selected {
|
||||||
outline: 3px solid var(--accent);
|
outline: 3px solid var(--accent);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export { MoveResult } from './scrabblefb/move-result.js';
|
|||||||
export { NotificationEvent } from './scrabblefb/notification-event.js';
|
export { NotificationEvent } from './scrabblefb/notification-event.js';
|
||||||
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
||||||
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
|
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
|
||||||
|
export { OutgoingRequestList } from './scrabblefb/outgoing-request-list.js';
|
||||||
export { PlayTile } from './scrabblefb/play-tile.js';
|
export { PlayTile } from './scrabblefb/play-tile.js';
|
||||||
export { Profile } from './scrabblefb/profile.js';
|
export { Profile } from './scrabblefb/profile.js';
|
||||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
||||||
|
|||||||
@@ -88,8 +88,13 @@ seatsLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastActivityUnix():bigint {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 24);
|
||||||
|
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
|
||||||
|
}
|
||||||
|
|
||||||
static startGameView(builder:flatbuffers.Builder) {
|
static startGameView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(10);
|
builder.startObject(11);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||||
@@ -144,12 +149,16 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint) {
|
||||||
|
builder.addFieldInt64(10, lastActivityUnix, BigInt('0'));
|
||||||
|
}
|
||||||
|
|
||||||
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset {
|
||||||
GameView.startGameView(builder);
|
GameView.startGameView(builder);
|
||||||
GameView.addId(builder, idOffset);
|
GameView.addId(builder, idOffset);
|
||||||
GameView.addVariant(builder, variantOffset);
|
GameView.addVariant(builder, variantOffset);
|
||||||
@@ -161,6 +170,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
|||||||
GameView.addMoveCount(builder, moveCount);
|
GameView.addMoveCount(builder, moveCount);
|
||||||
GameView.addEndReason(builder, endReasonOffset);
|
GameView.addEndReason(builder, endReasonOffset);
|
||||||
GameView.addSeats(builder, seatsOffset);
|
GameView.addSeats(builder, seatsOffset);
|
||||||
|
GameView.addLastActivityUnix(builder, lastActivityUnix);
|
||||||
return GameView.endGameView(builder);
|
return GameView.endGameView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
import { AccountRef } from '../scrabblefb/account-ref.js';
|
||||||
|
|
||||||
|
|
||||||
|
export class OutgoingRequestList {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):OutgoingRequestList {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList {
|
||||||
|
return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
requests(index: number, obj?:AccountRef):AccountRef|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestsLength():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startOutgoingRequestList(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, requestsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createRequestsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||||
|
builder.startVector(4, data.length, 4);
|
||||||
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
|
builder.addOffset(data[i]!);
|
||||||
|
}
|
||||||
|
return builder.endVector();
|
||||||
|
}
|
||||||
|
|
||||||
|
static startRequestsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
|
builder.startVector(4, numElems, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endOutgoingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
OutgoingRequestList.startOutgoingRequestList(builder);
|
||||||
|
OutgoingRequestList.addRequests(builder, requestsOffset);
|
||||||
|
return OutgoingRequestList.endOutgoingRequestList(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
insideTelegram,
|
insideTelegram,
|
||||||
onTelegramPath,
|
onTelegramPath,
|
||||||
telegramColorScheme,
|
telegramColorScheme,
|
||||||
|
telegramContentSafeAreaTop,
|
||||||
telegramDisableVerticalSwipes,
|
telegramDisableVerticalSwipes,
|
||||||
telegramHaptic,
|
telegramHaptic,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
@@ -227,6 +228,19 @@ function syncTelegramChrome(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
|
||||||
|
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
|
||||||
|
* `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its
|
||||||
|
* band (Stage 17). Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||||
|
*/
|
||||||
|
function syncTelegramSafeArea(): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const top = telegramContentSafeAreaTop();
|
||||||
|
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
|
||||||
|
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrap(): Promise<void> {
|
export async function bootstrap(): Promise<void> {
|
||||||
const prefs = await loadPrefs();
|
const prefs = await loadPrefs();
|
||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
@@ -263,6 +277,9 @@ export async function bootstrap(): Promise<void> {
|
|||||||
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
|
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
|
||||||
// fighting tile drag / board scroll.
|
// fighting tile drag / board scroll.
|
||||||
syncTelegramChrome();
|
syncTelegramChrome();
|
||||||
|
syncTelegramSafeArea();
|
||||||
|
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||||
|
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
||||||
telegramDisableVerticalSwipes();
|
telegramDisableVerticalSwipes();
|
||||||
try {
|
try {
|
||||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export interface GatewayClient {
|
|||||||
// --- friends (Stage 8) ---
|
// --- friends (Stage 8) ---
|
||||||
friendsList(): Promise<AccountRef[]>;
|
friendsList(): Promise<AccountRef[]>;
|
||||||
friendsIncoming(): Promise<AccountRef[]>;
|
friendsIncoming(): Promise<AccountRef[]>;
|
||||||
|
/** Addressees the caller has already requested (pending or declined); cannot re-request. */
|
||||||
|
friendsOutgoing(): Promise<AccountRef[]>;
|
||||||
friendRequest(accountId: string): Promise<void>;
|
friendRequest(accountId: string): Promise<void>;
|
||||||
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
||||||
friendCancel(accountId: string): Promise<void>;
|
friendCancel(accountId: string): Promise<void>;
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
|||||||
turnTimeoutSecs: g.turnTimeoutSecs(),
|
turnTimeoutSecs: g.turnTimeoutSecs(),
|
||||||
moveCount: g.moveCount(),
|
moveCount: g.moveCount(),
|
||||||
endReason: s(g.endReason()),
|
endReason: s(g.endReason()),
|
||||||
|
lastActivityUnix: Number(g.lastActivityUnix()),
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -587,6 +588,16 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decodeOutgoingList(buf: Uint8Array): AccountRef[] {
|
||||||
|
const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf));
|
||||||
|
const out: AccountRef[] = [];
|
||||||
|
for (let i = 0; i < l.requestsLength(); i++) {
|
||||||
|
const r = l.requests(i);
|
||||||
|
if (r) out.push(decodeAccountRef(r));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
|
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
|
||||||
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
||||||
const out: AccountRef[] = [];
|
const out: AccountRef[] = [];
|
||||||
@@ -678,6 +689,7 @@ function emptyGame(): GameView {
|
|||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: 0,
|
||||||
seats: [],
|
seats: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ export const en = {
|
|||||||
'about.version': 'Version {v}',
|
'about.version': 'Version {v}',
|
||||||
|
|
||||||
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
|
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
|
||||||
'landing.playWeb': 'Play in browser',
|
|
||||||
'landing.playTelegram': 'Play in Telegram',
|
'landing.playTelegram': 'Play in Telegram',
|
||||||
|
|
||||||
'lang.en': 'English',
|
'lang.en': 'English',
|
||||||
@@ -242,6 +241,7 @@ export const en = {
|
|||||||
'game.exportGcg': 'Export GCG',
|
'game.exportGcg': 'Export GCG',
|
||||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||||
'game.requestSent': 'Request sent',
|
'game.requestSent': 'Request sent',
|
||||||
|
'game.alreadyFriends': '✓ In friends',
|
||||||
|
|
||||||
'time.minutes': '{n} min',
|
'time.minutes': '{n} min',
|
||||||
'time.hours': '{n} h',
|
'time.hours': '{n} h',
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'about.version': 'Версия {v}',
|
'about.version': 'Версия {v}',
|
||||||
|
|
||||||
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
||||||
'landing.playWeb': 'Играть в браузере',
|
|
||||||
'landing.playTelegram': 'Играть в Telegram',
|
'landing.playTelegram': 'Играть в Telegram',
|
||||||
|
|
||||||
'lang.en': 'English',
|
'lang.en': 'English',
|
||||||
@@ -243,6 +242,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.exportGcg': 'Экспорт GCG',
|
'game.exportGcg': 'Экспорт GCG',
|
||||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||||
'game.requestSent': 'Запрос отправлен',
|
'game.requestSent': 'Запрос отправлен',
|
||||||
|
'game.alreadyFriends': '✓ В друзьях',
|
||||||
|
|
||||||
'time.minutes': '{n} мин',
|
'time.minutes': '{n} мин',
|
||||||
'time.hours': '{n} ч',
|
'time.hours': '{n} ч',
|
||||||
|
|||||||
+12
-12
@@ -1,20 +1,20 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { telegramBotLink } from './landing';
|
import { telegramChannelLink } from './landing';
|
||||||
|
|
||||||
describe('telegramBotLink', () => {
|
describe('telegramChannelLink', () => {
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
it('returns the per-language bot link when configured', () => {
|
it('builds the per-language t.me link from the channel name', () => {
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
|
||||||
expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game');
|
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
|
||||||
expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game');
|
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when the locale link is unset or blank', () => {
|
it('returns null when the locale channel is unset or blank', () => {
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', '');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' ');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
|
||||||
expect(telegramBotLink('en')).toBeNull();
|
expect(telegramChannelLink('en')).toBeNull();
|
||||||
expect(telegramBotLink('ru')).toBeNull();
|
expect(telegramChannelLink('ru')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-9
@@ -1,16 +1,20 @@
|
|||||||
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
|
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
|
||||||
// the per-language Telegram-bot link selection is unit-testable.
|
// the per-language Telegram-channel link selection is unit-testable.
|
||||||
|
|
||||||
import type { Locale } from './i18n/index.svelte';
|
import type { Locale } from './i18n/index.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramBotLink returns the t.me link for the locale's game bot, or null when it is not
|
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
|
||||||
* configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU)
|
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
|
||||||
* because the test and prod contours run different bots (different usernames), so the link
|
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
|
||||||
* cannot be hardcoded.
|
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
|
||||||
|
* (the id to post, the name to link). A leading "@" is tolerated.
|
||||||
*/
|
*/
|
||||||
export function telegramBotLink(locale: Locale): string | null {
|
export function telegramChannelLink(locale: Locale): string | null {
|
||||||
const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN;
|
const raw =
|
||||||
const link = (raw as string | undefined)?.trim();
|
locale === 'ru'
|
||||||
return link ? link : null;
|
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
|
||||||
|
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
|
||||||
|
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
|
||||||
|
return name ? `https://t.me/${name}` : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { groupGames, isMyTurn } from './lobbysort';
|
||||||
|
import type { GameView, Seat } from './model';
|
||||||
|
|
||||||
|
const ME = 'me';
|
||||||
|
const seat = (s: number, accountId: string): Seat => ({
|
||||||
|
seat: s,
|
||||||
|
accountId,
|
||||||
|
displayName: accountId,
|
||||||
|
score: 0,
|
||||||
|
hintsUsed: 0,
|
||||||
|
isWinner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
variant: 'english',
|
||||||
|
dictVersion: 'v1',
|
||||||
|
status,
|
||||||
|
players: 2,
|
||||||
|
toMove,
|
||||||
|
turnTimeoutSecs: 0,
|
||||||
|
moveCount: 0,
|
||||||
|
endReason: '',
|
||||||
|
lastActivityUnix,
|
||||||
|
seats: [seat(0, ME), seat(1, 'opp')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupGames', () => {
|
||||||
|
it('partitions into your-turn, their-turn and finished', () => {
|
||||||
|
const g = groupGames(
|
||||||
|
[
|
||||||
|
game('a', 'active', 0, 100), // toMove 0 == my seat -> my turn
|
||||||
|
game('b', 'active', 1, 100), // their turn
|
||||||
|
game('c', 'finished', 0, 100),
|
||||||
|
],
|
||||||
|
ME,
|
||||||
|
);
|
||||||
|
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
|
||||||
|
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
|
||||||
|
expect(g.finished.map((x) => x.id)).toEqual(['c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders your-turn oldest-first, the other two newest-first', () => {
|
||||||
|
const g = groupGames(
|
||||||
|
[
|
||||||
|
game('y_new', 'active', 0, 200),
|
||||||
|
game('y_old', 'active', 0, 100),
|
||||||
|
game('t_new', 'active', 1, 200),
|
||||||
|
game('t_old', 'active', 1, 100),
|
||||||
|
game('f_new', 'finished', 0, 200),
|
||||||
|
game('f_old', 'finished', 0, 100),
|
||||||
|
],
|
||||||
|
ME,
|
||||||
|
);
|
||||||
|
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
|
||||||
|
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
|
||||||
|
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isMyTurn is false for a finished game even at my seat', () => {
|
||||||
|
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
|
||||||
|
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
|
||||||
|
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three
|
||||||
|
// sections — games awaiting the caller's move, games awaiting the opponent, and finished
|
||||||
|
// games — each ordered by last activity: your-turn oldest-first (the longest-neglected on
|
||||||
|
// top), the other two newest-first.
|
||||||
|
|
||||||
|
import type { GameView } from './model';
|
||||||
|
|
||||||
|
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
|
||||||
|
export function isMyTurn(game: GameView, myId: string): boolean {
|
||||||
|
const me = game.seats.find((s) => s.accountId === myId);
|
||||||
|
return game.status === 'active' && !!me && game.toMove === me.seat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LobbyGroups holds the three ordered lobby sections. */
|
||||||
|
export interface LobbyGroups {
|
||||||
|
yourTurn: GameView[];
|
||||||
|
theirTurn: GameView[];
|
||||||
|
finished: GameView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groupGames partitions games for myId into the three lobby sections and orders each: the
|
||||||
|
* your-turn games by ascending last activity (the longest-waiting first), the opponent-turn
|
||||||
|
* and finished games by descending last activity (the most recent first).
|
||||||
|
*/
|
||||||
|
export function groupGames(games: GameView[], myId: string): LobbyGroups {
|
||||||
|
const yourTurn: GameView[] = [];
|
||||||
|
const theirTurn: GameView[] = [];
|
||||||
|
const finished: GameView[] = [];
|
||||||
|
for (const g of games) {
|
||||||
|
if (g.status !== 'active') finished.push(g);
|
||||||
|
else if (isMyTurn(g, myId)) yourTurn.push(g);
|
||||||
|
else theirTurn.push(g);
|
||||||
|
}
|
||||||
|
yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix);
|
||||||
|
theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
|
||||||
|
finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
|
||||||
|
return { yourTurn, theirTurn, finished };
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
private pendingMatch: string | null = null;
|
private pendingMatch: string | null = null;
|
||||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||||
|
private outgoing: AccountRef[] = [];
|
||||||
private blocks: AccountRef[] = [];
|
private blocks: AccountRef[] = [];
|
||||||
private invitations: Invitation[] = mockInvitations();
|
private invitations: Invitation[] = mockInvitations();
|
||||||
private readonly stats: Stats = { ...MOCK_STATS };
|
private readonly stats: Stats = { ...MOCK_STATS };
|
||||||
@@ -155,6 +156,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||||
seats: [
|
seats: [
|
||||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
@@ -372,8 +374,15 @@ export class MockGateway implements GatewayClient {
|
|||||||
async friendsIncoming(): Promise<AccountRef[]> {
|
async friendsIncoming(): Promise<AccountRef[]> {
|
||||||
return this.incoming.map((f) => ({ ...f }));
|
return this.incoming.map((f) => ({ ...f }));
|
||||||
}
|
}
|
||||||
async friendRequest(_accountId: string): Promise<void> {
|
async friendsOutgoing(): Promise<AccountRef[]> {
|
||||||
// The real backend requires a shared game; the mock simply acknowledges.
|
return this.outgoing.map((f) => ({ ...f }));
|
||||||
|
}
|
||||||
|
async friendRequest(accountId: string): Promise<void> {
|
||||||
|
// The real backend requires a shared game; the mock records the outgoing request so
|
||||||
|
// the game's "add to friends" item reads as sent across reloads.
|
||||||
|
if (!this.outgoing.some((o) => o.accountId === accountId)) {
|
||||||
|
this.outgoing.push({ accountId, displayName: this.nameFor(accountId) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
|
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
|
||||||
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
|
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ export const PROFILE: Profile = {
|
|||||||
|
|
||||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||||
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
|
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
|
||||||
export const MOCK_FRIENDS: AccountRef[] = [
|
// Ann is the active game's opponent but deliberately not a friend, so the in-game
|
||||||
{ accountId: 'ann', displayName: 'Ann' },
|
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
|
||||||
{ accountId: 'kaya', displayName: 'Kaya' },
|
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
|
||||||
];
|
|
||||||
|
|
||||||
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
||||||
|
|
||||||
@@ -144,6 +143,7 @@ function activeGame(): MockGame {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: G1_MOVES.length,
|
moveCount: G1_MOVES.length,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
|
||||||
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
|
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
|
||||||
},
|
},
|
||||||
moves: G1_MOVES,
|
moves: G1_MOVES,
|
||||||
@@ -177,6 +177,7 @@ function finishedG2(): MockGame {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: 2,
|
moveCount: 2,
|
||||||
endReason: 'normal',
|
endReason: 'normal',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
|
||||||
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
|
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
|
||||||
},
|
},
|
||||||
moves: [
|
moves: [
|
||||||
@@ -211,6 +212,7 @@ function finishedG3(): MockGame {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: 1,
|
moveCount: 1,
|
||||||
endReason: 'resignation',
|
endReason: 'resignation',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
|
||||||
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
|
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
|
||||||
},
|
},
|
||||||
moves: [
|
moves: [
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface GameView {
|
|||||||
turnTimeoutSecs: number;
|
turnTimeoutSecs: number;
|
||||||
moveCount: number;
|
moveCount: number;
|
||||||
endReason: string;
|
endReason: string;
|
||||||
|
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
|
||||||
|
lastActivityUnix: number;
|
||||||
seats: Seat[];
|
seats: Seat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
|||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: 0,
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface TelegramWebApp {
|
|||||||
initDataUnsafe?: { start_param?: string };
|
initDataUnsafe?: { start_param?: string };
|
||||||
themeParams?: TelegramThemeParams;
|
themeParams?: TelegramThemeParams;
|
||||||
colorScheme?: 'light' | 'dark';
|
colorScheme?: 'light' | 'dark';
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
ready?: () => void;
|
ready?: () => void;
|
||||||
expand?: () => void;
|
expand?: () => void;
|
||||||
onEvent?: (event: string, handler: () => void) => void;
|
onEvent?: (event: string, handler: () => void) => void;
|
||||||
@@ -99,6 +101,15 @@ export function telegramSetChrome(header: string, background: string, bottom: st
|
|||||||
if (bottom) w?.setBottomBarColor?.(bottom);
|
if (bottom) w?.setBottomBarColor?.(bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramContentSafeAreaTop returns the height (px) Telegram's own UI overlays at the top of
|
||||||
|
* the viewport in fullscreen (its nav band; the content-safe area, Bot API 8.0). It is 0
|
||||||
|
* outside Telegram or on clients predating it, so callers can pad/position defensively.
|
||||||
|
*/
|
||||||
|
export function telegramContentSafeAreaTop(): number {
|
||||||
|
return webApp()?.contentSafeAreaInset?.top ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
|
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
|
||||||
* it does not fight tile drag-and-drop or the board's vertical scroll.
|
* it does not fight tile drag-and-drop or the board's vertical scroll.
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async friendsIncoming() {
|
async friendsIncoming() {
|
||||||
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
|
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
|
||||||
},
|
},
|
||||||
|
async friendsOutgoing() {
|
||||||
|
return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty()));
|
||||||
|
},
|
||||||
async friendRequest(accountId) {
|
async friendRequest(accountId) {
|
||||||
await exec('friends.request', codec.encodeTarget(accountId));
|
await exec('friends.request', codec.encodeTarget(accountId));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { resultBadge } from '../lib/result';
|
import { resultBadge } from '../lib/result';
|
||||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||||
|
import { groupGames } from '../lib/lobbysort';
|
||||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||||
|
|
||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
@@ -46,8 +47,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const myId = $derived(app.session?.userId ?? '');
|
const myId = $derived(app.session?.userId ?? '');
|
||||||
const active = $derived(games.filter((g) => g.status === 'active'));
|
const groups = $derived(groupGames(games, myId));
|
||||||
const finished = $derived(games.filter((g) => g.status !== 'active'));
|
|
||||||
|
|
||||||
function opponents(g: GameView): string {
|
function opponents(g: GameView): string {
|
||||||
return g.seats
|
return g.seats
|
||||||
@@ -129,25 +129,26 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
|
{#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)}
|
||||||
{#if group.list.length}
|
{#if group.list.length}
|
||||||
<section>
|
<section>
|
||||||
<h2>{t(group.h as 'lobby.activeGames')}</h2>
|
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
|
||||||
|
<div class="list">
|
||||||
{#each group.list as g (g.id)}
|
{#each group.list as g (g.id)}
|
||||||
{@const b = resultBadge(g, myId)}
|
|
||||||
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
||||||
<span class="info">
|
<span class="info">
|
||||||
<span class="who">{opponents(g) || '—'}</span>
|
<span class="who">{opponents(g) || '—'}</span>
|
||||||
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
|
<span class="sub">{scoreline(g)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="emoji">{b.emoji}</span>
|
<span class="emoji">{resultBadge(g, myId).emoji}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if !active.length && !finished.length && !invitations.length}
|
{#if !games.length && !invitations.length}
|
||||||
<p class="empty">{t('lobby.noActive')}</p>
|
<p class="empty">{t('lobby.noActive')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +187,6 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.row,
|
|
||||||
.invite {
|
.invite {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -202,6 +202,31 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||||||
|
consecutive rows (Stage 17). */
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.row + .row {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.row:active {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
.info {
|
.info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user