From e1c343790298514ab94ae95c626bcd7d993660db Mon Sep 17 00:00:00 2001 From: turk Date: Sun, 3 May 2026 21:05:43 -0600 Subject: [PATCH 1/4] prevent re-join problems --- bin/db-dev | 4 ++-- pregame.go | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/bin/db-dev b/bin/db-dev index 2473723..f58ad6b 100755 --- a/bin/db-dev +++ b/bin/db-dev @@ -60,11 +60,11 @@ fi # wait for ready printf "waiting for %s to be ready..." ${CONTAINER} for _ in $(seq 1 30); do - if docker exec "$CONTAINER" pg_isready -U "$USER" >/dev/null 2>&1; then + if docker exec "$CONTAINER" pg_isready -h 127.0.0.1 -p 5432 -U "$USER" -d "$DB" >/dev/null 2>&1; then printf "\n" mkdir -p "/tmp/rulette" touch "/tmp/rulette/db-ready" - trap "rm -f /tmp/rulette/db-ready" EXIT + trap 'rm -f /tmp/rulette/db-ready; cleanup' EXIT echo "${CONTAINER} ready on :${PORT}" echo echo "ctrl-c to stop" diff --git a/pregame.go b/pregame.go index 98025cc..28035d5 100644 --- a/pregame.go +++ b/pregame.go @@ -104,6 +104,18 @@ func joinHandler(w http.ResponseWriter, r *http.Request) { } switch r.Method { case http.MethodGet: + // if the visitor is already a player in this game, don't show them + // the join form — bounce them back to the game page. + if _, cookieKey, err := cookie(r); err == nil { + s, err := stateFromCacheOrDB(r.Context(), &cache, gameID) + if err == nil && s.isPlayerInGame(cookieKey) { + log.Warn("player attempted to rejoin, redirecting to game", + "game_id", gameID, + ) + http.Redirect(w, r, fmt.Sprintf("/%s", gameID), http.StatusSeeOther) + return + } + } w.Header().Set("Content-Type", "text/html") templateFilepath := path.Join("static", "html", "tmpl.join.html") tmpl, err := readParse(static, templateFilepath) From 8d5b164e736c465abbb8e9febceee410dd15e3a5 Mon Sep 17 00:00:00 2001 From: turk Date: Mon, 4 May 2026 19:17:22 -0600 Subject: [PATCH 2/4] pr changes --- pregame.go | 36 ++++++++++++++++++++++++++++++++++-- rulette_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/pregame.go b/pregame.go index 28035d5..ab7cc8a 100644 --- a/pregame.go +++ b/pregame.go @@ -105,10 +105,20 @@ func joinHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // if the visitor is already a player in this game, don't show them - // the join form — bounce them back to the game page. + // the join form — bounce them back to the game page. Fail closed + // on state lookup errors: a cookied visitor reaching the form is + // the bug we're trying to prevent. if _, cookieKey, err := cookie(r); err == nil { s, err := stateFromCacheOrDB(r.Context(), &cache, gameID) - if err == nil && s.isPlayerInGame(cookieKey) { + if err != nil { + log.Error("fetch game state for rejoin check", + "error", err, + "game_id", gameID, + ) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if s.isPlayerInGame(cookieKey) { log.Warn("player attempted to rejoin, redirecting to game", "game_id", gameID, ) @@ -147,6 +157,28 @@ func joinHandler(w http.ResponseWriter, r *http.Request) { return } + // reject rejoin: if the caller already has a session for this game, + // bounce to the game page rather than minting a new player/session + // (which would orphan the original identity and host initiative). + if _, cookieKey, err := cookie(r); err == nil { + s, err := stateFromCacheOrDB(r.Context(), &cache, gameID) + if err != nil { + log.Error("fetch game state for rejoin check", + "error", err, + "game_id", gameID, + ) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if s.isPlayerInGame(cookieKey) { + log.Warn("POST rejoin blocked, redirecting to game", + "game_id", gameID, + ) + http.Redirect(w, r, fmt.Sprintf("/%s", gameID), http.StatusSeeOther) + return + } + } + switch game.StateID { case 6: log.Info("join attempt to closed game") diff --git a/rulette_test.go b/rulette_test.go index e8d0403..982f13a 100644 --- a/rulette_test.go +++ b/rulette_test.go @@ -166,6 +166,38 @@ func TestGame(t *testing.T) { ) }) + t.Run("GET /{game_id}/join (existing player)", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodGet, fmt.Sprintf("/%s/join", gameID), nil, + ) + req.AddCookie(users[0].cookie) + w := httptest.NewRecorder() + joinHandler(w, req) + require.Equal(t, http.StatusSeeOther, w.Result().StatusCode, + "existing player GET /join should redirect", + ) + require.Equal(t, fmt.Sprintf("/%s", gameID), + w.Result().Header.Get("Location"), + "redirect target should be the game page", + ) + for _, c := range w.Result().Cookies() { + require.NotEqual(t, "session", c.Name, + "existing-player redirect must not issue a new session cookie", + ) + } + }) + + t.Run("GET /{game_id}/join (no cookie)", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodGet, fmt.Sprintf("/%s/join", gameID), nil, + ) + w := httptest.NewRecorder() + joinHandler(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode, + "strangers without a cookie should still get the join form", + ) + }) + t.Run("GET /{game_id}/qr (inviting)", func(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/{game_id}/qr", qrHandler) From e3a2a1be8209c7d19e351904e08849519df68647 Mon Sep 17 00:00:00 2001 From: turk Date: Tue, 5 May 2026 21:19:17 -0600 Subject: [PATCH 3/4] first pass at i18n --- game.go | 14 +-- i18n.go | 119 +++++++++++++++++++++ lang.go | 31 ++++++ main.go | 19 ++-- middleware.go | 9 ++ pregame.go | 4 +- static.go | 10 +- static/css/style.css | 13 +++ static/html/index.html | 13 +-- static/html/tmpl.accuse_dialog.html | 4 +- static/html/tmpl.change_points_dialog.html | 10 +- static/html/tmpl.footer.html | 11 ++ static/html/tmpl.game.html | 40 +++---- static/html/tmpl.join.html | 8 +- static/html/tmpl.players.html | 6 +- static/html/tmpl.points.html | 2 +- static/html/tmpl.status.html | 4 +- static/html/tmpl.table.html | 10 +- tx/en.json | 62 +++++++++++ 19 files changed, 323 insertions(+), 66 deletions(-) create mode 100644 i18n.go create mode 100644 lang.go create mode 100644 tx/en.json diff --git a/game.go b/game.go index 9f36f0b..28ca88c 100644 --- a/game.go +++ b/game.go @@ -53,7 +53,7 @@ func gameHandler(w http.ResponseWriter, r *http.Request) { } filepath := path.Join("static", "html", "tmpl.game.html") - tmpl, err := readParse(static, filepath) + tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context())) if err != nil { log.Error("read and parse template", "error", err, @@ -131,7 +131,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { switch topic { case "players": filepath := path.Join("static", "html", "tmpl.players.html") - tmpl, err := readParse(static, filepath) + tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context())) if err != nil { log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -148,7 +148,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { return case "table": filepath := path.Join("static", "html", "tmpl.table.html") - tmpl, err := readParse(static, filepath) + tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context())) if err != nil { log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -166,7 +166,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { return case "status": filepath := path.Join("static", "html", "tmpl.status.html") - tmpl, err := readParse(static, filepath) + tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context())) if err != nil { log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -189,7 +189,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { } case "points": filepath := path.Join("static", "html", "tmpl.points.html") - tmpl, err := readParse(static, filepath) + tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context())) if err != nil { log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -223,7 +223,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { return } filepath := path.Join("static", "html", "tmpl.change_points_dialog.html") - tmpl, err := readParse(static, filepath) + tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context())) if err != nil { log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -240,7 +240,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { return case "accuse": filepath := path.Join("static", "html", "tmpl.accuse_dialog.html") - tmpl, err := readParse(static, filepath) + tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context())) if err != nil { log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..e3d1a02 --- /dev/null +++ b/i18n.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "net/http" + "path" + "sort" + "strings" +) + +const defaultLocale = "en" + +// translations is locale -> key -> value, populated by loadTranslations +// at startup and read-only thereafter. +var translations = map[string]map[string]string{} + +type ctxLocaleKey struct{} + +func init() { + if err := loadTranslations(translationsFS); err != nil { + panic(fmt.Sprintf("load translations: %v", err)) + } +} + +// loadTranslations reads every tx/*.json file from fs and registers +// each as a locale named after the file's basename (e.g. tx/en.json +// becomes locale "en"). +func loadTranslations(fs embed.FS) error { + entries, err := fs.ReadDir("tx") + if err != nil { + return fmt.Errorf("read tx dir: %w", err) + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + raw, err := fs.ReadFile(path.Join("tx", e.Name())) + if err != nil { + return fmt.Errorf("read %s: %w", e.Name(), err) + } + m := map[string]string{} + if err := json.Unmarshal(raw, &m); err != nil { + return fmt.Errorf("parse %s: %w", e.Name(), err) + } + locale := strings.TrimSuffix(e.Name(), ".json") + translations[locale] = m + } + if _, ok := translations[defaultLocale]; !ok { + return fmt.Errorf("missing default locale %q", defaultLocale) + } + return nil +} + +// Tx looks up key for the given locale, falling back to the default +// locale, and finally to the key itself so that missing entries are +// loud but non-fatal. +func Tx(locale, key string) string { + if m, ok := translations[locale]; ok { + if v, ok := m[key]; ok { + return v + } + } + if m, ok := translations[defaultLocale]; ok { + if v, ok := m[key]; ok { + return v + } + } + return key +} + +// Locales returns the registered locale codes in sorted order. +func Locales() []string { + out := make([]string, 0, len(translations)) + for k := range translations { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// LocaleFromContext returns the locale stored on ctx by i18nMW, +// falling back to the default locale. +func LocaleFromContext(ctx context.Context) string { + if v, ok := ctx.Value(ctxLocaleKey{}).(string); ok && v != "" { + return v + } + return defaultLocale +} + +// WithLocale returns a new context carrying locale. +func WithLocale(ctx context.Context, locale string) context.Context { + return context.WithValue(ctx, ctxLocaleKey{}, locale) +} + +// detectLocale picks the request locale by precedence: +// 1. lang cookie (the persisted toggle choice) +// 2. Accept-Language header (first tag's primary subtag) +// 3. defaultLocale +// A candidate is only honored if it's a registered locale. +func detectLocale(r *http.Request) string { + if c, err := r.Cookie("lang"); err == nil { + if _, ok := translations[c.Value]; ok { + return c.Value + } + } + if h := r.Header.Get("Accept-Language"); h != "" { + first := strings.SplitN(h, ",", 2)[0] + first = strings.SplitN(first, ";", 2)[0] + first = strings.ToLower(strings.TrimSpace(first)) + first = strings.SplitN(first, "-", 2)[0] + if _, ok := translations[first]; ok { + return first + } + } + return defaultLocale +} diff --git a/lang.go b/lang.go new file mode 100644 index 0000000..af95554 --- /dev/null +++ b/lang.go @@ -0,0 +1,31 @@ +package main + +import ( + "net/http" + "time" +) + +// langHandler persists the user's locale preference via a cookie. +// On invalid or unknown locales, falls back silently to the default. +func langHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + choice := r.FormValue("lang") + if _, ok := translations[choice]; !ok { + choice = defaultLocale + } + http.SetCookie(w, &http.Cookie{ + Name: "lang", + Value: choice, + Path: "/", + MaxAge: int((365 * 24 * time.Hour).Seconds()), + SameSite: http.SameSiteLaxMode, + }) + target := r.Referer() + if target == "" { + target = "/" + } + http.Redirect(w, r, target, http.StatusSeeOther) +} diff --git a/main.go b/main.go index cf20fcc..560070b 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,9 @@ var dbSchema string //go:embed static var static embed.FS +//go:embed tx +var translationsFS embed.FS + func init() { var err error log, err = logger.New(slog.HandlerOptions{}) @@ -64,14 +67,16 @@ func main() { mux.Handle("/static/img/", logMW(rateMW(http.FileServer(http.FS(static))))) mux.Handle("/static/fonts/", logMW(rateMW(http.FileServer(http.FS(static))))) // pregame.go - mux.Handle("/", logMW(rateMW(http.HandlerFunc(rootHandler)))) - mux.Handle("/create", logMW(rateMW(http.HandlerFunc(createHandler)))) - mux.Handle("/{game_id}/join", logMW(rateMW(http.HandlerFunc(joinHandler)))) + mux.Handle("/", logMW(rateMW(i18nMW(http.HandlerFunc(rootHandler))))) + mux.Handle("/create", logMW(rateMW(i18nMW(http.HandlerFunc(createHandler))))) + mux.Handle("/{game_id}/join", logMW(rateMW(i18nMW(http.HandlerFunc(joinHandler))))) + // lang.go — locale toggle; no i18nMW (it sets the locale source). + mux.Handle("/lang", logMW(rateMW(http.HandlerFunc(langHandler)))) // game.go - mux.Handle("/{game_id}", logMW(rateMW(http.HandlerFunc(gameHandler)))) - mux.Handle("/{game_id}/qr", logMW(rateMW(http.HandlerFunc(qrHandler)))) - mux.Handle("/{game_id}/data/{topic}", logMW(rateMW(http.HandlerFunc(dataHandler)))) - mux.Handle("/{game_id}/action/{action}", logMW(rateMW(http.HandlerFunc(actionHandler)))) + mux.Handle("/{game_id}", logMW(rateMW(i18nMW(http.HandlerFunc(gameHandler))))) + mux.Handle("/{game_id}/qr", logMW(rateMW(i18nMW(http.HandlerFunc(qrHandler))))) + mux.Handle("/{game_id}/data/{topic}", logMW(rateMW(i18nMW(http.HandlerFunc(dataHandler))))) + mux.Handle("/{game_id}/action/{action}", logMW(rateMW(i18nMW(http.HandlerFunc(actionHandler))))) ctx := context.Background() // RULETTE_PG_URL is a postgres connection string, e.g.: diff --git a/middleware.go b/middleware.go index 38f65cb..b70f32d 100644 --- a/middleware.go +++ b/middleware.go @@ -9,6 +9,15 @@ import ( "golang.org/x/time/rate" ) +// i18nMW resolves the request locale (cookie > Accept-Language > default) +// and stashes it on the request context for handlers and templates. +func i18nMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := WithLocale(r.Context(), detectLocale(r)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + // logMW logs every incoming request. func logMW(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pregame.go b/pregame.go index ab7cc8a..7cddca6 100644 --- a/pregame.go +++ b/pregame.go @@ -42,7 +42,7 @@ func setCookieErr(w http.ResponseWriter, err error) { // from which a user can start a new game with a POST to /create. func rootHandler(w http.ResponseWriter, r *http.Request) { indexPath := path.Join("static", "html", "index.html") - tmpl, err := readParse(static, indexPath) + tmpl, err := readParse(static, indexPath, LocaleFromContext(r.Context())) if err != nil { http.Error(w, "server error", http.StatusInternalServerError) return @@ -128,7 +128,7 @@ func joinHandler(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html") templateFilepath := path.Join("static", "html", "tmpl.join.html") - tmpl, err := readParse(static, templateFilepath) + tmpl, err := readParse(static, templateFilepath, LocaleFromContext(r.Context())) if err != nil { log.Error("read parse", "error", err, diff --git a/static.go b/static.go index b308943..f3d8d6b 100644 --- a/static.go +++ b/static.go @@ -3,14 +3,17 @@ package main import ( "embed" "fmt" - "path/filepath" "html/template" + "path/filepath" ) // readParse reads a template file from an embedded filesystem, // parses it together with the shared footer, and returns the // resulting *template.Template or any error. -func readParse(fs embed.FS, path string) (*template.Template, error) { +// +// The locale argument binds the template's Tx function so that +// {{Tx "key"}} in templates resolves against the caller's locale. +func readParse(fs embed.FS, path string, locale string) (*template.Template, error) { f, err := fs.ReadFile(path) if err != nil { return nil, fmt.Errorf("read file %q from embed.FS: %w", path, err) @@ -22,6 +25,9 @@ func readParse(fs embed.FS, path string) (*template.Template, error) { name := filepath.Base(path) funcs := template.FuncMap{ "version": func() string { return version }, + "Tx": func(key string) string { return Tx(locale, key) }, + "locale": func() string { return locale }, + "locales": Locales, } tmpl, err := template.New(name).Funcs(funcs).Parse(string(f)) if err != nil { diff --git a/static/css/style.css b/static/css/style.css index 715b9ec..3a8b77a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -293,6 +293,19 @@ body > footer:not(.footer-version) { pointer-events: none; } +.lang-picker { + position: fixed; + top: 8px; + right: 12px; + font-size: 0.75rem; + opacity: 0.7; + z-index: 100; +} + +.lang-picker label { + margin-right: 4px; +} + /* game title */ .game-title { width: 100%; diff --git a/static/html/index.html b/static/html/index.html index 3cebd31..0873ad6 100644 --- a/static/html/index.html +++ b/static/html/index.html @@ -1,27 +1,28 @@ - + - Rulette + {{Tx "app.name"}} + {{ template "lang_picker" }}
- +
- - + +
{{ template "footer" }} diff --git a/static/html/tmpl.accuse_dialog.html b/static/html/tmpl.accuse_dialog.html index d42397d..550dbf8 100644 --- a/static/html/tmpl.accuse_dialog.html +++ b/static/html/tmpl.accuse_dialog.html @@ -1,4 +1,4 @@ -

