From 146700cf4c5b2647f215ca2932b0bce069ddb01d Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Fri, 23 Oct 2026 00:45:30 +0700 Subject: [PATCH] up --- api/main.proto | 68 +++++++++--------- .../main.swagger.json | 13 ++++ docs/GAME_PACKAGE.md | 6 ++ internal/app/game_errors.go | 3 +- internal/app/game_mapper.go | 7 ++ internal/app/game_wifi_test.go | 48 +++++++++++++ internal/app/games.go | 4 +- internal/modules/team_qr_pdf/generator.go | 48 +++++++++---- .../modules/team_qr_pdf/generator_test.go | 25 +++++++ internal/modules/team_qr_pdf/wifi.go | 59 ++++++++++++++++ internal/modules/team_qr_pdf/wifi_test.go | 57 +++++++++++++++ internal/repos/game.go | 28 ++++---- internal/repos/game_exports_repo/snapshot.go | 1 + internal/repos/game_imports_repo/publish.go | 8 ++- internal/repos/games_repo/repo.go | 9 +++ .../services/answer_service/service_test.go | 3 +- .../game_export_service/local_mode_test.go | 5 +- .../game_export_service/return_test.go | 6 +- internal/services/game_model/game.go | 26 +++---- internal/services/game_model/game_mapper.go | 26 +++---- .../services/game_package_service/model.go | 24 ++++--- .../game_package_service/service_test.go | 4 ++ .../game_package_service/validation.go | 5 ++ .../flow_apply_test.go | 2 +- .../services/game_service/lifecycle_test.go | 20 +++++- internal/services/game_service/service.go | 17 ++++- internal/services/game_service/wifi_test.go | 24 +++++++ .../gameplay_service/finished_story_test.go | 3 +- internal/services/team_service/receipt.go | 69 +++++++++++++++++-- .../services/team_service/receipt_test.go | 57 +++++++++++++-- internal/services/team_service/team_qr.go | 7 +- .../services/team_service/team_qr_test.go | 5 +- migrations/20261023120000_game_wifi.sql | 11 +++ proto/main.pb.go | 48 +++++++++++-- 34 files changed, 622 insertions(+), 124 deletions(-) create mode 100644 internal/app/game_wifi_test.go create mode 100644 internal/modules/team_qr_pdf/wifi.go create mode 100644 internal/modules/team_qr_pdf/wifi_test.go create mode 100644 internal/services/game_service/wifi_test.go create mode 100644 migrations/20261023120000_game_wifi.sql diff --git a/api/main.proto b/api/main.proto index 6813657..4ea1a14 100644 --- a/api/main.proto +++ b/api/main.proto @@ -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 { diff --git a/cmd/evening_detective_server/main.swagger.json b/cmd/evening_detective_server/main.swagger.json index 2c893a7..924bb5b 100644 --- a/cmd/evening_detective_server/main.swagger.json +++ b/cmd/evening_detective_server/main.swagger.json @@ -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" } } }, diff --git a/docs/GAME_PACKAGE.md b/docs/GAME_PACKAGE.md index c1dd6f9..9786237 100644 --- a/docs/GAME_PACKAGE.md +++ b/docs/GAME_PACKAGE.md @@ -17,6 +17,12 @@ MIME и SHA-256 в нижнем регистре. В перечень входи не входит. Все остальные ZIP-записи должны быть заявлены и использованы ссылками из данных игры. Вложенные архивы и каталоги не используются. +В объекте `game` необязательные `wifi_name` и `wifi_password` содержат настройки +сети для печати карточек команд и чеков. Они сохраняются при экспорте и импорте; +пакеты без этих полей используют пустые значения. Пароль входит в пакет открытым +текстом, как и данные входа команд. После изменения настроек для переноса нужен +новый экспорт: готовый пакет остаётся снимком на момент подготовки. + Полный перечень JSON-полей задан типами в `model.go`; неизвестные поля и неподдерживаемые версии при чтении отклоняются. Время записывается как RFC3339, `token_hash` — как стандартный Base64 от исходных 32 байтов SHA-256. diff --git a/internal/app/game_errors.go b/internal/app/game_errors.go index 52edb72..9b2c389 100644 --- a/internal/app/game_errors.go +++ b/internal/app/game_errors.go @@ -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) diff --git a/internal/app/game_mapper.go b/internal/app/game_mapper.go index c08a774..ad5d3fe 100644 --- a/internal/app/game_mapper.go +++ b/internal/app/game_mapper.go @@ -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 +} diff --git a/internal/app/game_wifi_test.go b/internal/app/game_wifi_test.go new file mode 100644 index 0000000..f6e5743 --- /dev/null +++ b/internal/app/game_wifi_test.go @@ -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) + } +} diff --git a/internal/app/games.go b/internal/app/games.go index 7c4d83d..5a40089 100644 --- a/internal/app/games.go +++ b/internal/app/games.go @@ -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, "не удалось изменить игру") diff --git a/internal/modules/team_qr_pdf/generator.go b/internal/modules/team_qr_pdf/generator.go index 30b4439..8dbc7b6 100644 --- a/internal/modules/team_qr_pdf/generator.go +++ b/internal/modules/team_qr_pdf/generator.go @@ -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 { diff --git a/internal/modules/team_qr_pdf/generator_test.go b/internal/modules/team_qr_pdf/generator_test.go index dce6013..a5ba044 100644 --- a/internal/modules/team_qr_pdf/generator_test.go +++ b/internal/modules/team_qr_pdf/generator_test.go @@ -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") + } +} diff --git a/internal/modules/team_qr_pdf/wifi.go b/internal/modules/team_qr_pdf/wifi.go new file mode 100644 index 0000000..e2644bb --- /dev/null +++ b/internal/modules/team_qr_pdf/wifi.go @@ -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-код команды.", + "Если телефон сообщает, что сеть без интернета, оставайтесь подключены.", + } +} diff --git a/internal/modules/team_qr_pdf/wifi_test.go b/internal/modules/team_qr_pdf/wifi_test.go new file mode 100644 index 0000000..b13a010 --- /dev/null +++ b/internal/modules/team_qr_pdf/wifi_test.go @@ -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") + } +} diff --git a/internal/repos/game.go b/internal/repos/game.go index f00bd07..46e4cb7 100644 --- a/internal/repos/game.go +++ b/internal/repos/game.go @@ -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 } diff --git a/internal/repos/game_exports_repo/snapshot.go b/internal/repos/game_exports_repo/snapshot.go index 95c35c6..67694f2 100644 --- a/internal/repos/game_exports_repo/snapshot.go +++ b/internal/repos/game_exports_repo/snapshot.go @@ -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), diff --git a/internal/repos/game_imports_repo/publish.go b/internal/repos/game_imports_repo/publish.go index 79211ee..0451710 100644 --- a/internal/repos/game_imports_repo/publish.go +++ b/internal/repos/game_imports_repo/publish.go @@ -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, diff --git a/internal/repos/games_repo/repo.go b/internal/repos/games_repo/repo.go index 4149670..a7ca571 100644 --- a/internal/repos/games_repo/repo.go +++ b/internal/repos/games_repo/repo.go @@ -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 diff --git a/internal/services/answer_service/service_test.go b/internal/services/answer_service/service_test.go index 7db909d..cd5a6c1 100644 --- a/internal/services/answer_service/service_test.go +++ b/internal/services/answer_service/service_test.go @@ -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;`) diff --git a/internal/services/game_export_service/local_mode_test.go b/internal/services/game_export_service/local_mode_test.go index 4e0c151..d038e62 100644 --- a/internal/services/game_export_service/local_mode_test.go +++ b/internal/services/game_export_service/local_mode_test.go @@ -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) diff --git a/internal/services/game_export_service/return_test.go b/internal/services/game_export_service/return_test.go index 72f94a5..69f75c5 100644 --- a/internal/services/game_export_service/return_test.go +++ b/internal/services/game_export_service/return_test.go @@ -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) diff --git a/internal/services/game_model/game.go b/internal/services/game_model/game.go index 3991ec0..1caf7a2 100644 --- a/internal/services/game_model/game.go +++ b/internal/services/game_model/game.go @@ -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 } diff --git a/internal/services/game_model/game_mapper.go b/internal/services/game_model/game_mapper.go index ad9a77f..253f89b 100644 --- a/internal/services/game_model/game_mapper.go +++ b/internal/services/game_model/game_mapper.go @@ -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, } } diff --git a/internal/services/game_package_service/model.go b/internal/services/game_package_service/model.go index 2cad0ef..76194d0 100644 --- a/internal/services/game_package_service/model.go +++ b/internal/services/game_package_service/model.go @@ -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 { diff --git a/internal/services/game_package_service/service_test.go b/internal/services/game_package_service/service_test.go index 3c13a90..bdbcc5e 100644 --- a/internal/services/game_package_service/service_test.go +++ b/internal/services/game_package_service/service_test.go @@ -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{} diff --git a/internal/services/game_package_service/validation.go b/internal/services/game_package_service/validation.go index 0648912..7b47caa 100644 --- a/internal/services/game_package_service/validation.go +++ b/internal/services/game_package_service/validation.go @@ -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") { diff --git a/internal/services/game_results_package_service/flow_apply_test.go b/internal/services/game_results_package_service/flow_apply_test.go index db66bed..d1a39a7 100644 --- a/internal/services/game_results_package_service/flow_apply_test.go +++ b/internal/services/game_results_package_service/flow_apply_test.go @@ -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 { diff --git a/internal/services/game_service/lifecycle_test.go b/internal/services/game_service/lifecycle_test.go index 645fe3c..12660ae 100644 --- a/internal/services/game_service/lifecycle_test.go +++ b/internal/services/game_service/lifecycle_test.go @@ -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) diff --git a/internal/services/game_service/service.go b/internal/services/game_service/service.go index e5d8a0f..3e9f042 100644 --- a/internal/services/game_service/service.go +++ b/internal/services/game_service/service.go @@ -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. diff --git a/internal/services/game_service/wifi_test.go b/internal/services/game_service/wifi_test.go new file mode 100644 index 0000000..13e790f --- /dev/null +++ b/internal/services/game_service/wifi_test.go @@ -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") + } + } +} diff --git a/internal/services/gameplay_service/finished_story_test.go b/internal/services/gameplay_service/finished_story_test.go index 6d5b431..1637237 100644 --- a/internal/services/gameplay_service/finished_story_test.go +++ b/internal/services/gameplay_service/finished_story_test.go @@ -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;`) diff --git a/internal/services/team_service/receipt.go b/internal/services/team_service/receipt.go index 984dc50..9a4033f 100644 --- a/internal/services/team_service/receipt.go +++ b/internal/services/team_service/receipt.go @@ -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 +} diff --git a/internal/services/team_service/receipt_test.go b/internal/services/team_service/receipt_test.go index c512b6f..aa8bb53 100644 --- a/internal/services/team_service/receipt_test.go +++ b/internal/services/team_service/receipt_test.go @@ -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") + } + } + } +} diff --git a/internal/services/team_service/team_qr.go b/internal/services/team_service/team_qr.go index dec3056..5352543 100644 --- a/internal/services/team_service/team_qr.go +++ b/internal/services/team_service/team_qr.go @@ -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) { diff --git a/internal/services/team_service/team_qr_test.go b/internal/services/team_service/team_qr_test.go index 571e8f3..3f91741 100644 --- a/internal/services/team_service/team_qr_test.go +++ b/internal/services/team_service/team_qr_test.go @@ -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) diff --git a/migrations/20261023120000_game_wifi.sql b/migrations/20261023120000_game_wifi.sql new file mode 100644 index 0000000..e90d54b --- /dev/null +++ b/migrations/20261023120000_game_wifi.sql @@ -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; diff --git a/proto/main.pb.go b/proto/main.pb.go index fdee836..b06cc1c 100644 --- a/proto/main.pb.go +++ b/proto/main.pb.go @@ -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{}