This commit is contained in:
2026-10-23 00:45:30 +07:00
parent 60119a07e8
commit 146700cf4c
34 changed files with 622 additions and 124 deletions
+36 -32
View File
@@ -1450,8 +1450,8 @@ message UploadPlaylistTrackRsp {
}
message ReorderPlaylistTracksReq {
int32 playlistID = 1;
repeated int32 trackIDs = 2;
int32 playlistID = 1;
repeated int32 trackIDs = 2;
}
message ReorderPlaylistTracksRsp {
@@ -1727,18 +1727,20 @@ message GetGamesRsp {
}
message Game {
int32 ID = 1;
string name = 2;
string description = 3;
google.protobuf.Timestamp start_at = 4;
string status = 5;
Scenario scenario = 6;
repeated Team teams = 7;
optional google.protobuf.Timestamp started_at = 8;
optional google.protobuf.Timestamp ended_at = 9;
string format = 10;
string location = 11;
GameExport export = 12;
int32 ID = 1;
string name = 2;
string description = 3;
google.protobuf.Timestamp start_at = 4;
string status = 5;
Scenario scenario = 6;
repeated Team teams = 7;
optional google.protobuf.Timestamp started_at = 8;
optional google.protobuf.Timestamp ended_at = 9;
string format = 10;
string location = 11;
GameExport export = 12;
string wifi_name = 13; // Management settings only.
string wifi_password = 14;
}
// Download ready ZIP through the authenticated streaming HTTP route:
@@ -1774,13 +1776,13 @@ message GameExportRsp { GameExport export = 1; }
enum ScreenMode {
reserved 3, 5;
reserved "SCREEN_MODE_INTRO_DONE", "SCREEN_MODE_CONCLUSION_DONE";
SCREEN_MODE_UNSPECIFIED = 0;
SCREEN_MODE_COVER = 1;
SCREEN_MODE_INTRO = 2;
SCREEN_MODE_CONCLUSION = 4;
SCREEN_MODE_RESULTS = 6;
SCREEN_MODE_TIMER = 7;
SCREEN_MODE_TEXT = 8;
SCREEN_MODE_UNSPECIFIED = 0;
SCREEN_MODE_COVER = 1;
SCREEN_MODE_INTRO = 2;
SCREEN_MODE_CONCLUSION = 4;
SCREEN_MODE_RESULTS = 6;
SCREEN_MODE_TIMER = 7;
SCREEN_MODE_TEXT = 8;
}
enum ScreenTimerState {
@@ -1906,10 +1908,10 @@ message AdvanceScreenMusicRsp {
}
message BindScreenReq {
string screenID = 1;
string accessToken = 2;
int32 gameID = 3;
bool initializeAudio = 4;
string screenID = 1;
string accessToken = 2;
int32 gameID = 3;
bool initializeAudio = 4;
}
message BindScreenRsp {
@@ -2183,13 +2185,15 @@ message ModerateGameReviewRsp {
}
message UpdateGameReq {
int32 ID = 1;
string name = 2;
string description = 3;
google.protobuf.Timestamp start_at = 4;
int32 scenarioID = 5;
string format = 6;
string location = 7;
int32 ID = 1;
string name = 2;
string description = 3;
google.protobuf.Timestamp start_at = 4;
int32 scenarioID = 5;
string format = 6;
string location = 7;
optional string wifi_name = 8;
optional string wifi_password = 9;
}
message UpdateGameRsp {
@@ -4487,6 +4487,12 @@
},
"location": {
"type": "string"
},
"wifiName": {
"type": "string"
},
"wifiPassword": {
"type": "string"
}
}
},
@@ -5137,6 +5143,13 @@
},
"export": {
"$ref": "#/definitions/evening_detective_serverGameExport"
},
"wifiName": {
"type": "string",
"description": "Management settings only."
},
"wifiPassword": {
"type": "string"
}
}
},
+6
View File
@@ -17,6 +17,12 @@ MIME и SHA-256 в нижнем регистре. В перечень входи
не входит. Все остальные ZIP-записи должны быть заявлены и использованы
ссылками из данных игры. Вложенные архивы и каталоги не используются.
В объекте `game` необязательные `wifi_name` и `wifi_password` содержат настройки
сети для печати карточек команд и чеков. Они сохраняются при экспорте и импорте;
пакеты без этих полей используют пустые значения. Пароль входит в пакет открытым
текстом, как и данные входа команд. После изменения настроек для переноса нужен
новый экспорт: готовый пакет остаётся снимком на момент подготовки.
Полный перечень JSON-полей задан типами в `model.go`; неизвестные поля и
неподдерживаемые версии при чтении отклоняются. Время записывается как RFC3339,
`token_hash` — как стандартный Base64 от исходных 32 байтов SHA-256.
+2 -1
View File
@@ -3,6 +3,7 @@ package app
import (
"errors"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/scenarios_service"
@@ -14,7 +15,7 @@ func gameErrorStatus(err error, internalMessage string) error {
switch {
case errors.Is(err, game_service.ErrGameNotFound), errors.Is(err, scenarios_service.ErrScenarioNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, game_service.ErrInvalidGameFormat):
case errors.Is(err, game_service.ErrInvalidGameFormat), errors.Is(err, team_qr_pdf.ErrInvalidWiFi):
return status.Error(codes.InvalidArgument, err.Error())
default:
return status.Error(codes.Internal, internalMessage)
+7
View File
@@ -68,3 +68,10 @@ func mapGameSummary(game *game_model.Game) *proto.Game {
Status: game.Status,
}
}
func mapGameSettings(game *game_model.Game) *proto.Game {
result := mapGame(game)
result.WifiName = game.WiFiName
result.WifiPassword = game.WiFiPassword
return result
}
+48
View File
@@ -0,0 +1,48 @@
package app
import (
"testing"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/services/game_model"
"evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
)
func TestGameWiFiVisibility(t *testing.T) {
game := &game_model.Game{WiFiName: "Детектив", WiFiPassword: "secret"}
settings := mapGameSettings(game)
if settings.WifiName != game.WiFiName || settings.WifiPassword != game.WiFiPassword {
t.Fatal("management settings missing")
}
for _, result := range []*proto.Game{mapGame(game), mapGameSummary(game)} {
if result.WifiName != "" || result.WifiPassword != "" {
t.Fatal("Wi-Fi exposed outside management settings")
}
}
}
func TestGameWiFiOptionalUpdate(t *testing.T) {
for _, tc := range []struct {
body string
present bool
}{
{`{}`, false},
{`{"wifiName":"", "wifiPassword":""}`, true},
{`{"wifiName":"Venue", "wifiPassword":" secret "}`, true},
} {
var req proto.UpdateGameReq
if err := protojson.Unmarshal([]byte(tc.body), &req); err != nil {
t.Fatal(err)
}
if (req.WifiName != nil) != tc.present || (req.WifiPassword != nil) != tc.present {
t.Fatal("missing fields cannot be distinguished from clearing")
}
}
if got := status.Code(gameErrorStatus(team_qr_pdf.ErrInvalidWiFi, "save failed")); got != codes.InvalidArgument {
t.Fatalf("code = %v", got)
}
}
+3 -1
View File
@@ -93,7 +93,7 @@ func (s *server) GetGame(ctx context.Context, req *proto.GetGameReq) (*proto.Get
}
return &proto.GetGameRsp{
Game: mapGame(game),
Game: mapGameSettings(game),
}, nil
}
@@ -184,6 +184,8 @@ func (s *server) UpdateGame(ctx context.Context, req *proto.UpdateGameReq) (*pro
req.StartAt.AsTime(),
req.ScenarioID,
req.Format,
req.WifiName,
req.WifiPassword,
)
if err != nil {
return nil, gameErrorStatus(err, "не удалось изменить игру")
+36 -12
View File
@@ -18,8 +18,10 @@ var ErrNoCards = errors.New("нет карточек")
// Card contains the data rendered on a team QR-code card.
type Card struct {
Name string
URL string
Name string
URL string
WiFiName string
WiFiPassword string
}
//go:embed fonts/DejaVuSansCondensed.ttf
@@ -45,7 +47,18 @@ func Generate(cards []Card) ([]byte, error) {
pdf.SetCellMargin(0)
RegisterFont(pdf)
pdf.SetFillColor(0, 0, 0)
const margin, width, height, qrSize = 10.0, 190.0 / 3, 277.0 / 4, 36.0
const margin, qrSize = 10.0, 36.0
columns, rows := 3, 4
for _, card := range cards {
if err := ValidateWiFi(card.WiFiName, card.WiFiPassword); err != nil {
return nil, err
}
if card.WiFiName != "" {
columns, rows = 2, 3
}
}
width, height := 190.0/float64(columns), 277.0/float64(rows)
perPage := columns * rows
for i, card := range cards {
u, err := url.Parse(card.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
@@ -55,22 +68,22 @@ func Generate(cards []Card) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("qr команды %d: %w", i+1, err)
}
if i%12 == 0 {
if i%perPage == 0 {
pdf.AddPage()
pdf.SetDrawColor(100, 100, 100)
pdf.SetLineWidth(0.2)
pdf.SetDashPattern([]float64{1, 1}, 0)
for col := 0; col <= 3; col++ {
for col := 0; col <= columns; col++ {
x := margin + float64(col)*width
pdf.Line(x, margin, x, margin+4*height)
pdf.Line(x, margin, x, margin+float64(rows)*height)
}
for row := 0; row <= 4; row++ {
for row := 0; row <= rows; row++ {
y := margin + float64(row)*height
pdf.Line(margin, y, margin+3*width, y)
pdf.Line(margin, y, margin+float64(columns)*width, y)
}
}
x := margin + float64(i%3)*width
y := margin + float64(i%12/3)*height
x := margin + float64(i%columns)*width
y := margin + float64(i%perPage/columns)*height
bitmap := qr.Bitmap()
pixel := qrSize / float64(len(bitmap))
for row, cells := range bitmap {
@@ -86,23 +99,34 @@ func Generate(cards []Card) ([]byte, error) {
return nil, fmt.Errorf("название команды %d содержит неподдерживаемый символ %q", i+1, r)
}
}
var instructions []string
pdf.SetFont("teams", "", 8)
for _, paragraph := range WiFiInstructions(card.WiFiName, card.WiFiPassword) {
instructions = append(instructions, pdf.SplitText(paragraph, width-6)...)
}
instructionHeight := float64(len(instructions)) * 3.3
var lines []string
var lineHeight float64
for size := 12.0; size >= 6; size-- {
pdf.SetFont("teams", "", size)
lineHeight = size * 0.3528 * 1.15
lines = pdf.SplitText(name, width-6)
if float64(len(lines))*lineHeight <= height-qrSize-8 {
if float64(len(lines))*lineHeight <= height-qrSize-8-instructionHeight {
break
}
}
if float64(len(lines))*lineHeight > height-qrSize-8 {
if float64(len(lines))*lineHeight > height-qrSize-8-instructionHeight {
return nil, fmt.Errorf("название команды %d не помещается на карточке (%d строк, %f мм)", i+1, len(lines), lineHeight)
}
for line, text := range lines {
pdf.SetXY(x+3, y+qrSize+5+float64(line)*lineHeight)
pdf.CellFormat(width-6, lineHeight, text, "", 0, "C", false, 0, "")
}
pdf.SetFont("teams", "", 8)
for line, text := range instructions {
pdf.SetXY(x+3, y+qrSize+5+float64(len(lines))*lineHeight+float64(line)*3.3)
pdf.CellFormat(width-6, 3.3, text, "", 0, "L", false, 0, "")
}
}
var result bytes.Buffer
if err := pdf.Output(&result); err != nil {
@@ -52,3 +52,28 @@ func TestGenerate(t *testing.T) {
}
}
}
func TestGenerateWiFiCards(t *testing.T) {
for _, password := range []string{"", strings.Repeat("W", 64), " secret "} {
cards := make([]Card, 7)
for i := range cards {
cards[i] = Card{
Name: strings.Repeat("Ш", 255),
URL: "https://example.org/team-story/019a1234-5678-7abc-8def-000000000007?password=abc123",
WiFiName: strings.Repeat("W", 32),
WiFiPassword: password,
}
}
data, err := Generate(cards)
if err != nil {
t.Fatal(err)
}
if got := len(regexp.MustCompile(`/Type /Page\b`).FindAll(data, -1)); got != 2 {
t.Fatalf("got %d pages, want 2", got)
}
}
_, err := Generate([]Card{{Name: "Team", URL: "https://example.org", WiFiName: "bad\n"}})
if !errors.Is(err, ErrInvalidWiFi) {
t.Fatal("invalid Wi-Fi accepted")
}
}
+59
View File
@@ -0,0 +1,59 @@
package team_qr_pdf
import (
"errors"
"fmt"
"unicode"
"unicode/utf8"
"golang.org/x/image/font/sfnt"
)
// ErrInvalidWiFi indicates Wi-Fi settings that cannot be printed reliably.
var ErrInvalidWiFi = errors.New("некорректные параметры Wi-Fi")
// ValidateWiFi checks lengths, printable characters and the embedded font.
func ValidateWiFi(name, password string) error {
if len(name) > 32 || !utf8.ValidString(name) {
return fmt.Errorf("%w: название сети — до 32 байт UTF-8", ErrInvalidWiFi)
}
if len(password) > 64 {
return fmt.Errorf("%w: пароль — до 64 печатных ASCII-символов", ErrInvalidWiFi)
}
for _, r := range password {
if r < 32 || r > 126 {
return fmt.Errorf("%w: пароль — только печатные ASCII-символы", ErrInvalidWiFi)
}
}
if name == "" {
return nil
}
ttf, err := sfnt.Parse(font)
if err != nil {
return fmt.Errorf("parse Wi-Fi font: %w", err)
}
for _, r := range name {
glyph, err := ttf.GlyphIndex(nil, r)
if err != nil || glyph == 0 || r > 0xffff || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return fmt.Errorf("%w: название сети содержит непечатаемый символ", ErrInvalidWiFi)
}
}
return nil
}
// WiFiInstructions returns print paragraphs, or none when no network is configured.
func WiFiInstructions(name, password string) []string {
if name == "" {
return nil
}
access := "Без пароля"
if password != "" {
access = "Пароль: «" + password + "»"
}
return []string{
"1. Подключитесь к Wi-Fi «" + name + "».",
access,
"2. Отсканируйте QR-код команды.",
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
}
}
+57
View File
@@ -0,0 +1,57 @@
package team_qr_pdf
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestValidateWiFi(t *testing.T) {
for _, tc := range []struct {
name, password string
invalid bool
}{
{},
{name: "Детектив", password: " secret password "},
{name: strings.Repeat("Ш", 16), password: strings.Repeat("~", 64)},
{name: strings.Repeat("x", 33), invalid: true},
{name: strings.Repeat("Ш", 17), invalid: true},
{name: "сеть\n", invalid: true},
{name: "сеть\u2028", invalid: true},
{name: "сеть\u200b", invalid: true},
{name: "сеть🕵", invalid: true},
{name: string([]byte{0xff}), invalid: true},
{password: strings.Repeat("x", 65), invalid: true},
{password: "пароль", invalid: true},
{password: "secret\t", invalid: true},
{password: "secret\x7f", invalid: true},
} {
err := ValidateWiFi(tc.name, tc.password)
if tc.invalid && !errors.Is(err, ErrInvalidWiFi) {
t.Fatal("invalid Wi-Fi accepted")
}
if !tc.invalid && err != nil {
t.Fatal(err)
}
}
}
func TestWiFiInstructions(t *testing.T) {
if got := WiFiInstructions("", "secret"); len(got) != 0 {
t.Fatal("instructions without a network")
}
got := WiFiInstructions(" Venue ", " secret ")
want := []string{
"1. Подключитесь к Wi-Fi « Venue ».",
"Пароль: « secret »",
"2. Отсканируйте QR-код команды.",
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
}
if !reflect.DeepEqual(got, want) {
t.Fatal("instructions changed credentials")
}
if got := WiFiInstructions("Venue", ""); got[1] != "Без пароля" {
t.Fatal("open network requires a password")
}
}
+15 -13
View File
@@ -6,17 +6,19 @@ import (
// Game describes a game and its scenario and teams.
type Game struct {
Export *GameExport
Format string
ID int
Name string
Description string
Location *string
StartAt time.Time
Status string
ScenarioID int
Scenario *Scenario
Teams []*Team
StartedAt *time.Time
EndedAt *time.Time
Export *GameExport
Format string
ID int
Name string
WiFiName string
WiFiPassword string
Description string
Location *string
StartAt time.Time
Status string
ScenarioID int
Scenario *Scenario
Teams []*Team
StartedAt *time.Time
EndedAt *time.Time
}
@@ -14,6 +14,7 @@ func captureSnapshot(ctx context.Context, tx pgx.Tx, gameID int) ([]byte, error)
`SELECT jsonb_build_object(
'game', jsonb_build_object(
'id', g.id, 'name', g.name, 'description', COALESCE(g.description, ''),
'wifi_name', g.wifi_name, 'wifi_password', g.wifi_password,
'location', g.location, 'start_at', g.start_at AT TIME ZONE 'UTC',
'status', g.status, 'format', g.format, 'scenario_id', g.scenario_id,
'playlist_id', COALESCE(m.playlist_id, g.playlist_id),
+5 -3
View File
@@ -89,7 +89,7 @@ func (r *GameImportsRepo) Complete(ctx context.Context, id string, data []byte)
_, err = tx.Exec(
ctx,
`INSERT INTO games (
id, name, description, location, start_at, status, format, scenario_id, playlist_id
id, name, description, location, start_at, status, format, scenario_id, playlist_id, wifi_name, wifi_password
)
SELECT
g.id,
@@ -100,10 +100,12 @@ func (r *GameImportsRepo) Complete(ctx context.Context, id string, data []byte)
g.status,
g.format,
$2,
($3::jsonb->>g.playlist_id::text)::integer
($3::jsonb->>g.playlist_id::text)::integer,
COALESCE(g.wifi_name, ''),
COALESCE(g.wifi_password, '')
FROM jsonb_to_record($1::jsonb->'game') AS g(
id integer, name text, description text, location text, start_at timestamptz,
status text, format text, playlist_id integer
status text, format text, playlist_id integer, wifi_name text, wifi_password text
)`,
data,
mappings.ScenarioID,
+9
View File
@@ -77,6 +77,8 @@ func (r *GamesRepo) GetGameByID(
games.scenario_id,
games.started_at,
games.ended_at,
games.wifi_name,
games.wifi_password,
(SELECT
CASE WHEN e.released_at IS NOT NULL OR EXISTS (
SELECT 1
@@ -105,6 +107,8 @@ func (r *GamesRepo) GetGameByID(
&game.ScenarioID,
&game.StartedAt,
&game.EndedAt,
&game.WiFiName,
&game.WiFiPassword,
&game.Export,
)
if err != nil {
@@ -207,6 +211,7 @@ func (r *GamesRepo) UpdateGame(
startAt time.Time,
scenarioID int32,
format string,
wifiName, wifiPassword *string,
) error {
tag, err := r.pool.Exec(
ctx,
@@ -218,6 +223,8 @@ func (r *GamesRepo) UpdateGame(
start_at = $4,
scenario_id = $5,
format = $6,
wifi_name = COALESCE($8, wifi_name),
wifi_password = COALESCE($9, wifi_password),
updated_at = NOW()
WHERE id = $7`,
name,
@@ -227,6 +234,8 @@ func (r *GamesRepo) UpdateGame(
scenarioID,
format,
id,
wifiName,
wifiPassword,
)
if err != nil {
return err
@@ -34,7 +34,8 @@ func TestAnswerAccessPostgres(t *testing.T) {
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
status TEXT, password TEXT, game_id INT);
INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
ALTER TABLE games ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '', ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '';
INSERT INTO games (id, name, description, location, start_at, format, status, scenario_id, started_at, ended_at) VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1);
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
@@ -46,7 +46,8 @@ func TestExportAnyStatusProducesFreshSetup(t *testing.T) {
t.Run(format+"/"+state, func(t *testing.T) {
ctx := context.Background()
f := newServiceFixture(t)
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now(), int32(f.scenarioID), format))
wifiName, wifiPassword := "Детектив", " secret "
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now(), int32(f.scenarioID), format, &wifiName, &wifiPassword))
if state != "draft" {
check(t, f.games.StartGame(ctx, f.gameID))
if state == "pause" {
@@ -102,7 +103,7 @@ func TestExportAnyStatusProducesFreshSetup(t *testing.T) {
check(t, err)
local, err := venue.games.GetGameByID(ctx, imported.GameID)
check(t, err)
if local.Status != "draft" || local.StartedAt != nil || local.EndedAt != nil {
if local.Status != "draft" || local.StartedAt != nil || local.EndedAt != nil || local.WiFiName != wifiName || local.WiFiPassword != wifiPassword {
t.Fatalf("import did not start fresh: %+v", local)
}
actions, err = actions_repo.NewActionsRepo(venue.pool).GetActionsByTeamID(ctx, f.teamID)
@@ -42,7 +42,7 @@ func newReturnFixture(t *testing.T, format string) returnFixture {
t.Helper()
ctx := context.Background()
f := newServiceFixture(t)
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format))
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format, nil, nil))
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, f.scenarioID, `{"places":[{"code":"p","name":"Place","text":"Text","applications":[{"name":"Clue","image":"original.png"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"},{"code":"q2","text":"Why?","answer":"Money"}]}`))
_, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Removed", "removed-qr")
check(t, err)
@@ -125,7 +125,7 @@ func TestReturnAtomicLifecycle(t *testing.T) {
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.data.DeletedTeamIDs[0]))
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.teamID))
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format))
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format, nil, nil))
token := hex.EncodeToString(bytes.Repeat([]byte{9}, 32))
check(t, f.teams.SetParticipationToken(ctx, f.teamID, token))
raw := f.pack(t)
@@ -454,7 +454,7 @@ func TestReturnOverwritesOnlineEditsAndIsolatesScenario(t *testing.T) {
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, selected, changedStory))
other, err := f.games.AddGame(ctx, "Other game", "", nil, time.Now(), int32(selected), "offline")
check(t, err)
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline"))
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline", nil, nil))
check(t, f.teams.UpdateTeam(ctx, f.teamID, "Online team name"))
extraTeam, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Online extra team", "online-qr")
check(t, err)
+14 -12
View File
@@ -9,16 +9,18 @@ import (
// Game describes a game exposed by the service layer.
type Game struct {
Export *repos.GameExport
Format string
ID int
Name string
Description string
Location *string
StartAt time.Time
Status string
Scenario *scenarios_service.Scenario
Teams []*Team
StartedAt *time.Time
EndedAt *time.Time
Export *repos.GameExport
Format string
ID int
Name string
WiFiName string
WiFiPassword string
Description string
Location *string
StartAt time.Time
Status string
Scenario *scenarios_service.Scenario
Teams []*Team
StartedAt *time.Time
EndedAt *time.Time
}
+14 -12
View File
@@ -11,18 +11,20 @@ func MapGame(
domain string,
) *Game {
return &Game{
ID: o.ID,
Export: o.Export,
Name: o.Name,
Description: o.Description,
Location: o.Location,
StartAt: o.StartAt,
Status: o.Status,
Format: o.Format,
Scenario: scenarios_service.MapScenarioLight(o.Scenario, domain),
Teams: MapTeams(o.Teams),
StartedAt: o.StartedAt,
EndedAt: o.EndedAt,
ID: o.ID,
Export: o.Export,
Name: o.Name,
Description: o.Description,
WiFiName: o.WiFiName,
WiFiPassword: o.WiFiPassword,
Location: o.Location,
StartAt: o.StartAt,
Status: o.Status,
Format: o.Format,
Scenario: scenarios_service.MapScenarioLight(o.Scenario, domain),
Teams: MapTeams(o.Teams),
StartedAt: o.StartedAt,
EndedAt: o.EndedAt,
}
}
+13 -11
View File
@@ -51,17 +51,19 @@ type Snapshot struct {
}
type Game struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Location *string `json:"location,omitempty"`
StartAt time.Time `json:"start_at"`
Status string `json:"status"`
Format string `json:"format"`
ScenarioID int `json:"scenario_id"`
PlaylistID *int `json:"playlist_id,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
WiFiName string `json:"wifi_name,omitempty"`
WiFiPassword string `json:"wifi_password,omitempty"`
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Location *string `json:"location,omitempty"`
StartAt time.Time `json:"start_at"`
Status string `json:"status"`
Format string `json:"format"`
ScenarioID int `json:"scenario_id"`
PlaylistID *int `json:"playlist_id,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
}
type Scenario struct {
@@ -90,6 +90,10 @@ func TestPackagePreservesPregameData(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
snapshot.Data.Game.Format = tc.format
if tc.format == "offline" {
snapshot.Data.Game.WiFiName = "Детектив"
snapshot.Data.Game.WiFiPassword = " secret "
}
snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: "019a1234-5678-7abc-8def-000000000063"})
before, _ := json.Marshal(snapshot)
calls := map[string]int{}
@@ -9,6 +9,8 @@ import (
"strings"
"unicode"
"evening_detective_server/internal/modules/team_qr_pdf"
"github.com/google/uuid"
)
@@ -18,6 +20,9 @@ func invalid(reason string) error {
func validateData(data *Data) error {
g := data.Game
if err := team_qr_pdf.ValidateWiFi(g.WiFiName, g.WiFiPassword); err != nil {
return invalid("некорректные параметры Wi-Fi")
}
if g.ID <= 0 || strings.TrimSpace(g.Name) == "" || g.StartAt.IsZero() ||
g.Status != "draft" || g.StartedAt != nil || g.EndedAt != nil ||
(g.Format != "online" && g.Format != "offline") {
@@ -64,7 +64,7 @@ func TestProdFlowReturns(t *testing.T) {
t.Fatal(err)
}
games := games_repo.NewGamesRepo(pool)
if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format); err != nil {
if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format, nil, nil); err != nil {
t.Fatal(err)
}
} else if err != nil {
@@ -58,7 +58,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
CREATE TEMP TABLE team_answers(team_id UUID, question_code TEXT, answer TEXT, score INT, updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(team_id, question_code));
INSERT INTO scenarios(id,name,scenario) VALUES(1,'Case','{"introduction":{"text":"Intro"},"places":[{"code":"p","name":"Place","applications":[{"name":"Clue","image":"clue.pdf"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}');`)
check(err)
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} {
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql", "20261023120000_game_wifi.sql"} {
data, err := os.ReadFile("../../../migrations/" + name)
check(err)
_, err = pool.Exec(ctx, strings.Split(string(data), "-- +goose Down")[0])
@@ -77,7 +77,23 @@ func TestServicesLifecyclePostgres(t *testing.T) {
start := time.Now().Add(48 * time.Hour)
id, err := game.AddGame(ctx, "Game", "", nil, start, 1, "online")
check(err)
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, ""))
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "", nil, nil))
wifiName, wifiPassword := "Детектив", " secret "
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &wifiName, &wifiPassword))
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", nil, nil))
stored, err := game.GetGame(ctx, id)
check(err)
if stored.WiFiName != wifiName || stored.WiFiPassword != wifiPassword {
t.Fatal("old client erased Wi-Fi settings")
}
empty := ""
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &empty, &empty))
stored, err = game.GetGame(ctx, id)
check(err)
if stored.WiFiName != "" || stored.WiFiPassword != "" {
t.Fatal("Wi-Fi settings not cleared")
}
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &wifiName, &wifiPassword))
registration := team_service.Registration{Name: "Team", Email: "captain@example.org", ParticipantsCount: 3}
teamID, err := team.RegisterTeam(ctx, id, "delivery", registration)
check(err)
+15 -2
View File
@@ -3,6 +3,9 @@ package game_service
import (
"context"
"errors"
"time"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/repos/actions_repo"
"evening_detective_server/internal/repos/applications_repo"
"evening_detective_server/internal/repos/games_repo"
@@ -10,7 +13,6 @@ import (
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/services/game_model"
"evening_detective_server/internal/services/scenarios_service"
"time"
)
// GameService handles game lifecycle operations.
@@ -174,7 +176,18 @@ func (s *GameService) UpdateGame(
startAt time.Time,
scenarioID int32,
format string,
wifiName, wifiPassword *string,
) error {
nameValue, passwordValue := "", ""
if wifiName != nil {
nameValue = *wifiName
}
if wifiPassword != nil {
passwordValue = *wifiPassword
}
if err := team_qr_pdf.ValidateWiFi(nameValue, passwordValue); err != nil {
return err
}
if format == "" {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {
@@ -185,7 +198,7 @@ func (s *GameService) UpdateGame(
if format != "online" && format != "offline" {
return ErrInvalidGameFormat
}
return mapGameError(s.gamesRepo.UpdateGame(ctx, id, name, description, location, startAt, scenarioID, format))
return mapGameError(s.gamesRepo.UpdateGame(ctx, id, name, description, location, startAt, scenarioID, format, wifiName, wifiPassword))
}
// StartGame marks a game as started.
@@ -0,0 +1,24 @@
package game_service
import (
"context"
"errors"
"strings"
"testing"
"time"
"evening_detective_server/internal/modules/team_qr_pdf"
)
func TestUpdateGameRejectsInvalidWiFiBeforeWriting(t *testing.T) {
s := &GameService{}
for _, tc := range []struct{ name, password string }{
{name: strings.Repeat("x", 33)},
{password: "bad\n"},
} {
err := s.UpdateGame(context.Background(), 1, "Game", "", nil, time.Now(), 1, "online", &tc.name, &tc.password)
if !errors.Is(err, team_qr_pdf.ErrInvalidWiFi) {
t.Fatal("invalid settings reached repository")
}
}
}
@@ -37,7 +37,8 @@ func TestFinishedTeamStoryPostgres(t *testing.T) {
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
status TEXT, password TEXT, game_id INT);
INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
ALTER TABLE games ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '', ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '';
INSERT INTO games (id, name, description, location, start_at, format, status, scenario_id, started_at, ended_at) VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1);
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
+64 -5
View File
@@ -103,6 +103,9 @@ func (s *TeamService) teamReceipt(ctx context.Context, gameID int, teamID string
}
func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
if err := team_qr_pdf.ValidateWiFi(card.WiFiName, card.WiFiPassword); err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidReceipt, err)
}
name := strings.Join(strings.Fields(card.Name), " ")
if name == "" || len([]rune(name)) > 255 {
return nil, ErrInvalidReceipt
@@ -111,17 +114,34 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
if err != nil {
return nil, fmt.Errorf("parse receipt font: %w", err)
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 26, DPI: 72, Hinting: font.HintingFull})
nameSize, nameLineHeight := 26.0, 34
if card.WiFiName != "" {
nameSize, nameLineHeight = 20, 26
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("open receipt font: %w", err)
}
defer face.Close()
defer func() {
if face != nil {
_ = face.Close()
}
}()
for _, r := range name {
if _, ok := face.GlyphAdvance(r); !ok {
return nil, ErrInvalidReceipt
}
}
lines := wrapReceiptName(name, face)
instructionFace, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("open Wi-Fi receipt font: %w", err)
}
defer instructionFace.Close()
var instructions []string
for _, paragraph := range team_qr_pdf.WiFiInstructions(card.WiFiName, card.WiFiPassword) {
instructions = append(instructions, wrapReceiptText(paragraph, instructionFace)...)
}
qr, err := qrcode.New(card.URL, qrcode.Medium)
if err != nil {
return nil, ErrInvalidReceipt
@@ -132,8 +152,20 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
return nil, ErrInvalidReceipt
}
qrSize := len(bitmap) * scale
qrY := 16 + len(lines)*34 + 16
height := qrY + qrSize + 24
qrY := 16 + len(lines)*nameLineHeight + 16
height := qrY + qrSize + 24 + len(instructions)*26
for card.WiFiName != "" && height > receiptMaxHeight && nameSize > 16 {
_ = face.Close()
nameSize -= 2
nameLineHeight = int(nameSize) + 6
face, err = opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("resize receipt font: %w", err)
}
lines = wrapReceiptName(name, face)
qrY = 16 + len(lines)*nameLineHeight + 16
height = qrY + qrSize + 24 + len(instructions)*26
}
if height > receiptMaxHeight {
return nil, ErrInvalidReceipt
}
@@ -142,7 +174,7 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
drawer := font.Drawer{Dst: img, Src: image.Black, Face: face}
for i, line := range lines {
width := drawer.MeasureString(line).Ceil()
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*34)
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*nameLineHeight)
drawer.DrawString(line)
}
x := (receiptWidth - qrSize) / 2
@@ -153,6 +185,11 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
}
}
}
drawer.Face = instructionFace
for i, line := range instructions {
drawer.Dot = fixed.P(16, qrY+qrSize+instructionFace.Metrics().Ascent.Ceil()+i*26)
drawer.DrawString(line)
}
for i, pixel := range img.Pix {
if pixel < 128 {
img.Pix[i] = 0
@@ -211,3 +248,25 @@ func validateReceiptRaster(data []byte) error {
}
return nil
}
// Preserve spaces in credentials when wrapping the printed instructions.
func wrapReceiptText(text string, face font.Face) []string {
var lines []string
line := ""
for _, r := range text {
for line != "" && font.MeasureString(face, line+string(r)).Ceil() > receiptWidth-32 {
if space := strings.LastIndexByte(line, ' '); space >= 0 {
lines = append(lines, line[:space+1])
line = line[space+1:]
} else {
lines = append(lines, line)
line = ""
}
}
line += string(r)
}
if line != "" {
lines = append(lines, line)
}
return lines
}
+53 -4
View File
@@ -14,18 +14,21 @@ import (
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/teams_repo"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
)
const receiptTestTeamID = "019a1234-5678-7abc-8def-000000000007"
type receiptReposStub struct {
ITeamsRepository
team *repos.Team
gameErr, teamErr error
team *repos.Team
gameErr, teamErr error
wifiName, wifiPassword string
}
func (s *receiptReposStub) GetGameByID(context.Context, int) (*repos.Game, error) {
return &repos.Game{ID: 1, Name: "Проверка чеков"}, s.gameErr
return &repos.Game{ID: 1, Name: "Проверка чеков", WiFiName: s.wifiName, WiFiPassword: s.wifiPassword}, s.gameErr
}
func (s *receiptReposStub) GetTeamByID(context.Context, string) (*repos.Team, error) {
@@ -81,7 +84,7 @@ func TestTeamReceiptValidation(t *testing.T) {
func TestTeamReceiptPreviewMatchesPrintedRaster(t *testing.T) {
for _, name := range []string{"Вечерние сыщики", "Очень длинное название команды БезПробеловОченьДлинноеНазваниеКоманды"} {
t.Run(name, func(t *testing.T) {
repos := &receiptReposStub{team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
repos := &receiptReposStub{wifiName: "Детектив", wifiPassword: " secret ", team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
printer := &receiptPrinterStub{}
s := NewTeamService(repos, repos, nil, "https://game.test/app", nil, true, printer)
data, err := s.PreviewTeamReceipt(context.Background(), 1, receiptTestTeamID)
@@ -149,3 +152,49 @@ func TestReceiptPrinterErrorsRoundTrip(t *testing.T) {
})
}
}
func TestReceiptWiFiFitsAndPreservesCredentials(t *testing.T) {
for _, name := range []string{"Команда", strings.Repeat("Ш", 255), strings.Repeat("W", 255)} {
for _, password := range []string{"", " secret ", strings.Repeat("W", 64)} {
card := team_qr_pdf.Card{
Name: name,
URL: "https://example.org/team-story/019a1234-5678-7abc-8def-000000000007?password=abc123",
WiFiName: strings.Repeat("W", 32),
WiFiPassword: password,
}
img, err := renderReceipt(card)
if err != nil {
t.Fatalf("name runes=%d, password bytes=%d: %v", len([]rune(name)), len(password), err)
}
if img.Bounds().Dy() > receiptMaxHeight {
t.Fatal("receipt exceeds printer limit")
}
if err := validateReceiptRaster(packReceipt(img)); err != nil {
t.Fatal(err)
}
}
}
}
func TestWrapReceiptTextPreservesCredentials(t *testing.T) {
ttf, err := opentype.Parse(team_qr_pdf.FontData())
if err != nil {
t.Fatal(err)
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
if err != nil {
t.Fatal(err)
}
defer face.Close()
for _, text := range []string{" secret password ", " " + strings.Repeat("W", 64), "Пароль: «" + strings.Repeat("W", 64) + "»", "Подключитесь к Wi-Fi организатора."} {
lines := wrapReceiptText(text, face)
if strings.Join(lines, "") != text {
t.Fatal("wrapping changed credentials")
}
for _, line := range lines {
if font.MeasureString(face, line).Ceil() > receiptWidth-32 {
t.Fatal("text clipped at receipt edge")
}
}
}
}
+6 -1
View File
@@ -29,7 +29,12 @@ func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_q
link.Path = strings.TrimRight(base.Path, "/") + "/team-story/" + team.ID
link.RawPath = ""
link.RawQuery = url.Values{"password": {team.Password}}.Encode()
cards = append(cards, team_qr_pdf.Card{Name: team.Name, URL: link.String()})
cards = append(cards, team_qr_pdf.Card{
Name: team.Name,
URL: link.String(),
WiFiName: game.WiFiName,
WiFiPassword: game.WiFiPassword,
})
}
name := strings.Map(func(r rune) rune {
if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) {
@@ -9,7 +9,7 @@ import (
)
func TestTeamQRData(t *testing.T) {
game := &repos.Game{Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)}
game := &repos.Game{WiFiName: "Детектив", WiFiPassword: " secret ", Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)}
teams := []*repos.Team{{ID: "019a1234-5678-7abc-8def-000000000007", Name: "Знатоки", Password: "a+b&c?"}}
cards, filename, err := teamQRData("https://example.org/app/", game, teams)
if err != nil {
@@ -18,6 +18,9 @@ func TestTeamQRData(t *testing.T) {
if len(cards) != 1 || cards[0].Name != "Знатоки" {
t.Fatalf("cards: %+v", cards)
}
if cards[0].WiFiName != game.WiFiName || cards[0].WiFiPassword != game.WiFiPassword {
t.Fatal("Wi-Fi settings missing from print card")
}
u, err := url.Parse(cards[0].URL)
if err != nil || u.Path != "/app/team-story/019a1234-5678-7abc-8def-000000000007" || u.Query().Get("password") != teams[0].Password {
t.Fatalf("wrong team link: %s", cards[0].URL)
+11
View File
@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE games
ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '',
ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '',
ADD CONSTRAINT games_wifi_name_length CHECK (octet_length(wifi_name) <= 32),
ADD CONSTRAINT games_wifi_password_length CHECK (octet_length(wifi_password) <= 64);
-- +goose Down
ALTER TABLE games
DROP COLUMN wifi_name,
DROP COLUMN wifi_password;
+44 -4
View File
@@ -5369,6 +5369,8 @@ type Game struct {
Format string `protobuf:"bytes,10,opt,name=format,proto3" json:"format,omitempty"`
Location string `protobuf:"bytes,11,opt,name=location,proto3" json:"location,omitempty"`
Export *GameExport `protobuf:"bytes,12,opt,name=export,proto3" json:"export,omitempty"`
WifiName string `protobuf:"bytes,13,opt,name=wifi_name,json=wifiName,proto3" json:"wifi_name,omitempty"` // Management settings only.
WifiPassword string `protobuf:"bytes,14,opt,name=wifi_password,json=wifiPassword,proto3" json:"wifi_password,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -5487,6 +5489,20 @@ func (x *Game) GetExport() *GameExport {
return nil
}
func (x *Game) GetWifiName() string {
if x != nil {
return x.WifiName
}
return ""
}
func (x *Game) GetWifiPassword() string {
if x != nil {
return x.WifiPassword
}
return ""
}
// Download ready ZIP through the authenticated streaming HTTP route:
// GET /api/games/{ID}/export/download (not an HttpBody RPC).
type GameExport struct {
@@ -9428,6 +9444,8 @@ type UpdateGameReq struct {
ScenarioID int32 `protobuf:"varint,5,opt,name=scenarioID,proto3" json:"scenarioID,omitempty"`
Format string `protobuf:"bytes,6,opt,name=format,proto3" json:"format,omitempty"`
Location string `protobuf:"bytes,7,opt,name=location,proto3" json:"location,omitempty"`
WifiName *string `protobuf:"bytes,8,opt,name=wifi_name,json=wifiName,proto3,oneof" json:"wifi_name,omitempty"`
WifiPassword *string `protobuf:"bytes,9,opt,name=wifi_password,json=wifiPassword,proto3,oneof" json:"wifi_password,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -9511,6 +9529,20 @@ func (x *UpdateGameReq) GetLocation() string {
return ""
}
func (x *UpdateGameReq) GetWifiName() string {
if x != nil && x.WifiName != nil {
return *x.WifiName
}
return ""
}
func (x *UpdateGameReq) GetWifiPassword() string {
if x != nil && x.WifiPassword != nil {
return *x.WifiPassword
}
return ""
}
type UpdateGameRsp struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -13370,7 +13402,7 @@ const file_main_proto_rawDesc = "" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\"\r\n" +
"\vGetGamesReq\"I\n" +
"\vGetGamesRsp\x12:\n" +
"\x05games\x18\x01 \x03(\v2$.crabs.evening_detective_server.GameR\x05games\"\xad\x04\n" +
"\x05games\x18\x01 \x03(\v2$.crabs.evening_detective_server.GameR\x05games\"\xef\x04\n" +
"\x04Game\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
@@ -13385,7 +13417,9 @@ const file_main_proto_rawDesc = "" +
"\x06format\x18\n" +
" \x01(\tR\x06format\x12\x1a\n" +
"\blocation\x18\v \x01(\tR\blocation\x12B\n" +
"\x06export\x18\f \x01(\v2*.crabs.evening_detective_server.GameExportR\x06exportB\r\n" +
"\x06export\x18\f \x01(\v2*.crabs.evening_detective_server.GameExportR\x06export\x12\x1b\n" +
"\twifi_name\x18\r \x01(\tR\bwifiName\x12#\n" +
"\rwifi_password\x18\x0e \x01(\tR\fwifiPasswordB\r\n" +
"\v_started_atB\v\n" +
"\t_ended_at\"\x96\x01\n" +
"\n" +
@@ -13666,7 +13700,7 @@ const file_main_proto_rawDesc = "" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\"[\n" +
"\x15ModerateGameReviewRsp\x12B\n" +
"\x06review\x18\x01 \x01(\v2*.crabs.evening_detective_server.GameReviewR\x06review\"\xe0\x01\n" +
"\x06review\x18\x01 \x01(\v2*.crabs.evening_detective_server.GameReviewR\x06review\"\xcc\x02\n" +
"\rUpdateGameReq\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
@@ -13676,7 +13710,12 @@ const file_main_proto_rawDesc = "" +
"scenarioID\x18\x05 \x01(\x05R\n" +
"scenarioID\x12\x16\n" +
"\x06format\x18\x06 \x01(\tR\x06format\x12\x1a\n" +
"\blocation\x18\a \x01(\tR\blocation\"\x0f\n" +
"\blocation\x18\a \x01(\tR\blocation\x12 \n" +
"\twifi_name\x18\b \x01(\tH\x00R\bwifiName\x88\x01\x01\x12(\n" +
"\rwifi_password\x18\t \x01(\tH\x01R\fwifiPassword\x88\x01\x01B\f\n" +
"\n" +
"_wifi_nameB\x10\n" +
"\x0e_wifi_password\"\x0f\n" +
"\rUpdateGameRsp\"\x1e\n" +
"\fStartGameReq\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\"\x0e\n" +
@@ -14875,6 +14914,7 @@ func file_main_proto_init() {
file_main_proto_msgTypes[74].OneofWrappers = []any{}
file_main_proto_msgTypes[106].OneofWrappers = []any{}
file_main_proto_msgTypes[114].OneofWrappers = []any{}
file_main_proto_msgTypes[176].OneofWrappers = []any{}
file_main_proto_msgTypes[209].OneofWrappers = []any{}
file_main_proto_msgTypes[220].OneofWrappers = []any{}
file_main_proto_msgTypes[221].OneofWrappers = []any{}