which RULE?

+

{{Tx "accuse.heading_which"}} {{Tx "accuse.heading_rule"}}

{{ range .Players }}
{{ .Name }} @@ -17,4 +17,4 @@

which RULE?

{{ end }}
{{ end }} - + diff --git a/static/html/tmpl.change_points_dialog.html b/static/html/tmpl.change_points_dialog.html index de196ed..d34f1c9 100644 --- a/static/html/tmpl.change_points_dialog.html +++ b/static/html/tmpl.change_points_dialog.html @@ -1,14 +1,14 @@ -

Change Points

+

{{Tx "change_points.title"}}

- + - + - +
- + diff --git a/static/html/tmpl.footer.html b/static/html/tmpl.footer.html index e3fa702..1a8a5be 100644 --- a/static/html/tmpl.footer.html +++ b/static/html/tmpl.footer.html @@ -1 +1,12 @@ {{define "footer"}}
{{version}}
{{end}} + +{{define "lang_picker"}}
+ + + +
{{end}} diff --git a/static/html/tmpl.game.html b/static/html/tmpl.game.html index 273097b..34f6953 100644 --- a/static/html/tmpl.game.html +++ b/static/html/tmpl.game.html @@ -1,11 +1,11 @@ - + - Rulette | {{ .Game.Name }} + {{Tx "app.name"}} | {{ .Game.Name }} - + @@ -17,7 +17,7 @@

