diff --git a/api/main.proto b/api/main.proto index 8731564..769a740 100644 --- a/api/main.proto +++ b/api/main.proto @@ -1268,6 +1268,7 @@ message AddGameReq { string description = 2; google.protobuf.Timestamp start_at = 3; int32 scenario_id = 4; + string format = 5; } message AddGameRsp { @@ -1292,6 +1293,7 @@ message Game { repeated Team teams = 7; optional google.protobuf.Timestamp started_at = 8; optional google.protobuf.Timestamp ended_at = 9; + string format = 10; } enum ScreenMode { @@ -1515,6 +1517,7 @@ message UpdateGameReq { string description = 3; google.protobuf.Timestamp start_at = 4; int32 scenario_id = 5; + string format = 6; } message UpdateGameRsp { diff --git a/bin/evening_detective_server b/bin/evening_detective_server index e59e6d9..9079ecc 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/main.swagger.json b/cmd/evening_detective_server/main.swagger.json index 851f9c4..7e7c14f 100644 --- a/cmd/evening_detective_server/main.swagger.json +++ b/cmd/evening_detective_server/main.swagger.json @@ -2770,6 +2770,9 @@ "scenarioId": { "type": "integer", "format": "int32" + }, + "format": { + "type": "string" } } }, @@ -2890,6 +2893,9 @@ "scenarioId": { "type": "integer", "format": "int32" + }, + "format": { + "type": "string" } } }, @@ -3215,6 +3221,9 @@ "endedAt": { "type": "string", "format": "date-time" + }, + "format": { + "type": "string" } } }, diff --git a/internal/app/game_mapper.go b/internal/app/game_mapper.go index a1396c0..d3aa7d7 100644 --- a/internal/app/game_mapper.go +++ b/internal/app/game_mapper.go @@ -25,6 +25,7 @@ func mapGame( Description: o.Description, StartAt: timestamppb.New(o.StartAt), Status: o.Status, + Format: o.Format, Scenario: mapScenario(o.Scenario), Teams: mapTeams(o.Teams), StartedAt: startedAt, diff --git a/internal/app/server.go b/internal/app/server.go index 70eacee..4db8e8f 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -1152,6 +1152,7 @@ func (s *server) AddGame(ctx context.Context, req *proto.AddGameReq) (*proto.Add req.Description, req.StartAt.AsTime(), req.ScenarioId, + req.Format, ) if err != nil { return &proto.AddGameRsp{ @@ -1274,6 +1275,7 @@ func (s *server) UpdateGame(ctx context.Context, req *proto.UpdateGameReq) (*pro req.Description, req.StartAt.AsTime(), req.ScenarioId, + req.Format, ) if err != nil { return &proto.UpdateGameRsp{ diff --git a/internal/repos/game.go b/internal/repos/game.go index bc36a84..b19d401 100644 --- a/internal/repos/game.go +++ b/internal/repos/game.go @@ -5,6 +5,7 @@ import ( ) type Game struct { + Format string ID int Name string Description string diff --git a/internal/repos/games_repo/repo.go b/internal/repos/games_repo/repo.go index 9dca07f..c353a0e 100644 --- a/internal/repos/games_repo/repo.go +++ b/internal/repos/games_repo/repo.go @@ -30,17 +30,19 @@ func (r *GamesRepo) AddGame( description string, startAt time.Time, scenarioId int32, + format string, ) (int, error) { id := 0 err := r.pool.QueryRow( ctx, - `INSERT INTO games (name, description, start_at, scenario_id) - VALUES ($1, $2, $3, $4) + `INSERT INTO games (name, description, start_at, scenario_id, format) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, name, description, startAt, scenarioId, + format, ).Scan(&id) if err != nil { @@ -61,6 +63,7 @@ func (r *GamesRepo) GetGameByID( games.name, games.description, games.start_at, + games.format, games.status, games.scenario_id, games.started_at, @@ -74,6 +77,7 @@ func (r *GamesRepo) GetGameByID( &game.Name, &game.Description, &game.StartAt, + &game.Format, &game.Status, &game.ScenarioId, &game.StartedAt, @@ -118,6 +122,7 @@ func (r *GamesRepo) GetGames( games.name, games.description, games.start_at, + games.format, games.status FROM games ORDER BY created_at DESC`, @@ -135,6 +140,7 @@ func (r *GamesRepo) GetGames( &game.Name, &game.Description, &game.StartAt, + &game.Format, &game.Status, ) if err != nil { @@ -156,6 +162,7 @@ func (r *GamesRepo) UpdateGame( description string, startAt time.Time, scenarioId int32, + format string, ) error { tag, err := r.pool.Exec( ctx, @@ -165,12 +172,14 @@ func (r *GamesRepo) UpdateGame( description = $2, start_at = $3, scenario_id = $4, + format = $5, updated_at = NOW() - WHERE id = $5`, + WHERE id = $6`, name, description, startAt, scenarioId, + format, id, ) if err != nil { diff --git a/internal/services/game_service/game.go b/internal/services/game_service/game.go index 4027642..a059e0a 100644 --- a/internal/services/game_service/game.go +++ b/internal/services/game_service/game.go @@ -6,6 +6,7 @@ import ( ) type Game struct { + Format string ID int Name string Description string diff --git a/internal/services/game_service/game_mapper.go b/internal/services/game_service/game_mapper.go index df85abc..4ea1d9c 100644 --- a/internal/services/game_service/game_mapper.go +++ b/internal/services/game_service/game_mapper.go @@ -15,6 +15,7 @@ func mapGame( Description: o.Description, StartAt: o.StartAt, Status: o.Status, + Format: o.Format, Scenario: scenarios_service.MapScenarioLight(o.Scenario, domain), Teams: mapTeams(o.Teams), StartedAt: o.StartedAt, diff --git a/internal/services/game_service/service.go b/internal/services/game_service/service.go index 9c3f614..97be402 100644 --- a/internal/services/game_service/service.go +++ b/internal/services/game_service/service.go @@ -72,8 +72,15 @@ func (s *GameService) AddGame( description string, startAt time.Time, scenarioId int32, + format string, ) (int, error) { - id, err := s.gamesRepo.AddGame(ctx, name, description, startAt, scenarioId) + if format == "" { + format = "online" + } + if format != "online" && format != "offline" { + return 0, errors.New("Формат игры должен быть online или offline") + } + id, err := s.gamesRepo.AddGame(ctx, name, description, startAt, scenarioId, format) if err != nil { return 0, err } @@ -170,8 +177,19 @@ func (s *GameService) UpdateGame( description string, startAt time.Time, scenarioId int32, + format string, ) error { - return s.gamesRepo.UpdateGame(ctx, id, name, description, startAt, scenarioId) + if format == "" { + game, err := s.gamesRepo.GetGameByID(ctx, id) + if err != nil { + return err + } + format = game.Format + } + if format != "online" && format != "offline" { + return errors.New("Формат игры должен быть online или offline") + } + return s.gamesRepo.UpdateGame(ctx, id, name, description, startAt, scenarioId, format) } func (s *GameService) StartGame(ctx context.Context, id int) error { @@ -255,7 +273,9 @@ func (s *GameService) GetTeamActions(ctx context.Context, teamId int, password s for i, action := range actions { codes[i] = action.Code } - return s.storyteller.GetStory(scenario.Story, codes), mapGame(game, s.domain), actions, nil + story := s.storyteller.GetStory(scenario.Story, codes) + filterTeamStory(story, game.Format) + return story, mapGame(game, s.domain), actions, nil } func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password string, actionCode string, viaMCP bool) error { @@ -394,6 +414,9 @@ func (s *GameService) GetTeamAnswers(ctx context.Context, teamId int, password s if err != nil { return nil, err } + if game.Format == "offline" { + return nil, nil + } _, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) if err != nil { return nil, err @@ -424,6 +447,9 @@ func (s *GameService) SubmitTeamAnswers(ctx context.Context, teamId int, passwor if err != nil { return err } + if game.Format == "offline" { + return errors.New("В offline-игре ответы через сайт недоступны") + } switch game.Status { case "run", "pause": // игра идёт — можно отвечать diff --git a/internal/services/game_service/team_story.go b/internal/services/game_service/team_story.go new file mode 100644 index 0000000..c391f6d --- /dev/null +++ b/internal/services/game_service/team_story.go @@ -0,0 +1,18 @@ +package game_service + +import "evening_detective_server/internal/modules/storytelling" + +// filterTeamStory применяется только к истории команды, общей для сайта и MCP. +func filterTeamStory(story *storytelling.Story, format string) { + if format != "offline" { + return + } + story.Introduction = nil + story.Questions = nil + for _, place := range story.Places { + for _, application := range place.Applications { + application.Image = "" + application.FileType = "" + } + } +} diff --git a/internal/services/game_service/team_story_test.go b/internal/services/game_service/team_story_test.go new file mode 100644 index 0000000..b753146 --- /dev/null +++ b/internal/services/game_service/team_story_test.go @@ -0,0 +1,53 @@ +package game_service + +import ( + "testing" + + "evening_detective_server/internal/modules/cleaner" + "evening_detective_server/internal/modules/storytelling" +) + +func TestTeamStoryFormat(t *testing.T) { + scenario := &storytelling.Story{ + Introduction: &storytelling.Introduction{Text: "Введение", Audio: "intro.mp3"}, + Questions: []*storytelling.Question{{Code: "q", Text: "Вопрос"}}, + Places: []*storytelling.Place{{Code: "p", Text: "Точка", Image: "place.jpg", + Applications: []*storytelling.Application{ + {Name: "Фото", Image: "clue.jpg", FileType: "image"}, + {Name: "Документ", Image: "clue.pdf", FileType: "pdf"}, + {Name: "Запись", Image: "clue.mp3", FileType: "audio"}, + }, + }}, + } + for _, format := range []string{"offline", "online"} { + t.Run(format, func(t *testing.T) { + story := storytelling.NewStory(cleaner.NewCleaner()).GetStory(scenario, []string{"p"}) + filterTeamStory(story, format) + if len(story.Places) != 1 || story.Places[0].Text != "Точка" || story.Places[0].Image != "place.jpg" { + t.Fatal("place content changed") + } + if format == "offline" { + if story.Introduction != nil || len(story.Questions) != 0 { + t.Fatal("offline story exposes introduction or questions") + } + } else if story.Introduction == nil || len(story.Questions) != 1 { + t.Fatal("online story lost introduction or questions") + } + if len(story.Places[0].Applications) != 3 { + t.Fatal("clue names missing") + } + for i, application := range story.Places[0].Applications { + original := scenario.Places[0].Applications[i] + if application.Name != original.Name || original.Image == "" || original.FileType == "" { + t.Fatal("clue name or original scenario changed") + } + if format == "offline" && (application.Image != "" || application.FileType != "") { + t.Fatal("offline story exposes a clue file") + } + if format == "online" && application.Image != original.Image { + t.Fatal("online story lost a clue file") + } + } + }) + } +} diff --git a/migrations/20260913120000_add_game_format.sql b/migrations/20260913120000_add_game_format.sql new file mode 100644 index 0000000..3ebc6eb --- /dev/null +++ b/migrations/20260913120000_add_game_format.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE games ADD COLUMN format TEXT NOT NULL DEFAULT 'online' + CHECK (format IN ('online', 'offline')); + +-- +goose Down +ALTER TABLE games DROP COLUMN format; diff --git a/proto/main.pb.go b/proto/main.pb.go index 9a39079..dcf9c17 100644 --- a/proto/main.pb.go +++ b/proto/main.pb.go @@ -4387,6 +4387,7 @@ type AddGameReq struct { Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` StartAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=start_at,json=startAt,proto3" json:"start_at,omitempty"` ScenarioId int32 `protobuf:"varint,4,opt,name=scenario_id,json=scenarioId,proto3" json:"scenario_id,omitempty"` + Format string `protobuf:"bytes,5,opt,name=format,proto3" json:"format,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4449,6 +4450,13 @@ func (x *AddGameReq) GetScenarioId() int32 { return 0 } +func (x *AddGameReq) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + type AddGameRsp struct { state protoimpl.MessageState `protogen:"open.v1"` Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` @@ -4600,6 +4608,7 @@ type Game struct { Teams []*Team `protobuf:"bytes,7,rep,name=teams,proto3" json:"teams,omitempty"` StartedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=started_at,json=startedAt,proto3,oneof" json:"started_at,omitempty"` EndedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=ended_at,json=endedAt,proto3,oneof" json:"ended_at,omitempty"` + Format string `protobuf:"bytes,10,opt,name=format,proto3" json:"format,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4697,6 +4706,13 @@ func (x *Game) GetEndedAt() *timestamppb.Timestamp { return nil } +func (x *Game) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + type ScreenState struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -6620,6 +6636,7 @@ type UpdateGameReq struct { Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` StartAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=start_at,json=startAt,proto3" json:"start_at,omitempty"` ScenarioId int32 `protobuf:"varint,5,opt,name=scenario_id,json=scenarioId,proto3" json:"scenario_id,omitempty"` + Format string `protobuf:"bytes,6,opt,name=format,proto3" json:"format,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -6689,6 +6706,13 @@ func (x *UpdateGameReq) GetScenarioId() int32 { return 0 } +func (x *UpdateGameReq) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + type UpdateGameRsp struct { state protoimpl.MessageState `protogen:"open.v1"` Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` @@ -8680,14 +8704,15 @@ const file_main_proto_rawDesc = "" + "\x02id\x18\x01 \x01(\x05R\x02id\"@\n" + "\x18UploadScenarioArchiveRsp\x12\x14\n" + "\x05error\x18\x01 \x01(\tR\x05error\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x05R\x02id\"\x9a\x01\n" + + "\x02id\x18\x02 \x01(\x05R\x02id\"\xb2\x01\n" + "\n" + "AddGameReq\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x125\n" + "\bstart_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\astartAt\x12\x1f\n" + "\vscenario_id\x18\x04 \x01(\x05R\n" + - "scenarioId\"2\n" + + "scenarioId\x12\x16\n" + + "\x06format\x18\x05 \x01(\tR\x06format\"2\n" + "\n" + "AddGameRsp\x12\x14\n" + "\x05error\x18\x01 \x01(\tR\x05error\x12\x0e\n" + @@ -8695,7 +8720,7 @@ const file_main_proto_rawDesc = "" + "\vGetGamesReq\"_\n" + "\vGetGamesRsp\x12\x14\n" + "\x05error\x18\x01 \x01(\tR\x05error\x12:\n" + - "\x05games\x18\x02 \x03(\v2$.crabs.evening_detective_server.GameR\x05games\"\xb5\x03\n" + + "\x05games\x18\x02 \x03(\v2$.crabs.evening_detective_server.GameR\x05games\"\xcd\x03\n" + "\x04Game\x12\x0e\n" + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + @@ -8706,7 +8731,9 @@ const file_main_proto_rawDesc = "" + "\x05teams\x18\a \x03(\v2$.crabs.evening_detective_server.TeamR\x05teams\x12>\n" + "\n" + "started_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x00R\tstartedAt\x88\x01\x01\x12:\n" + - "\bended_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampH\x01R\aendedAt\x88\x01\x01B\r\n" + + "\bended_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampH\x01R\aendedAt\x88\x01\x01\x12\x16\n" + + "\x06format\x18\n" + + " \x01(\tR\x06formatB\r\n" + "\v_started_atB\v\n" + "\t_ended_at\"\xb6\x04\n" + "\vScreenState\x12\x0e\n" + @@ -8842,14 +8869,15 @@ const file_main_proto_rawDesc = "" + "\x11GetGameResultsRsp\x12\x14\n" + "\x05error\x18\x01 \x01(\tR\x05error\x12M\n" + "\tquestions\x18\x02 \x03(\v2/.crabs.evening_detective_server.ResultsQuestionR\tquestions\x12@\n" + - "\x05teams\x18\x03 \x03(\v2*.crabs.evening_detective_server.TeamResultR\x05teams\"\xad\x01\n" + + "\x05teams\x18\x03 \x03(\v2*.crabs.evening_detective_server.TeamResultR\x05teams\"\xc5\x01\n" + "\rUpdateGameReq\x12\x0e\n" + "\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x03 \x01(\tR\vdescription\x125\n" + "\bstart_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\astartAt\x12\x1f\n" + "\vscenario_id\x18\x05 \x01(\x05R\n" + - "scenarioId\"%\n" + + "scenarioId\x12\x16\n" + + "\x06format\x18\x06 \x01(\tR\x06format\"%\n" + "\rUpdateGameRsp\x12\x14\n" + "\x05error\x18\x01 \x01(\tR\x05error\"\x1e\n" + "\fStartGameReq\x12\x0e\n" +