Compare commits
15 Commits
v1.1.0
...
170a6ae9ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 170a6ae9ef | |||
| 004aca4e97 | |||
| b78ce42922 | |||
| 08c2c5f660 | |||
| 06cc4c1edc | |||
| 90f0424de2 | |||
| c36543e54e | |||
| be1627936f | |||
| 1ba52dd0b4 | |||
| bb0e3e17e5 | |||
| 1ef2bde395 | |||
| 62f66735a3 | |||
| 81680a1d5e | |||
| a393561d79 | |||
| e3e4cedc77 |
@@ -31,7 +31,7 @@ on:
|
|||||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.2.1
|
DICT_VERSION: v1.3.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
|||||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
pnpm start # UI mock mode: lobby -> game, no backend
|
||||||
|
|
||||||
docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA
|
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
||||||
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||||
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
||||||
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
|
|||||||
services build from multi-stage distroless `*/Dockerfile`.
|
services build from multi-stage distroless `*/Dockerfile`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
|
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
|
||||||
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
|
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
|
||||||
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
||||||
```
|
```
|
||||||
|
|||||||
+6
-4
@@ -7,12 +7,14 @@
|
|||||||
# (GOPRIVATE), so the build stage needs git and network.
|
# (GOPRIVATE), so the build stage needs git and network.
|
||||||
#
|
#
|
||||||
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||||
# in the Docker context:
|
# in the Docker context. DICT_VERSION has no default — the caller supplies the
|
||||||
# docker build -f backend/Dockerfile -t scrabble-backend .
|
# scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md
|
||||||
|
# "Bumping the dictionary version"):
|
||||||
|
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
|
||||||
|
|
||||||
# --- dictionary artifact -----------------------------------------------------
|
# --- dictionary artifact -----------------------------------------------------
|
||||||
FROM alpine:3.20 AS dawg
|
FROM alpine:3.20 AS dawg
|
||||||
ARG DICT_VERSION=v1.2.1
|
ARG DICT_VERSION
|
||||||
RUN apk add --no-cache curl tar
|
RUN apk add --no-cache curl tar
|
||||||
RUN mkdir -p /dawg \
|
RUN mkdir -p /dawg \
|
||||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||||
@@ -42,7 +44,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
|||||||
# Re-declare the build arg in this stage so it labels the seed dictionary. One
|
# Re-declare the build arg in this stage so it labels the seed dictionary. One
|
||||||
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
|
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
|
||||||
# label the binary pins, so the resident version equals the release tag.
|
# label the binary pins, so the resident version equals the release tag.
|
||||||
ARG DICT_VERSION=v1.2.1
|
ARG DICT_VERSION
|
||||||
COPY --from=build /out/backend /usr/local/bin/backend
|
COPY --from=build /out/backend /usr/local/bin/backend
|
||||||
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||||
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||||
|
|||||||
+1
-1
@@ -228,7 +228,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
|
|||||||
```sh
|
```sh
|
||||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg
|
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg
|
||||||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||||
BACKEND_DICT_DIR=/tmp/dawg \
|
BACKEND_DICT_DIR=/tmp/dawg \
|
||||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
|
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
|
||||||
<li><b>Channel</b> {{.Channel}}</li>
|
<li><b>Channel</b> {{.Channel}}</li>
|
||||||
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
|
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
|
||||||
|
<li><b>App version</b> {{if .Version}}<code>{{.Version}}</code>{{else}}<span class="note">unknown</span>{{end}}</li>
|
||||||
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
|
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
|
||||||
<li><b>Filed</b> {{.CreatedAt}}</li>
|
<li><b>Filed</b> {{.CreatedAt}} UTC · browser {{if .CreatedAtBrowser}}{{.CreatedAtBrowser}} ({{.BrowserTZ}}){{else}}<span class="note">N/A</span>{{end}} · user {{if .CreatedAtUser}}{{.CreatedAtUser}} ({{.UserTZ}}){{else}}<span class="note">N/A</span>{{end}}</li>
|
||||||
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
|
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
|
||||||
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
|
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -554,5 +554,17 @@ type FeedbackDetailView struct {
|
|||||||
ReplyBody string
|
ReplyBody string
|
||||||
RepliedAt string
|
RepliedAt string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
|
// Version is the client app build the report was sent from (empty for rows that predate it).
|
||||||
|
Version string
|
||||||
|
// The Filed time is shown in three zones so the operator can tell what is certainly known from
|
||||||
|
// what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
|
||||||
|
// instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
|
||||||
|
// when the client reported none (an older build). CreatedAtUser is that instant in the sender's
|
||||||
|
// saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
|
||||||
|
// default — the template then shows "N/A" so the missing datum is explicit.
|
||||||
|
CreatedAtBrowser string
|
||||||
|
BrowserTZ string
|
||||||
|
CreatedAtUser string
|
||||||
|
UserTZ string
|
||||||
Banned bool
|
Banned bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
|
|||||||
// validates the body (non-empty, within the rune limit) and the optional
|
// validates the body (non-empty, within the rune limit) and the optional
|
||||||
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
|
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
|
||||||
// client IP (validated); channel is the submitting platform.
|
// client IP (validated); channel is the submitting platform.
|
||||||
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
|
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, browserTZ, senderIP string) error {
|
||||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -112,9 +112,10 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
|
|||||||
attachmentName = "" // a name without bytes carries no attachment
|
attachmentName = "" // a name without bytes carries no attachment
|
||||||
}
|
}
|
||||||
ch := normalizeChannel(channel)
|
ch := normalizeChannel(channel)
|
||||||
// Snapshot the sender's interface language at submit time (acc is already loaded
|
// Snapshot the sender's interface language, the client app version and the client's
|
||||||
// for the guest check) so the operator later sees the state as it was.
|
// detected UTC offset at submit time (acc is already loaded for the guest check) so the
|
||||||
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
|
// operator later sees the state as it was.
|
||||||
|
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, browserTZ, parseIP(senderIP))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ func NewStore(db *sql.DB) *Store {
|
|||||||
|
|
||||||
// Insert stores one feedback message from accountID and returns its id. attachment
|
// Insert stores one feedback message from accountID and returns its id. attachment
|
||||||
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
||||||
// channel are stored as given. lang (the sender's interface language) is a snapshot
|
// channel are stored as given. lang (interface language), version (client app build) and
|
||||||
// taken now, so the operator later sees the state at submit time. created_at defaults
|
// browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator
|
||||||
// to now() in the database.
|
// later sees the state at submit time. created_at defaults to now() in the database.
|
||||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
|
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version, browserTZ string, ip *string) (uuid.UUID, error) {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
||||||
@@ -48,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
|
|||||||
}
|
}
|
||||||
if _, err := s.db.ExecContext(ctx,
|
if _, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO backend.feedback_messages
|
`INSERT INTO backend.feedback_messages
|
||||||
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
|
(message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, browser_tz, sender_ip)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
|
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
@@ -229,6 +229,14 @@ type AdminMessage struct {
|
|||||||
Channel string
|
Channel string
|
||||||
// Lang is the sender's interface language, snapshotted at submit time.
|
// Lang is the sender's interface language, snapshotted at submit time.
|
||||||
Lang string
|
Lang string
|
||||||
|
// Version is the client app build the report was sent from, snapshotted at submit time.
|
||||||
|
Version string
|
||||||
|
// BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the
|
||||||
|
// filed time can be shown in the sender's browser-local zone even before they save a profile.
|
||||||
|
BrowserTZ string
|
||||||
|
// TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for
|
||||||
|
// rendering CreatedAt in the sender's own configured time alongside UTC.
|
||||||
|
TimeZone string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
HasAttachment bool
|
HasAttachment bool
|
||||||
AttachmentName string
|
AttachmentName string
|
||||||
@@ -343,7 +351,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
|||||||
var m AdminMessage
|
var m AdminMessage
|
||||||
var repliedAt sql.NullTime
|
var repliedAt sql.NullTime
|
||||||
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
||||||
COALESCE(m.lang, ''),
|
COALESCE(m.lang, ''), COALESCE(m.app_version, ''), COALESCE(m.browser_tz, ''), a.time_zone,
|
||||||
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
|
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
|
||||||
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
|
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
|
||||||
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
|
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
|
||||||
@@ -352,7 +360,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
|||||||
WHERE m.message_id = $1`
|
WHERE m.message_id = $1`
|
||||||
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
||||||
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
||||||
&m.Lang,
|
&m.Lang, &m.Version, &m.BrowserTZ, &m.TimeZone,
|
||||||
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
||||||
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.U
|
|||||||
func TestFeedbackGuestRejected(t *testing.T) {
|
func TestFeedbackGuestRejected(t *testing.T) {
|
||||||
svc := newFeedbackService()
|
svc := newFeedbackService()
|
||||||
guest := provisionGuest(t)
|
guest := provisionGuest(t)
|
||||||
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
|
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "v1", "+05:00", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
|
||||||
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
|
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
|
|||||||
svc := newFeedbackService()
|
svc := newFeedbackService()
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
|
|
||||||
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil {
|
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "v1.2.0", "+03:00", "9.9.9.9"); err != nil {
|
||||||
t.Fatalf("submit: %v", err)
|
t.Fatalf("submit: %v", err)
|
||||||
}
|
}
|
||||||
// Anti-spam gate: a second message is refused while the first is unreviewed.
|
// Anti-spam gate: a second message is refused while the first is unreviewed.
|
||||||
if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) {
|
if err := svc.Submit(ctx, acc, "again", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrPendingReview) {
|
||||||
t.Fatalf("second submit err = %v, want ErrPendingReview", err)
|
t.Fatalf("second submit err = %v, want ErrPendingReview", err)
|
||||||
}
|
}
|
||||||
if st, err := svc.State(ctx, acc); err != nil {
|
if st, err := svc.State(ctx, acc); err != nil {
|
||||||
@@ -69,7 +69,7 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
|
|||||||
if m.Body != "please fix the board" { // trimmed
|
if m.Body != "please fix the board" { // trimmed
|
||||||
t.Fatalf("body = %q, want trimmed", m.Body)
|
t.Fatalf("body = %q, want trimmed", m.Body)
|
||||||
}
|
}
|
||||||
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" {
|
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" || m.Version != "v1.2.0" || m.BrowserTZ != "+03:00" {
|
||||||
t.Fatalf("admin message = %+v", m)
|
t.Fatalf("admin message = %+v", m)
|
||||||
}
|
}
|
||||||
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
|
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
|
||||||
@@ -116,7 +116,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
|
|||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
|
|
||||||
// msg1, replied → the player can send again and currently sees the reply.
|
// msg1, replied → the player can send again and currently sees the reply.
|
||||||
if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil {
|
if err := svc.Submit(ctx, acc, "first", nil, "", "web", "", "", ""); err != nil {
|
||||||
t.Fatalf("submit msg1: %v", err)
|
t.Fatalf("submit msg1: %v", err)
|
||||||
}
|
}
|
||||||
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
|
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
|
||||||
@@ -130,7 +130,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
|
|||||||
|
|
||||||
// Sending a new message immediately drops the previous reply (it now belongs to an
|
// Sending a new message immediately drops the previous reply (it now belongs to an
|
||||||
// older message), even though it is well within the one-week window.
|
// older message), even though it is well within the one-week window.
|
||||||
if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil {
|
if err := svc.Submit(ctx, acc, "second", nil, "", "web", "", "", ""); err != nil {
|
||||||
t.Fatalf("submit msg2: %v", err)
|
t.Fatalf("submit msg2: %v", err)
|
||||||
}
|
}
|
||||||
st, err := svc.State(ctx, acc)
|
st, err := svc.State(ctx, acc)
|
||||||
@@ -154,7 +154,7 @@ func TestFeedbackSnapshotsLanguage(t *testing.T) {
|
|||||||
t.Fatalf("set language: %v", err)
|
t.Fatalf("set language: %v", err)
|
||||||
}
|
}
|
||||||
// A message snapshots the sender's interface language at submit time.
|
// A message snapshots the sender's interface language at submit time.
|
||||||
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
|
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", "", "", ""); err != nil {
|
||||||
t.Fatalf("submit: %v", err)
|
t.Fatalf("submit: %v", err)
|
||||||
}
|
}
|
||||||
id := latestFeedbackID(t, svc, acc)
|
id := latestFeedbackID(t, svc, acc)
|
||||||
@@ -184,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) {
|
|||||||
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
||||||
t.Fatalf("grant role: %v", err)
|
t.Fatalf("grant role: %v", err)
|
||||||
}
|
}
|
||||||
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) {
|
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrBanned) {
|
||||||
t.Fatalf("banned submit err = %v, want ErrBanned", err)
|
t.Fatalf("banned submit err = %v, want ErrBanned", err)
|
||||||
}
|
}
|
||||||
if st, err := svc.State(ctx, acc); err != nil {
|
if st, err := svc.State(ctx, acc); err != nil {
|
||||||
@@ -196,7 +196,7 @@ func TestFeedbackBanRole(t *testing.T) {
|
|||||||
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
||||||
t.Fatalf("revoke role: %v", err)
|
t.Fatalf("revoke role: %v", err)
|
||||||
}
|
}
|
||||||
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil {
|
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", "", "", ""); err != nil {
|
||||||
t.Fatalf("submit after unban: %v", err)
|
t.Fatalf("submit after unban: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
acc := provisionAccount(t) // fresh account so the pending gate never fires first
|
acc := provisionAccount(t) // fresh account so the pending gate never fires first
|
||||||
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) {
|
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", "", "", ""); !errors.Is(err, tt.want) {
|
||||||
t.Fatalf("submit err = %v, want %v", err, tt.want)
|
t.Fatalf("submit err = %v, want %v", err, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -231,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) {
|
|||||||
svc := newFeedbackService()
|
svc := newFeedbackService()
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
|
|
||||||
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil {
|
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", "", "", ""); err != nil {
|
||||||
t.Fatalf("submit: %v", err)
|
t.Fatalf("submit: %v", err)
|
||||||
}
|
}
|
||||||
id := latestFeedbackID(t, svc, acc)
|
id := latestFeedbackID(t, svc, acc)
|
||||||
@@ -276,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
|
|||||||
svc := newFeedbackService()
|
svc := newFeedbackService()
|
||||||
acc := provisionAccount(t)
|
acc := provisionAccount(t)
|
||||||
|
|
||||||
if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil {
|
if err := svc.Submit(ctx, acc, "one", nil, "", "web", "", "", ""); err != nil {
|
||||||
t.Fatalf("submit: %v", err)
|
t.Fatalf("submit: %v", err)
|
||||||
}
|
}
|
||||||
if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
|
if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
|
||||||
@@ -286,7 +286,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
|
|||||||
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
|
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
|
||||||
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
|
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
|
||||||
}
|
}
|
||||||
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil {
|
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", "", "", ""); err != nil {
|
||||||
t.Fatalf("submit after delete-all: %v", err)
|
t.Fatalf("submit after delete-all: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- Replace the default (house) ad campaign's single seed tip with the curated,
|
||||||
|
-- language-agnostic Scrabble tip set (one bilingual row per tip; the client picks the
|
||||||
|
-- column for the viewer's language). Data-only — the ad_messages schema is unchanged, so
|
||||||
|
-- a backend image rollback stays DB-safe. The default campaign is the fixed house id seeded
|
||||||
|
-- in 00001; ON DELETE CASCADE is irrelevant here (we only touch its messages).
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
|
||||||
|
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru) VALUES
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 0, 'Keep a balanced rack — a slight edge of consonants over vowels.', 'Держи на руках баланс — с лёгким перевесом согласных над гласными.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 1, 'Your "leave" (the tiles you keep) sets up your next turn — value it.', '«Остаток» (что оставляешь на руках) готовит следующий ход — цени его.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 2, 'Shed duplicate tiles — repeats clog your options.', 'Сбрасывай дубли фишек — повторы забивают возможности.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 3, 'A slightly consonant-heavy rack builds full-rack plays more easily.', 'Лёгкий перевес согласных проще складывается в выкладку всех фишек.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 4, 'Play several tiles per turn to keep your rack cycling.', 'Выкладывай по нескольку фишек за ход, чтобы рука обновлялась.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 5, 'Don''t hoard hard-to-place duplicates or a lone high-value tile.', 'Не копи труднопристраиваемые дубли или одинокую дорогую фишку.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 6, 'Using all your rack tiles in one move scores a large bonus — chase it.', 'Выкладка всех фишек с рук за ход даёт крупный бонус — стремись к ней.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 7, 'Learn common prefixes and suffixes — they extend words to use every tile.', 'Учи частые приставки и суффиксы — они растягивают слово на все фишки.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 8, '"Fish": play few tiles to keep a near-complete rack when you''re ahead.', '«Рыбачь»: сыграй мало фишек, сохранив почти всю руку, когда ведёшь.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 9, 'Don''t hoard high-value tiles — play them in good time, not at the very end.', 'Не копи дорогие фишки — играй их вовремя, а не под самый конец.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 10, 'Don''t hold a high-value tile waiting for a rare partner — usually a loss.', 'Не держи дорогую фишку ради редкого партнёра — обычно это проигрыш.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 11, 'Land your priciest tile on a premium square for a big single score.', 'Сажай самую дорогую фишку на бонусную клетку ради крупных очков.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 12, 'High-value tiles shine in parallel plays through short words.', 'Дорогие фишки сильны в параллельных выкладках через короткие слова.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 13, 'Stuck with an unplayable high-value tile late? Exchange it.', 'Завис с неиграбельной дорогой фишкой под конец? Обменяй её.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 14, 'The blanks are the most valuable tiles in the bag — guard them.', 'Пустышки — самые ценные фишки в мешке; береги их.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 15, 'Save a blank for a full-rack play or a key premium square.', 'Береги пустышку для выкладки всех фишек или важной бонусной клетки.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 16, 'Don''t spend a blank cheaply — hold it for a much bigger gain.', 'Не трать пустышку по мелочи — придержи ради куда большей выгоды.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 17, 'Put high-value tiles on letter-bonus or word-bonus squares.', 'Клади дорогие фишки на бонус буквы или слова.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 18, 'Stack bonuses — a letter bonus under a word bonus multiplies both.', 'Совмещай бонусы — бонус буквы под бонусом слова умножает оба.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 19, 'Parallel plays can earn nearly half your points — look for them.', 'Параллельные выкладки могут давать почти половину очков — ищи их.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 20, 'A hook adds one tile to an existing word to make a new one.', '«Крючок» — одна фишка к готовому слову, образующая новое.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 21, 'Hooks work at the front or the back of a word.', 'Крючки работают спереди и сзади слова.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 22, 'Short words are the keys to tight parallel plays — memorize them.', 'Короткие слова — ключ к плотным параллелям; выучи их.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 23, 'Your opening word crosses the centre — keep it compact, don''t open up.', 'Первое слово идёт через центр — держи компактным, не раскрывайся.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 24, 'It''s not only your score — limit your opponent''s options too.', 'Это не только твои очки — ограничивай и возможности соперника.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 25, 'Denying a big reply often beats squeezing a few more points yourself.', 'Закрыть крупный ответ часто важнее, чем добрать пару своих очков.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 26, 'When ahead, keep the board tight and closed; avoid open lanes.', 'Ведёшь — держи доску плотной и закрытой, не открывай линии.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 27, 'When behind, open the board up to create high-scoring chances.', 'Отстаёшь — раскрывай доску ради шансов на крупный ход.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 28, 'Don''t leave a word-bonus square open right beside your word.', 'Не оставляй клетку бонуса слова открытой рядом со своим словом.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 29, 'Block a hot square even with a weak word to deny a big play.', 'Закрывай опасную клетку даже слабым словом, чтобы срубить крупный ход.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 30, 'Know words that take no hooks — use them to seal off lines.', 'Знай слова, не берущие крючков — ими запирай линии.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 31, 'Track the tiles played to judge what is still left in the bag.', 'Считай сыгранные фишки — так поймёшь, что осталось в мешке.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 32, 'Exchange when your rack is unbalanced or can only score low.', 'Меняй фишки, когда рука несбалансированна или тянет мало.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 33, 'A good exchange beats a bad play — a clean rack is worth a turn.', 'Хороший обмен лучше плохого хода — чистая рука стоит хода.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 34, 'Swap away a surplus of vowels or consonants to rebalance.', 'Сбрасывай в обмен избыток гласных или согласных, чтобы выровняться.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 35, 'Rare high-value tiles are gone once seen — note them as they appear.', 'Редкие дорогие фишки исчезают, едва мелькнув — отмечай их.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 36, 'Once the bag is empty, deduce your opponent''s remaining tiles.', 'Когда мешок пуст, вычисли оставшиеся фишки соперника.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 37, 'Shed high-value tiles before the bag empties — don''t get stuck with them.', 'Сбрось дорогие фишки до опустения мешка — не зависай с ними.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 38, 'Unplayed tiles count against you at the end — try to go out first.', 'Несыгранные фишки минусуют очки в конце — старайся выйти первым.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 39, 'Going out first adds your opponent''s leftover tiles to your score.', 'Кто вышел первым, добирает очки за оставшиеся фишки соперника.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 40, 'Sometimes leaving one tile in the bag buys you an extra turn.', 'Иногда оставить одну фишку в мешке — это лишний ход.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 41, 'In the endgame, block the exact squares your opponent needs.', 'В эндшпиле блокируй именно те клетки, что нужны сопернику.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 42, 'Shuffle your rack to spot new patterns.', 'Перемешивай фишки на руках — так замечаешь новые сочетания.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 43, 'Separate prefix, suffix and middle tiles to anagram faster.', 'Разнеси приставку, суффикс и середину — анаграммы решаются быстрее.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 44, 'Value board position and future turns over raw points this turn.', 'Цени позицию и будущие ходы выше сиюминутных очков.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 45, 'Early game build position; midgame maximize score; endgame defend.', 'В начале — позиция, в середине — очки, в конце — защита.'),
|
||||||
|
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 46, 'Learn the short-word lists first — they pay off in every game.', 'Сначала учи списки коротких слов — окупаются в каждой партии.');
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Restore the original single house tip seeded by the baseline.
|
||||||
|
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
|
||||||
|
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru)
|
||||||
|
VALUES ('00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
|
||||||
|
'Tip: a play using all 7 tiles earns a +50 bonus.',
|
||||||
|
'Совет: ход всеми 7 фишками приносит бонус +50 очков.');
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Capture the client app version (the build a report was sent from) with each feedback
|
||||||
|
-- message, so the operator console can show which version a player was on. Nullable, so the
|
||||||
|
-- rows that predate this keep working — additive and backward-compatible, so a backend image
|
||||||
|
-- rollback stays DB-safe (older code simply ignores the column).
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE backend.feedback_messages ADD COLUMN app_version text;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.feedback_messages DROP COLUMN app_version;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Capture the client's detected UTC offset ("±HH:MM") with each feedback message, so the
|
||||||
|
-- operator console can show the filed time in the sender's browser-local zone even before that
|
||||||
|
-- player has ever saved a profile (the account zone defaults to UTC until then). Nullable, so the
|
||||||
|
-- rows that predate this keep working — additive and backward-compatible, so a backend image
|
||||||
|
-- rollback stays DB-safe (older code simply ignores the column).
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE backend.feedback_messages ADD COLUMN browser_tz text;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.feedback_messages DROP COLUMN browser_tz;
|
||||||
@@ -1198,6 +1198,17 @@ func fmtTime(t time.Time) string {
|
|||||||
return t.UTC().Format("2006-01-02 15:04")
|
return t.UTC().Format("2006-01-02 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
|
||||||
|
// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
|
||||||
|
// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
|
||||||
|
// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
|
||||||
|
func fmtTimeIn(t time.Time, tz string) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
|
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
|
||||||
func fmtTimePtr(t *time.Time) string {
|
func fmtTimePtr(t *time.Time) string {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
|
|||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Filed time in three zones so the operator can tell what is certainly known from what is
|
||||||
|
// merely defaulted: always UTC; the client's offset detected at submit (when the build
|
||||||
|
// reported one); and the sender's saved profile zone (when set beyond the UTC default). An
|
||||||
|
// empty rendered time makes the template show "N/A" for that line.
|
||||||
|
browserCreated, userCreated := "", ""
|
||||||
|
if m.BrowserTZ != "" {
|
||||||
|
browserCreated = fmtTimeIn(m.CreatedAt, m.BrowserTZ)
|
||||||
|
}
|
||||||
|
if m.TimeZone != "" && m.TimeZone != "UTC" {
|
||||||
|
userCreated = fmtTimeIn(m.CreatedAt, m.TimeZone)
|
||||||
|
}
|
||||||
view := adminconsole.FeedbackDetailView{
|
view := adminconsole.FeedbackDetailView{
|
||||||
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
|
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
|
||||||
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang,
|
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang,
|
||||||
@@ -84,6 +95,9 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
|
|||||||
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
|
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
|
||||||
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
|
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
|
||||||
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
|
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
|
||||||
|
Version: m.Version,
|
||||||
|
CreatedAtBrowser: browserCreated, BrowserTZ: m.BrowserTZ,
|
||||||
|
CreatedAtUser: userCreated, UserTZ: m.TimeZone,
|
||||||
}
|
}
|
||||||
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
|
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
|
||||||
view.Banned = banned
|
view.Banned = banned
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ type feedbackSubmitRequest struct {
|
|||||||
Attachment string `json:"attachment"`
|
Attachment string `json:"attachment"`
|
||||||
AttachmentName string `json:"attachment_name"`
|
AttachmentName string `json:"attachment_name"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
|
// Version is the client's app version (pkg/version / the SPA build), snapshotted so the
|
||||||
|
// operator sees which build a report came from.
|
||||||
|
Version string `json:"version"`
|
||||||
|
// BrowserTZ is the client's detected UTC offset ("±HH:MM") at submit, so the operator can
|
||||||
|
// see the filed time in the sender's local zone even before they save a profile.
|
||||||
|
BrowserTZ string `json:"browser_tz"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// feedbackReplyDTO is the operator's reply shown back to the player.
|
// feedbackReplyDTO is the operator's reply shown back to the player.
|
||||||
@@ -61,7 +67,7 @@ func (s *Server) handleFeedbackSubmit(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
attachment = data
|
attachment = data
|
||||||
}
|
}
|
||||||
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil {
|
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, req.Version, req.BrowserTZ, clientIP(c)); err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ POSTGRES_PASSWORD=change-me # required
|
|||||||
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
||||||
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
||||||
DICT_VERSION=v1.2.1
|
DICT_VERSION=v1.3.0
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
+23
-1
@@ -80,7 +80,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||||
| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
|
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
|
||||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
||||||
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||||
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||||
@@ -117,6 +117,28 @@ collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF
|
|||||||
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
|
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
|
||||||
intentionally **unset** — caddy owns `/_gm` in the contour.
|
intentionally **unset** — caddy owns `/_gm` in the contour.
|
||||||
|
|
||||||
|
## Bumping the dictionary version
|
||||||
|
|
||||||
|
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
||||||
|
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
||||||
|
a build-time input with **no default** in the images, so it is set in exactly two places to
|
||||||
|
move the whole stack — change both to a new release:
|
||||||
|
|
||||||
|
1. **CI tests** — `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
|
||||||
|
download that dawg).
|
||||||
|
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
|
||||||
|
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
|
||||||
|
`DICT_VERSION`).
|
||||||
|
|
||||||
|
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
||||||
|
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||||
|
default — a missing value fails loudly instead of baking a stale tag.
|
||||||
|
|
||||||
|
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
|
||||||
|
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
|
||||||
|
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
|
||||||
|
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
|
||||||
|
|
||||||
## Production rollout
|
## Production rollout
|
||||||
|
|
||||||
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
|
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
args:
|
args:
|
||||||
# Seed dictionary for a FRESH volume; the per-contour value comes from the
|
# Seed dictionary for a FRESH volume; required (no default) so the release tag is
|
||||||
# deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below.
|
# set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION)
|
||||||
DICT_VERSION: ${DICT_VERSION:-v1.2.1}
|
# or .env for local builds. See the volume note below + deploy/README.md "Bumping the
|
||||||
|
# dictionary version".
|
||||||
|
DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env}
|
||||||
# Build version stamped into the binary (git tag; see pkg/version).
|
# Build version stamped into the binary (git tag; see pkg/version).
|
||||||
VERSION: ${APP_VERSION:-dev}
|
VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+10
-5
@@ -1149,9 +1149,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
|||||||
|
|
||||||
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
|
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
|
||||||
only). A message (≤1024 runes) plus an optional single attachment is stored in
|
only). A message (≤1024 runes) plus an optional single attachment is stored in
|
||||||
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting
|
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat), the submitting
|
||||||
**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain
|
**channel** (telegram/ios/android/web, client-reported and validated), the **client app version**
|
||||||
is `internal/feedback` (store + service), modelled on the admin chat-moderation surface.
|
(`__APP_VERSION__`, the build a report was sent from), the client's **detected UTC offset** at
|
||||||
|
submit (`browser_tz`, `±HH:MM`) and a snapshot of the sender's interface language are recorded. The domain is `internal/feedback` (store + service), modelled on the admin
|
||||||
|
chat-moderation surface.
|
||||||
|
|
||||||
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
|
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
|
||||||
gate is server-side. Because the operator must act before the next message, this is itself the
|
gate is server-side. Because the operator must act before the next message, this is itself the
|
||||||
@@ -1159,8 +1161,11 @@ rate limit — there is no separate per-user feedback limiter.
|
|||||||
|
|
||||||
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
|
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
|
||||||
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
|
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
|
||||||
(user content rendered as auto-escaped `html/template` text), and the read / reply / archive /
|
(user content rendered as auto-escaped `html/template` text; it shows the channel, interface
|
||||||
delete / delete-all actions — each marks the message read; merely opening the detail does not.
|
language and app version, and the filed time in three zones — UTC, the browser offset detected at
|
||||||
|
submit, and the sender's saved profile zone, each `N/A` when not known), and the read /
|
||||||
|
reply / archive / delete / delete-all actions — each marks the message read; merely opening the
|
||||||
|
detail does not.
|
||||||
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
|
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
|
||||||
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
|
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
|
||||||
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
|
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
|
||||||
|
|||||||
+3
-1
@@ -346,7 +346,9 @@ over-grant cannot be reversed there.
|
|||||||
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
|
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
|
||||||
**unread / read / archived** with per-user search, each shown with its sender, source, channel
|
**unread / read / archived** with per-user search, each shown with its sender, source, channel
|
||||||
(with the bot language — en/ru — for a Telegram message), the sender's interface
|
(with the bot language — en/ru — for a Telegram message), the sender's interface
|
||||||
language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered
|
language, the **app version** it was sent from, IP, the filed time (in three zones — UTC, the
|
||||||
|
browser zone detected at submit, and the sender's saved zone, each shown `N/A` when not known) and
|
||||||
|
any attachment. The operator can mark a message read, **reply** to the player (delivered
|
||||||
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
|
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
|
||||||
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
|
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
|
||||||
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ high-rate флага. С карточки пользователя операт
|
|||||||
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
|
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
|
||||||
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
|
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
|
||||||
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
|
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
|
||||||
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
|
**версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера
|
||||||
|
на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
|
||||||
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
|
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
|
||||||
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
|
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
|
||||||
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
|
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
|
||||||
|
|||||||
+4
-1
@@ -109,7 +109,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
|||||||
## Tiles & board
|
## Tiles & board
|
||||||
|
|
||||||
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
|
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
|
||||||
value), the point value bottom-right; blanks show no value.
|
value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the
|
||||||
|
"звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
|
||||||
|
tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
|
||||||
|
blank unmarked (`usesStarBlank` in `lib/variants.ts`).
|
||||||
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
|
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
|
||||||
the board's width** inside a fixed-size viewport (a real layout change → native scroll
|
the board's width** inside a fixed-size viewport (a real layout change → native scroll
|
||||||
that works consistently across browsers; no `transform`, which broke scrolling
|
that works consistently across browsers; no `transform`, which broke scrolling
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ type FeedbackUnreadResp struct {
|
|||||||
|
|
||||||
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
|
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
|
||||||
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
|
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
|
||||||
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error {
|
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, version, browserTz, clientIP string) error {
|
||||||
payload := map[string]string{
|
payload := map[string]string{
|
||||||
"body": body,
|
"body": body,
|
||||||
"attachment": "",
|
"attachment": "",
|
||||||
"attachment_name": attachmentName,
|
"attachment_name": attachmentName,
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
|
"version": version,
|
||||||
|
"browser_tz": browserTz,
|
||||||
}
|
}
|
||||||
if len(attachment) > 0 {
|
if len(attachment) > 0 {
|
||||||
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
|
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ func hideGameHandler(backend *backendclient.Client) Handler {
|
|||||||
func feedbackSubmitHandler(backend *backendclient.Client) Handler {
|
func feedbackSubmitHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
|
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
|
||||||
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), req.ClientIP); err != nil {
|
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), string(in.Version()), string(in.BrowserTz()), req.ClientIP); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return encodeAck(true), nil
|
return encodeAck(true), nil
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
# --- dictionary artifact -----------------------------------------------------
|
# --- dictionary artifact -----------------------------------------------------
|
||||||
FROM alpine:3.20 AS dawg
|
FROM alpine:3.20 AS dawg
|
||||||
ARG DICT_VERSION=v1.2.1
|
# Required, no default: the build caller supplies the scrabble-dictionary release tag.
|
||||||
|
ARG DICT_VERSION
|
||||||
RUN apk add --no-cache curl tar
|
RUN apk add --no-cache curl tar
|
||||||
RUN mkdir -p /dawg \
|
RUN mkdir -p /dawg \
|
||||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||||
|
|||||||
+2
-2
@@ -35,8 +35,8 @@ The harness reaches Postgres and the gateway directly, so run it as a one-shot
|
|||||||
container on the contour's docker network (this bypasses the host→gateway hairpin):
|
container on the contour's docker network (this bypasses the host→gateway hairpin):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# from the repo root
|
# from the repo root (DICT_VERSION has no default — pass the scrabble-dictionary release tag)
|
||||||
docker build -f loadtest/Dockerfile -t scrabble-loadtest .
|
docker build --build-arg DICT_VERSION=v1.3.0 -f loadtest/Dockerfile -t scrabble-loadtest .
|
||||||
|
|
||||||
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
|
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
|
||||||
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
|
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
|
||||||
|
|||||||
@@ -383,6 +383,8 @@ table FeedbackSubmitRequest {
|
|||||||
attachment:[ubyte];
|
attachment:[ubyte];
|
||||||
attachment_name:string;
|
attachment_name:string;
|
||||||
channel:string;
|
channel:string;
|
||||||
|
version:string;
|
||||||
|
browser_tz:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeedbackReply is the operator's answer shown back to the player.
|
// FeedbackReply is the operator's answer shown back to the player.
|
||||||
|
|||||||
@@ -99,8 +99,24 @@ func (rcv *FeedbackSubmitRequest) Channel() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *FeedbackSubmitRequest) Version() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *FeedbackSubmitRequest) BrowserTz() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
|
func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(4)
|
builder.StartObject(6)
|
||||||
}
|
}
|
||||||
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
|
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
|
||||||
@@ -117,6 +133,12 @@ func FeedbackSubmitRequestAddAttachmentName(builder *flatbuffers.Builder, attach
|
|||||||
func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) {
|
func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0)
|
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0)
|
||||||
}
|
}
|
||||||
|
func FeedbackSubmitRequestAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(version), 0)
|
||||||
|
}
|
||||||
|
func FeedbackSubmitRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(browserTz), 0)
|
||||||
|
}
|
||||||
func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
|
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
|
||||||
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
|
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
|
||||||
// shows its letter but no value, exactly as on the board. Letters are upper-cased for
|
// shows its letter but no value, exactly as on the board; in Erudit it also carries the
|
||||||
// display. The tile values ride on each tile, so this renders without the variant's
|
// blank's star (✻) in the value corner. Letters are upper-cased for display. The tile
|
||||||
// alphabet table (which the statistics screen has not cached).
|
// values ride on each tile, so this needs only the variant id (for the star) — not the
|
||||||
import type { BestMoveTile } from '../lib/model';
|
// variant's alphabet table, which the statistics screen has not cached.
|
||||||
|
import type { BestMoveTile, Variant } from '../lib/model';
|
||||||
|
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
|
|
||||||
let { word }: { word: BestMoveTile[] } = $props();
|
let { word, variant }: { word: BestMoveTile[]; variant: Variant } = $props();
|
||||||
|
|
||||||
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
|
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
|
||||||
</script>
|
</script>
|
||||||
@@ -15,7 +17,11 @@
|
|||||||
{#each word as tile, i (i)}
|
{#each word as tile, i (i)}
|
||||||
<span class="tile" class:blank={tile.blank} aria-hidden="true">
|
<span class="tile" class:blank={tile.blank} aria-hidden="true">
|
||||||
<span class="letter">{tile.letter.toUpperCase()}</span>
|
<span class="letter">{tile.letter.toUpperCase()}</span>
|
||||||
{#if !tile.blank}<span class="val">{tile.value}</span>{/if}
|
{#if !tile.blank}
|
||||||
|
<span class="val">{tile.value}</span>
|
||||||
|
{:else if usesStarBlank(variant)}
|
||||||
|
<span class="val blankmark">{BLANK_STAR}</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
@@ -50,4 +56,10 @@
|
|||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
||||||
|
its ink kept on the value digits' line (mirrors the board tile). */
|
||||||
|
.blankmark {
|
||||||
|
font-size: 8px;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { Premium } from '../lib/premiums';
|
import type { Premium } from '../lib/premiums';
|
||||||
import { valueForLetter } from '../lib/alphabet';
|
import { valueForLetter } from '../lib/alphabet';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
|
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
||||||
import type { Locale } from '../lib/i18n/catalog';
|
import type { Locale } from '../lib/i18n/catalog';
|
||||||
|
|
||||||
@@ -254,7 +255,11 @@
|
|||||||
>
|
>
|
||||||
{#if letter}
|
{#if letter}
|
||||||
<span class="letter">{letter}</span>
|
<span class="letter">{letter}</span>
|
||||||
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
|
{#if !blank}
|
||||||
|
<span class="val">{valueForLetter(variant, letter)}</span>
|
||||||
|
{:else if usesStarBlank(variant)}
|
||||||
|
<span class="val blankmark">{BLANK_STAR}</span>
|
||||||
|
{/if}
|
||||||
{:else if r === centre.row && c === centre.col}
|
{:else if r === centre.row && c === centre.col}
|
||||||
<span class="star">★</span>
|
<span class="star">★</span>
|
||||||
{:else if bl?.kind === 'single'}
|
{:else if bl?.kind === 'single'}
|
||||||
@@ -410,6 +415,12 @@
|
|||||||
font-size: 2.4cqw;
|
font-size: 2.4cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
||||||
|
its ink centred on the same line as a neighbouring tile's value digit. */
|
||||||
|
.blankmark {
|
||||||
|
font-size: 2.8cqw;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
.star {
|
.star {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { badgeKind } from '../lib/unread';
|
import { badgeKind } from '../lib/unread';
|
||||||
import { historyGrid } from '../lib/history';
|
import { historyGrid } from '../lib/history';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey } from '../lib/variants';
|
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft } from '../lib/hints';
|
import { hintsLeft } from '../lib/hints';
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
@@ -1293,7 +1293,7 @@
|
|||||||
|
|
||||||
{#if drag}
|
{#if drag}
|
||||||
<div class="ghost" class:touch={drag.touch} 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 ? (usesStarBlank(variant) ? BLANK_STAR : '') : drag.letter}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -3,6 +3,7 @@
|
|||||||
import { BLANK } from '../lib/placement';
|
import { BLANK } from '../lib/placement';
|
||||||
import { valueForLetter } from '../lib/alphabet';
|
import { valueForLetter } from '../lib/alphabet';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
|
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
slots,
|
slots,
|
||||||
@@ -66,8 +67,12 @@
|
|||||||
animate:hop={shuffling}
|
animate:hop={shuffling}
|
||||||
onpointerdown={(e) => ondown(e, slot.index)}
|
onpointerdown={(e) => ondown(e, slot.index)}
|
||||||
>
|
>
|
||||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
{#if slot.letter === BLANK}
|
||||||
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
|
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="letter">{slot.letter}</span>
|
||||||
|
<span class="val">{valueForLetter(variant, slot.letter)}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,4 +138,15 @@
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
|
||||||
|
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
|
||||||
|
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
|
||||||
|
.star {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(0.5% - 1px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -56,8 +56,22 @@ channel(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version():string|null
|
||||||
|
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
version(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserTz():string|null
|
||||||
|
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
|
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(4);
|
builder.startObject(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
|
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
|
||||||
@@ -88,17 +102,27 @@ static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset)
|
|||||||
builder.addFieldOffset(3, channelOffset, 0);
|
builder.addFieldOffset(3, channelOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(4, versionOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(5, browserTzOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
|
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
|
||||||
FeedbackSubmitRequest.addBody(builder, bodyOffset);
|
FeedbackSubmitRequest.addBody(builder, bodyOffset);
|
||||||
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
|
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
|
||||||
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
|
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
|
||||||
FeedbackSubmitRequest.addChannel(builder, channelOffset);
|
FeedbackSubmitRequest.addChannel(builder, channelOffset);
|
||||||
|
FeedbackSubmitRequest.addVersion(builder, versionOffset);
|
||||||
|
FeedbackSubmitRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
|
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { gateway } from './gateway';
|
|||||||
import { GatewayError } from './client';
|
import { GatewayError } from './client';
|
||||||
import { navigate, router } from './router.svelte';
|
import { navigate, router } from './router.svelte';
|
||||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||||
|
import { languageNeedsServerSync } from './language';
|
||||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
@@ -455,6 +456,13 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
// account here. preferred_language stays the user's saved choice (written from Settings,
|
// account here. preferred_language stays the user's saved choice (written from Settings,
|
||||||
// and used for out-of-app push routing), but the Telegram bot a user signs in through must
|
// and used for out-of-app push routing), but the Telegram bot a user signs in through must
|
||||||
// not dictate the UI: a ru-bot launch on an English system stays English.
|
// not dictate the UI: a ru-bot launch on an English system stays English.
|
||||||
|
//
|
||||||
|
// But the banner and out-of-app push routing ARE resolved from preferred_language, so an
|
||||||
|
// explicit device choice the account has not recorded yet (picked while a guest, or
|
||||||
|
// differing from the Telegram system-language seed) would otherwise leave them in the wrong
|
||||||
|
// language until the next Settings change. Reconcile the account to the saved local choice
|
||||||
|
// here; persistLanguageToServer no-ops for guests and when already equal.
|
||||||
|
if (app.localeLocked) void persistLanguageToServer(app.locale);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
@@ -475,6 +483,9 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
// A guest who chose a language and then linked in place now has a durable account: push the
|
||||||
|
// saved choice so the banner + push routing follow it (see adoptSession).
|
||||||
|
if (app.localeLocked) void persistLanguageToServer(app.locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -756,7 +767,7 @@ export function setLocalePref(locale: Locale): void {
|
|||||||
*/
|
*/
|
||||||
async function persistLanguageToServer(locale: Locale): Promise<void> {
|
async function persistLanguageToServer(locale: Locale): Promise<void> {
|
||||||
const p = app.profile;
|
const p = app.profile;
|
||||||
if (!p || p.isGuest || p.preferredLanguage === locale) return;
|
if (!p || !languageNeedsServerSync(p, locale)) return;
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileUpdate({
|
app.profile = await gateway.profileUpdate({
|
||||||
displayName: p.displayName,
|
displayName: p.displayName,
|
||||||
|
|||||||
@@ -76,18 +76,22 @@ describe('codec', () => {
|
|||||||
it('round-trips a feedback submit and decodes state + unread', () => {
|
it('round-trips a feedback submit and decodes state + unread', () => {
|
||||||
const att = new Uint8Array([1, 2, 3, 4]);
|
const att = new Uint8Array([1, 2, 3, 4]);
|
||||||
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
|
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
|
||||||
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')),
|
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios', 'v1.2.3', '+03:00')),
|
||||||
);
|
);
|
||||||
expect(req.body()).toBe('please fix');
|
expect(req.body()).toBe('please fix');
|
||||||
expect(req.attachmentName()).toBe('shot.png');
|
expect(req.attachmentName()).toBe('shot.png');
|
||||||
expect(req.channel()).toBe('ios');
|
expect(req.channel()).toBe('ios');
|
||||||
|
expect(req.version()).toBe('v1.2.3');
|
||||||
|
expect(req.browserTz()).toBe('+03:00');
|
||||||
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
|
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
|
||||||
|
|
||||||
// No attachment: the vector is empty.
|
// No attachment: the vector is empty.
|
||||||
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
|
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
|
||||||
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')),
|
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web', 'dev', '+00:00')),
|
||||||
);
|
);
|
||||||
expect(req2.body()).toBe('hi');
|
expect(req2.body()).toBe('hi');
|
||||||
|
expect(req2.version()).toBe('dev');
|
||||||
|
expect(req2.browserTz()).toBe('+00:00');
|
||||||
expect(req2.attachmentLength()).toBe(0);
|
expect(req2.attachmentLength()).toBe(0);
|
||||||
|
|
||||||
// State carrying a reply.
|
// State carrying a reply.
|
||||||
|
|||||||
@@ -489,17 +489,23 @@ export function encodeFeedbackSubmit(
|
|||||||
attachment: Uint8Array | null,
|
attachment: Uint8Array | null,
|
||||||
attachmentName: string,
|
attachmentName: string,
|
||||||
channel: string,
|
channel: string,
|
||||||
|
version: string,
|
||||||
|
browserTz: string,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const b = new Builder(256);
|
const b = new Builder(256);
|
||||||
const bodyOff = b.createString(body);
|
const bodyOff = b.createString(body);
|
||||||
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
|
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
|
||||||
const nameOff = b.createString(attachmentName);
|
const nameOff = b.createString(attachmentName);
|
||||||
const chOff = b.createString(channel);
|
const chOff = b.createString(channel);
|
||||||
|
const verOff = b.createString(version);
|
||||||
|
const tzOff = b.createString(browserTz);
|
||||||
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
|
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
|
||||||
fb.FeedbackSubmitRequest.addBody(b, bodyOff);
|
fb.FeedbackSubmitRequest.addBody(b, bodyOff);
|
||||||
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
|
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
|
||||||
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
|
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
|
||||||
fb.FeedbackSubmitRequest.addChannel(b, chOff);
|
fb.FeedbackSubmitRequest.addChannel(b, chOff);
|
||||||
|
fb.FeedbackSubmitRequest.addVersion(b, verOff);
|
||||||
|
fb.FeedbackSubmitRequest.addBrowserTz(b, tzOff);
|
||||||
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
|
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { languageNeedsServerSync } from './language';
|
||||||
|
import type { Profile } from './model';
|
||||||
|
|
||||||
|
// The reconciler only reads isGuest + preferredLanguage; a partial cast keeps the fixture small.
|
||||||
|
const profile = (over: Partial<Profile>): Profile => ({ isGuest: false, preferredLanguage: 'en', ...over }) as Profile;
|
||||||
|
|
||||||
|
describe('languageNeedsServerSync', () => {
|
||||||
|
it('is false without a profile', () => {
|
||||||
|
expect(languageNeedsServerSync(null, 'ru')).toBe(false);
|
||||||
|
expect(languageNeedsServerSync(undefined, 'ru')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for a guest — guests keep only the client preference', () => {
|
||||||
|
expect(languageNeedsServerSync(profile({ isGuest: true, preferredLanguage: 'en' }), 'ru')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when the account already matches the locale', () => {
|
||||||
|
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'ru')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for a real account whose stored language differs (banner + push follow it)', () => {
|
||||||
|
expect(languageNeedsServerSync(profile({ preferredLanguage: 'en' }), 'ru')).toBe(true);
|
||||||
|
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'en')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Interface-language reconciliation. Kept out of app.svelte.ts (a runes module that the
|
||||||
|
// node-env Vitest layer cannot import) so the decision is unit-testable.
|
||||||
|
|
||||||
|
import type { Locale } from './i18n/catalog';
|
||||||
|
import type { Profile } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* languageNeedsServerSync reports whether the durable account's `preferred_language` should be
|
||||||
|
* rewritten to the chosen interface `locale`. It is true only for a real (non-guest) account
|
||||||
|
* whose stored language differs from the locale; guests keep only the client-side preference,
|
||||||
|
* and an already-matching account is a no-op.
|
||||||
|
*
|
||||||
|
* The UI language follows the device (the local choice / system guess), but the advertising
|
||||||
|
* banner and out-of-app push routing are resolved server-side from `preferred_language`. A saved
|
||||||
|
* device choice the account has not yet recorded — picked while a guest, or differing from the
|
||||||
|
* Telegram system-language seed — would otherwise leave the banner and pushes in the wrong
|
||||||
|
* language until the next Settings change. Both the Settings control and the on-load reconciler
|
||||||
|
* gate their write on this.
|
||||||
|
*/
|
||||||
|
export function languageNeedsServerSync(profile: Profile | null | undefined, locale: Locale): boolean {
|
||||||
|
return !!profile && !profile.isGuest && profile.preferredLanguage !== locale;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { createConnectTransport } from '@connectrpc/connect-web';
|
|||||||
import { Gateway } from '../gen/edge/v1/edge_pb';
|
import { Gateway } from '../gen/edge/v1/edge_pb';
|
||||||
import { GatewayError, type GatewayClient } from './client';
|
import { GatewayError, type GatewayClient } from './client';
|
||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
|
import { browserOffset } from './profileValidation';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
|
||||||
@@ -147,7 +148,10 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
await exec('chat.read', codec.encodeGameAction(id));
|
await exec('chat.read', codec.encodeGameAction(id));
|
||||||
},
|
},
|
||||||
async feedbackSubmit(body, attachment, attachmentName, channel) {
|
async feedbackSubmit(body, attachment, attachmentName, channel) {
|
||||||
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
|
// The app build (Vite define) and the device's detected UTC offset ride with the report
|
||||||
|
// so the operator sees which version it came from and the local time it was filed; the
|
||||||
|
// caller need not pass them.
|
||||||
|
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel, __APP_VERSION__, browserOffset()));
|
||||||
},
|
},
|
||||||
async feedbackGet() {
|
async feedbackGet() {
|
||||||
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
|
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
availableVariants,
|
availableVariants,
|
||||||
supportsMultipleWordsToggle,
|
supportsMultipleWordsToggle,
|
||||||
multipleWordsForRequest,
|
multipleWordsForRequest,
|
||||||
|
usesStarBlank,
|
||||||
|
BLANK_STAR,
|
||||||
} from './variants';
|
} from './variants';
|
||||||
|
|
||||||
describe('ALL_VARIANTS', () => {
|
describe('ALL_VARIANTS', () => {
|
||||||
@@ -53,3 +55,16 @@ describe('multipleWordsForRequest', () => {
|
|||||||
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
|
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('usesStarBlank', () => {
|
||||||
|
it('marks the blank with a star for Erudit only', () => {
|
||||||
|
expect(usesStarBlank('erudit_ru')).toBe(true);
|
||||||
|
expect(usesStarBlank('scrabble_ru')).toBe(false);
|
||||||
|
expect(usesStarBlank('scrabble_en')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BLANK_STAR is the heavy teardrop-spoked asterisk (U+273B)', () => {
|
||||||
|
expect(BLANK_STAR).toBe('✻');
|
||||||
|
expect(BLANK_STAR.codePointAt(0)).toBe(0x273b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ export const VARIANT_FLAG: Record<Variant, string> = {
|
|||||||
// ru -> Russian + Эрудит.
|
// ru -> Russian + Эрудит.
|
||||||
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
|
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
|
||||||
|
|
||||||
|
// BLANK_STAR is the glyph drawn on an Эрудит blank tile: the variant's blank is the
|
||||||
|
// "звёздочка" (star) chip, so it carries a star rather than a bare face. U+273B HEAVY
|
||||||
|
// TEARDROP-SPOKED ASTERISK.
|
||||||
|
export const BLANK_STAR = '✻';
|
||||||
|
|
||||||
|
// usesStarBlank reports whether a variant marks its blank tiles with BLANK_STAR. Only
|
||||||
|
// Эрудит does: an empty rack blank shows the star centred, and a placed blank carries it
|
||||||
|
// in the value corner (the corner is free — a blank has no point value). The Scrabble
|
||||||
|
// variants leave the blank unmarked (an empty rack face; a placed blank shown by its
|
||||||
|
// designated letter alone).
|
||||||
|
export function usesStarBlank(v: Variant): boolean {
|
||||||
|
return v === 'erudit_ru';
|
||||||
|
}
|
||||||
|
|
||||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
|
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
|
||||||
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
|
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
|
||||||
// — a safety fallback; a real profile always carries at least one preference.
|
// — a safety fallback; a real profile always carries at least one preference.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
{#each bestMoves as bm (bm.variant)}
|
{#each bestMoves as bm (bm.variant)}
|
||||||
<span class="variant">{t(variantNameKey(bm.variant))}</span>
|
<span class="variant">{t(variantNameKey(bm.variant))}</span>
|
||||||
<span class="score">{bm.score}</span>
|
<span class="score">{bm.score}</span>
|
||||||
<span class="wordcell"><WordTiles word={bm.word} /></span>
|
<span class="wordcell"><WordTiles word={bm.word} variant={bm.variant} /></span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user