{{ .Game.Name }}

-
Playing as {{ .CallerName }}
+
{{Tx "game.playing_as"}} {{ .CallerName }}
{{ .Game.StateName }}
@@ -28,7 +28,7 @@

{{ .Game.Name }}

-

loading players...

+

{{Tx "game.loading_players"}}

@@ -37,7 +37,7 @@

{{ .Game.Name }}

-

loading points...

+

{{Tx "game.loading_points"}}

@@ -53,7 +53,7 @@

{{ .Game.Name }}

hx-get="/{{ .Game.ID }}/data/accuse" hx-trigger="loadAccuse from:body" hx-swap="innerHTML"> -

Loading...

+

{{Tx "game.loading_dialog"}}

@@ -66,14 +66,14 @@

{{ .Game.Name }}

-

Verdict

+

{{Tx "verdict.title"}}

- +
- +
@@ -83,27 +83,27 @@

Verdict

-

Penalty for the accused

+

{{Tx "penalty.title"}}

- + - +
- +
-

Invite players

- join QR code +

{{Tx "invite.title"}}

+ {{Tx - - - + + +
@@ -117,7 +117,7 @@

Invite players

{{ template "footer" }} diff --git a/static/html/tmpl.join.html b/static/html/tmpl.join.html index fda0467..eeab842 100644 --- a/static/html/tmpl.join.html +++ b/static/html/tmpl.join.html @@ -1,9 +1,9 @@ - + - Rulette | {{ .Name }} + {{Tx "app.name"}} | {{ .Name }} @@ -14,8 +14,8 @@

{{ .Name }}

- - + +
diff --git a/static/html/tmpl.players.html b/static/html/tmpl.players.html index a5505ae..ccf7c36 100644 --- a/static/html/tmpl.players.html +++ b/static/html/tmpl.players.html @@ -7,7 +7,7 @@
{{ .Name }} {{ .Points.Value }} - + {{ $rules := false }} {{ range $cards }} {{ if eq .PlayerID.Int32 $pid }} @@ -27,7 +27,7 @@ data-open-dialog="invite-dialog" data-invite-link="/{{ $gid }}/join" data-qr-src="/{{ $gid }}/qr"> - Invite Players + {{Tx "invite.invite_players_button"}} {{ $isHost := false }} {{ range .Players }} @@ -39,7 +39,7 @@ {{ end }} diff --git a/static/html/tmpl.points.html b/static/html/tmpl.points.html index f63af32..23a9eac 100644 --- a/static/html/tmpl.points.html +++ b/static/html/tmpl.points.html @@ -8,7 +8,7 @@ {{ if and $isHost (ne .Game.StateName "inviting") }}
{{ end }} diff --git a/static/html/tmpl.status.html b/static/html/tmpl.status.html index 0a120b0..f61280c 100644 --- a/static/html/tmpl.status.html +++ b/static/html/tmpl.status.html @@ -2,10 +2,10 @@
{{ .Game.StateDescription }}
{{ if .Game.InitiativeCurrent.Valid }}
- Turn {{ .Game.InitiativeCurrent.Int32 }} of {{ .Game.PlayerCount }} + {{Tx "status.turn_label"}} {{ .Game.InitiativeCurrent.Int32 }} {{Tx "status.turn_of"}} {{ .Game.PlayerCount }} {{ range .Players }} {{ if eq .Initiative.Int32 $.Game.InitiativeCurrent.Int32 }} - — {{ .Name }}'s turn + {{Tx "status.turn_dash"}} {{ .Name }}{{Tx "status.turn_owner_suffix"}} {{ end }} {{ end }}
diff --git a/static/html/tmpl.table.html b/static/html/tmpl.table.html index eafc671..561ee98 100644 --- a/static/html/tmpl.table.html +++ b/static/html/tmpl.table.html @@ -4,12 +4,12 @@ {{ end }} @@ -27,11 +27,11 @@
-

{{ $effect }} card!

-

Choose a card to {{ $effect }}:

+

{{ $effect }} {{Tx "modifier.card_suffix"}}

+

{{Tx "modifier.choose_label"}} {{ $effect }}:

{{ if or (eq $effect "clone") (eq $effect "transfer") }}
- Target player: + {{Tx "modifier.target_legend"}} {{ range $.Players }} {{ if ne .PlayerID $pid }}