From 87bd07019ab7c1571bfdaa101af857cc2b84c1f9 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Thu, 17 Sep 2026 02:38:05 +0700 Subject: [PATCH] add AnswerService --- cmd/evening_detective_server/main.go | 7 +- internal/app/answer_mapper.go | 14 +- internal/app/answers.go | 6 +- internal/app/games.go | 4 +- internal/app/results_mapper.go | 4 +- internal/app/results_mapper_test.go | 6 +- internal/app/screens.go | 2 +- internal/app/server.go | 4 + .../answer.go | 2 +- .../results.go | 2 +- .../results_test.go | 2 +- internal/services/answer_service/service.go | 293 ++++++++++++++++++ .../services/answer_service/service_test.go | 82 +++++ internal/services/game_service/service.go | 260 ---------------- internal/services/mcp_service/http_test.go | 2 +- internal/services/mcp_service/service.go | 23 +- internal/services/mcp_service/service_test.go | 33 +- .../participation_test.go | 6 +- 18 files changed, 448 insertions(+), 304 deletions(-) rename internal/services/{game_service => answer_service}/answer.go (97%) rename internal/services/{game_service => answer_service}/results.go (99%) rename internal/services/{game_service => answer_service}/results_test.go (99%) create mode 100644 internal/services/answer_service/service.go create mode 100644 internal/services/answer_service/service_test.go diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index 814d4d3..4440f76 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -24,6 +24,7 @@ import ( "evening_detective_server/internal/repos/team_answers_repo" "evening_detective_server/internal/repos/teams_repo" "evening_detective_server/internal/repos/users_repo" + "evening_detective_server/internal/services/answer_service" "evening_detective_server/internal/services/file_service" "evening_detective_server/internal/services/game_service" "evening_detective_server/internal/services/mcp_service" @@ -143,10 +144,11 @@ func main() { storyteller, actionsRepo, applicationsRepo, - answersRepo, cleaner, ) + answerService := answer_service.NewAnswerService(gameRepo, teamRepo, scenarioService, answersRepo, actionsRepo) + teamService := team_service.NewTeamService(gameRepo, teamRepo, passwordGenerator, team_qr_pdf.NewGenerator(), os.Getenv("APP_BASE_URL")) participationService := participation_service.NewParticipationService(teamRepo, os.Getenv("APP_BASE_URL")) @@ -199,6 +201,7 @@ func main() { fileService, scenarioService, gameService, + answerService, teamService, participationService, screenRepo, @@ -306,7 +309,7 @@ func main() { // REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому // http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway. mcpHTTP := server.NewStreamableHTTPServer( - mcp_service.NewMCPService(gameService).Server(), + mcp_service.NewMCPService(gameService, answerService).Server(), server.WithDisableLocalhostProtection(true), ) mainMux.Handle("/api/mcp", mcpHTTP) diff --git a/internal/app/answer_mapper.go b/internal/app/answer_mapper.go index 63b493e..869ed90 100644 --- a/internal/app/answer_mapper.go +++ b/internal/app/answer_mapper.go @@ -1,11 +1,11 @@ package app import ( - "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/answer_service" proto "evening_detective_server/proto" ) -func mapAnswers(o []game_service.Answer) []*proto.Answer { +func mapAnswers(o []answer_service.Answer) []*proto.Answer { res := make([]*proto.Answer, 0, len(o)) for _, item := range o { res = append(res, &proto.Answer{ @@ -16,10 +16,10 @@ func mapAnswers(o []game_service.Answer) []*proto.Answer { return res } -func convertAnswers(o []*proto.Answer) []game_service.Answer { - res := make([]game_service.Answer, 0, len(o)) +func convertAnswers(o []*proto.Answer) []answer_service.Answer { + res := make([]answer_service.Answer, 0, len(o)) for _, item := range o { - res = append(res, game_service.Answer{ + res = append(res, answer_service.Answer{ QuestionCode: item.QuestionCode, Text: item.Answer, }) @@ -27,7 +27,7 @@ func convertAnswers(o []*proto.Answer) []game_service.Answer { return res } -func mapAnswerScores(o []game_service.AnswerScore) []*proto.AnswerScore { +func mapAnswerScores(o []answer_service.AnswerScore) []*proto.AnswerScore { res := make([]*proto.AnswerScore, 0, len(o)) for _, item := range o { score := &proto.AnswerScore{ @@ -43,7 +43,7 @@ func mapAnswerScores(o []game_service.AnswerScore) []*proto.AnswerScore { return res } -func mapTeamAnswers(o []game_service.TeamAnswers) []*proto.TeamAnswers { +func mapTeamAnswers(o []answer_service.TeamAnswers) []*proto.TeamAnswers { res := make([]*proto.TeamAnswers, 0, len(o)) for _, item := range o { res = append(res, &proto.TeamAnswers{ diff --git a/internal/app/answers.go b/internal/app/answers.go index 8d79b80..bf32b85 100644 --- a/internal/app/answers.go +++ b/internal/app/answers.go @@ -12,7 +12,7 @@ import ( ) func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersReq) (*proto.GetTeamAnswersRsp, error) { - answers, err := s.gameService.GetTeamAnswers( + answers, err := s.answerService.GetTeamAnswers( ctx, int(req.Id), req.Password, @@ -29,7 +29,7 @@ func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersRe } func (s *server) SubmitTeamAnswers(ctx context.Context, req *proto.SubmitTeamAnswersReq) (*proto.SubmitTeamAnswersRsp, error) { - err := s.gameService.SubmitTeamAnswers( + err := s.answerService.SubmitTeamAnswers( ctx, int(req.Id), req.Password, @@ -56,7 +56,7 @@ func (s *server) SetTeamAnswerScore(ctx context.Context, req *proto.SetTeamAnswe score = &v } - err := s.gameService.SetTeamAnswerScore( + err := s.answerService.SetTeamAnswerScore( ctx, int(req.Id), req.QuestionCode, diff --git a/internal/app/games.go b/internal/app/games.go index ce4d8d6..136f898 100644 --- a/internal/app/games.go +++ b/internal/app/games.go @@ -83,7 +83,7 @@ func (s *server) GetGameAnswers(ctx context.Context, req *proto.GetGameAnswersRe return nil, status.Errorf(codes.PermissionDenied, "permission denied") } - gameAnswers, err := s.gameService.GetGameAnswers(ctx, int(req.GameId)) + gameAnswers, err := s.answerService.GetGameAnswers(ctx, int(req.GameId)) if err != nil { return &proto.GetGameAnswersRsp{ Error: err.Error(), @@ -102,7 +102,7 @@ func (s *server) GetGameResults(ctx context.Context, req *proto.GetGameResultsRe return nil, status.Errorf(codes.PermissionDenied, "permission denied") } - results, err := s.gameService.GetGameResults(ctx, int(req.GameId)) + results, err := s.answerService.GetGameResults(ctx, int(req.GameId)) if err != nil { return &proto.GetGameResultsRsp{ Error: err.Error(), diff --git a/internal/app/results_mapper.go b/internal/app/results_mapper.go index 09b2e3e..d88f7cf 100644 --- a/internal/app/results_mapper.go +++ b/internal/app/results_mapper.go @@ -2,7 +2,7 @@ package app import ( "evening_detective_server/internal/modules/storytelling" - "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/answer_service" proto "evening_detective_server/proto" ) @@ -23,7 +23,7 @@ func mapResultsQuestions(o []*storytelling.Question) []*proto.ResultsQuestion { // mapTeamResults мапит строки итоговой таблицы. Команда мапится целиком // (включая password/applications — аудитория та же, что у GetGameAnswers: // организатор/автор); actionsCount продублирован на уровне строки таблицы. -func mapTeamResults(o []game_service.TeamResult) []*proto.TeamResult { +func mapTeamResults(o []answer_service.TeamResult) []*proto.TeamResult { res := make([]*proto.TeamResult, 0, len(o)) for _, item := range o { res = append(res, &proto.TeamResult{ diff --git a/internal/app/results_mapper_test.go b/internal/app/results_mapper_test.go index 6c97483..5f07b42 100644 --- a/internal/app/results_mapper_test.go +++ b/internal/app/results_mapper_test.go @@ -4,12 +4,12 @@ import ( "evening_detective_server/internal/modules/game_model" "testing" - "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/answer_service" ) func TestMapTeamResults(t *testing.T) { sc := 5 - results := []game_service.TeamResult{ + results := []answer_service.TeamResult{ { Team: &game_model.Team{ ID: 1, @@ -17,7 +17,7 @@ func TestMapTeamResults(t *testing.T) { Status: "play", ActionsCount: 7, }, - Answers: []game_service.AnswerScore{ + Answers: []answer_service.AnswerScore{ {QuestionCode: "q1", Text: "ответ", Score: &sc}, }, TotalScore: 5, diff --git a/internal/app/screens.go b/internal/app/screens.go index 6eee94c..e4521d1 100644 --- a/internal/app/screens.go +++ b/internal/app/screens.go @@ -262,7 +262,7 @@ func (s *server) GetScreenResults(ctx context.Context, req *proto.GetScreenResul if game.Status != "finish" { return nil, status.Error(codes.FailedPrecondition, "результаты доступны после завершения игры") } - results, err := s.gameService.GetGameResults(ctx, *session.GameID) + results, err := s.answerService.GetGameResults(ctx, *session.GameID) if err != nil { return nil, status.Error(codes.Internal, "не удалось получить результаты") } diff --git a/internal/app/server.go b/internal/app/server.go index ba343e3..a6b4d24 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -4,6 +4,7 @@ import ( "evening_detective_server/internal/modules/string_tools" "evening_detective_server/internal/repos/game_map_repo" "evening_detective_server/internal/repos/screen_sessions_repo" + "evening_detective_server/internal/services/answer_service" "evening_detective_server/internal/services/file_service" "evening_detective_server/internal/services/game_service" "evening_detective_server/internal/services/participation_service" @@ -25,6 +26,7 @@ type server struct { fileService *file_service.FileService scenarioService *scenarios_service.ScenarioService gameService *game_service.GameService + answerService *answer_service.AnswerService teamService *team_service.TeamService participationService *participation_service.ParticipationService screenRepo *screen_sessions_repo.ScreenSessionsRepo @@ -37,6 +39,7 @@ func NewServer( fileService *file_service.FileService, scenarioService *scenarios_service.ScenarioService, gameService *game_service.GameService, + answerService *answer_service.AnswerService, teamService *team_service.TeamService, participationService *participation_service.ParticipationService, screenRepo *screen_sessions_repo.ScreenSessionsRepo, @@ -48,6 +51,7 @@ func NewServer( fileService: fileService, scenarioService: scenarioService, gameService: gameService, + answerService: answerService, teamService: teamService, participationService: participationService, screenRepo: screenRepo, diff --git a/internal/services/game_service/answer.go b/internal/services/answer_service/answer.go similarity index 97% rename from internal/services/game_service/answer.go rename to internal/services/answer_service/answer.go index 9641aec..bbf47f6 100644 --- a/internal/services/game_service/answer.go +++ b/internal/services/answer_service/answer.go @@ -1,4 +1,4 @@ -package game_service +package answer_service import ( "evening_detective_server/internal/modules/game_model" diff --git a/internal/services/game_service/results.go b/internal/services/answer_service/results.go similarity index 99% rename from internal/services/game_service/results.go rename to internal/services/answer_service/results.go index a63c3b1..a37bcac 100644 --- a/internal/services/game_service/results.go +++ b/internal/services/answer_service/results.go @@ -1,4 +1,4 @@ -package game_service +package answer_service import ( "evening_detective_server/internal/modules/game_model" diff --git a/internal/services/game_service/results_test.go b/internal/services/answer_service/results_test.go similarity index 99% rename from internal/services/game_service/results_test.go rename to internal/services/answer_service/results_test.go index a03b1d4..ffed0fd 100644 --- a/internal/services/game_service/results_test.go +++ b/internal/services/answer_service/results_test.go @@ -1,4 +1,4 @@ -package game_service +package answer_service import ( "evening_detective_server/internal/modules/game_model" diff --git a/internal/services/answer_service/service.go b/internal/services/answer_service/service.go new file mode 100644 index 0000000..aef4063 --- /dev/null +++ b/internal/services/answer_service/service.go @@ -0,0 +1,293 @@ +package answer_service + +import ( + "context" + "errors" + "evening_detective_server/internal/modules/game_model" + "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/repos" + "evening_detective_server/internal/repos/actions_repo" + "evening_detective_server/internal/repos/games_repo" + "evening_detective_server/internal/repos/team_answers_repo" + "evening_detective_server/internal/repos/teams_repo" + "evening_detective_server/internal/services/scenarios_service" +) + +type AnswerService struct { + gamesRepo *games_repo.GamesRepo + teamsRepo *teams_repo.TeamsRepo + scenarioService *scenarios_service.ScenarioService + answersRepo *team_answers_repo.TeamAnswersRepo + actionsRepo *actions_repo.ActionsRepo +} + +func NewAnswerService( + gamesRepo *games_repo.GamesRepo, + teamsRepo *teams_repo.TeamsRepo, + scenarioService *scenarios_service.ScenarioService, + answersRepo *team_answers_repo.TeamAnswersRepo, + actionsRepo *actions_repo.ActionsRepo, +) *AnswerService { + return &AnswerService{ + gamesRepo: gamesRepo, + teamsRepo: teamsRepo, + scenarioService: scenarioService, + answersRepo: answersRepo, + actionsRepo: actionsRepo, + } +} + +var ( + // ErrGameFinished — после завершения игры ответы команд больше нельзя + // изменять: организатор оценивает их в панели игры. + ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять") + // ErrGameNotStarted — игра ещё не начата, отвечать на вопросы нельзя. + ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя") + // ErrQuestionNotFound — вопрос с таким кодом отсутствует в сценарии игры. + ErrQuestionNotFound = errors.New("Вопрос не найден") + // ErrGameNotFinished — итоги игры доступны только после её завершения. + ErrGameNotFinished = errors.New("Итоги доступны после завершения игры") +) + +// getTeamAndGame проверяет команду по паролю и возвращает её вместе с игрой. +func (s *AnswerService) getTeamAndGame(ctx context.Context, teamId int, password string) (*repos.Team, *repos.Game, error) { + team, err := s.teamsRepo.GetTeamByID(ctx, teamId) + if err != nil { + return nil, nil, err + } + if team.Password != password || team.ParticipationStatus == "cancelled" { + return nil, nil, teams_repo.ErrTeamNotFound + } + game, err := s.gamesRepo.GetGameByID(ctx, team.GameId) + if err != nil { + return nil, nil, err + } + return team, game, nil +} + +// getScenarioQuestionsByCode возвращает вопросы сценария игры с индексацией по коду. +func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenarioId int) ([]*storytelling.Question, map[string]*storytelling.Question, error) { + scenario, err := s.scenarioService.GetFullScenarioByID(ctx, scenarioId) + if err != nil { + return nil, nil, err + } + byCode := make(map[string]*storytelling.Question, len(scenario.Story.Questions)) + for _, question := range scenario.Story.Questions { + byCode[question.Code] = question + } + return scenario.Story.Questions, byCode, nil +} + +// GetTeamAnswers возвращает ответы команды на вопросы дела (без баллов: +// баллы видны только организаторам). Ответы на удалённые из сценария +// вопросы не возвращаются. +func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamId int, password string) ([]Answer, error) { + _, game, err := s.getTeamAndGame(ctx, teamId, password) + 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 + } + teamAnswers, err := s.answersRepo.GetAnswersByTeamID(ctx, teamId) + if err != nil { + return nil, err + } + answers := make([]Answer, 0, len(teamAnswers)) + for _, teamAnswer := range teamAnswers { + if _, ok := byCode[teamAnswer.QuestionCode]; !ok { + continue + } + answers = append(answers, Answer{ + QuestionCode: teamAnswer.QuestionCode, + Text: teamAnswer.Text, + }) + } + return answers, nil +} + +// SubmitTeamAnswers сохраняет ответы команды на вопросы дела (upsert). +// Доступно только пока игра идёт (статусы run/pause): после завершения +// ответы фиксируются для оценки организатором. Переданные коды должны +// существовать в сценарии игры; пустая строка очищает ответ. +func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []Answer) error { + _, game, err := s.getTeamAndGame(ctx, teamId, password) + if err != nil { + return err + } + if game.Format == "offline" { + return errors.New("В offline-игре ответы через сайт недоступны") + } + switch game.Status { + case "run", "pause": + // игра идёт — можно отвечать + default: + if game.Status == "finish" { + return ErrGameFinished + } + return ErrGameNotStarted + } + _, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) + if err != nil { + return err + } + rows := make([]repos.TeamAnswer, 0, len(answers)) + for _, answer := range answers { + if _, ok := byCode[answer.QuestionCode]; !ok { + return ErrQuestionNotFound + } + rows = append(rows, repos.TeamAnswer{ + QuestionCode: answer.QuestionCode, + Text: answer.Text, + }) + } + return s.answersRepo.UpsertAnswers(ctx, teamId, rows) +} + +// GetGameAnswers возвращает полную картину для оценки организатором: +// вопросы сценария с правильными ответами и ответы всех команд с баллами. +// Команда может не дать ответ на вопрос — такой вопрос попадает в список +// ответов команды с пустым текстом и без балла (организатор видит его и +// может выставить балл, в т.ч. штрафной). Ответы на удалённые из сценария +// вопросы не возвращаются (как в GetTeamAnswers). +func (s *AnswerService) GetGameAnswers(ctx context.Context, gameId int) (*GameAnswers, error) { + game, err := s.gamesRepo.GetGameByID(ctx, gameId) + if err != nil { + return nil, err + } + questions, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) + if err != nil { + return nil, err + } + teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId) + if err != nil { + return nil, err + } + rows, err := s.answersRepo.GetAnswersByGameID(ctx, gameId) + if err != nil { + return nil, err + } + answerByTeamAndCode := make(map[int]map[string]AnswerScore, len(teams)) + for _, row := range rows { + if _, ok := byCode[row.QuestionCode]; !ok { + continue + } + score := row.Score + if answerByTeamAndCode[row.TeamId] == nil { + answerByTeamAndCode[row.TeamId] = make(map[string]AnswerScore, len(questions)) + } + answerByTeamAndCode[row.TeamId][row.QuestionCode] = AnswerScore{ + QuestionCode: row.QuestionCode, + Text: row.Text, + Score: score, + } + } + teamAnswers := make([]TeamAnswers, 0, len(teams)) + for _, team := range teams { + byCode := answerByTeamAndCode[team.ID] + // Все вопросы сценария в порядке сценария; неотвеченные — с пустым + // текстом и без балла, чтобы организатор видел пропуски. + answers := make([]AnswerScore, 0, len(questions)) + for _, question := range questions { + if answer, ok := byCode[question.Code]; ok { + answers = append(answers, answer) + continue + } + answers = append(answers, AnswerScore{QuestionCode: question.Code}) + } + teamAnswers = append(teamAnswers, TeamAnswers{ + Team: game_model.MapTeam(team), + Answers: answers, + }) + } + return &GameAnswers{ + Questions: questions, + Teams: teamAnswers, + }, nil +} + +// SetTeamAnswerScore выставляет балл команде за ответ на вопрос дела; +// score == nil сбрасывает балл. Балл может быть любым целым числом +// (в т.ч. отрицательным — штрафные баллы). Вопрос должен существовать +// в сценарии игры. +func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamId int, questionCode string, score *int) error { + team, err := s.teamsRepo.GetTeamByID(ctx, teamId) + if err != nil { + return err + } + game, err := s.gamesRepo.GetGameByID(ctx, team.GameId) + if err != nil { + return err + } + _, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) + if err != nil { + return err + } + if _, ok := byCode[questionCode]; !ok { + return ErrQuestionNotFound + } + return s.answersRepo.SetAnswerScore(ctx, teamId, questionCode, score) +} + +// GetGameResults возвращает итоговую таблицу игры: вопросы сценария (шапка +// таблицы, без правильных ответов) и команды, отсортированные по сумме +// баллов, с баллами по каждому вопросу (в порядке сценария), числом ходов и +// местом. Доступно только после завершения игры (статус finish); неоценённые +// вопросы в сумме считаются как 0. +func (s *AnswerService) GetGameResults(ctx context.Context, gameId int) (*GameResults, error) { + game, err := s.gamesRepo.GetGameByID(ctx, gameId) + if err != nil { + return nil, err + } + if game.Status != "finish" { + return nil, ErrGameNotFinished + } + questions, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) + if err != nil { + return nil, err + } + teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId) + if err != nil { + return nil, err + } + teamIds := make([]int, 0, len(teams)) + for _, team := range teams { + teamIds = append(teamIds, team.ID) + } + actionsCountByTeamIDs, err := s.actionsRepo.GetActionsCountByTeamIDs(ctx, teamIds) + if err != nil { + return nil, err + } + rows, err := s.answersRepo.GetAnswersByGameID(ctx, gameId) + if err != nil { + return nil, err + } + answerByTeamAndCode := make(map[int]map[string]AnswerScore, len(teams)) + for _, row := range rows { + if _, ok := byCode[row.QuestionCode]; !ok { + continue + } + if answerByTeamAndCode[row.TeamId] == nil { + answerByTeamAndCode[row.TeamId] = make(map[string]AnswerScore, len(questions)) + } + answerByTeamAndCode[row.TeamId][row.QuestionCode] = AnswerScore{ + QuestionCode: row.QuestionCode, + Text: row.Text, + Score: row.Score, + } + } + mappedTeams := make([]*game_model.Team, 0, len(teams)) + for _, team := range teams { + mapped := game_model.MapTeam(team) + mapped.ActionsCount = actionsCountByTeamIDs[team.ID] + mappedTeams = append(mappedTeams, mapped) + } + return &GameResults{ + Questions: questions, + Teams: rankTeams(buildTeamResults(questions, mappedTeams, answerByTeamAndCode)), + }, nil +} diff --git a/internal/services/answer_service/service_test.go b/internal/services/answer_service/service_test.go new file mode 100644 index 0000000..2c0056d --- /dev/null +++ b/internal/services/answer_service/service_test.go @@ -0,0 +1,82 @@ +package answer_service + +import ( + "context" + "errors" + "os" + "testing" + + "evening_detective_server/internal/repos/games_repo" + "evening_detective_server/internal/repos/teams_repo" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestAnswerAccessPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration check") + } + ctx := context.Background() + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatal(err) + } + config.MaxConns = 1 + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + _, err = pool.Exec(ctx, ` + CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, start_at TIMESTAMP, + format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP); + CREATE TEMP TABLE teams (id INT, name TEXT, captain_email TEXT, participants_count INT, + status TEXT, password TEXT, game_id INT); + INSERT INTO games VALUES (1, 'Game', '', NOW(), 'online', 'finish', 1, NULL, NULL); + INSERT INTO teams VALUES (1, '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;`) + if err != nil { + t.Fatal(err) + } + // Rejected requests must not reach scenario or answer storage. + service := NewAnswerService(games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool), nil, nil, nil) + for _, tc := range []struct { + status string + want error + }{ + {"draft", ErrGameNotStarted}, {"finish", ErrGameFinished}, + } { + if _, err := pool.Exec(ctx, "UPDATE games SET status=$1", tc.status); err != nil { + t.Fatal(err) + } + if err := service.SubmitTeamAnswers(ctx, 1, "secret", nil); !errors.Is(err, tc.want) { + t.Fatalf("status %s: %v", tc.status, err) + } + } + if _, err := pool.Exec(ctx, "UPDATE games SET status='run',format='offline'"); err != nil { + t.Fatal(err) + } + if answers, err := service.GetTeamAnswers(ctx, 1, "secret"); err != nil || len(answers) != 0 { + t.Fatalf("offline answers: %v %v", answers, err) + } + if err := service.SubmitTeamAnswers(ctx, 1, "secret", nil); err == nil { + t.Fatal("offline accepted answers") + } + if _, err := service.GetGameResults(ctx, 1); !errors.Is(err, ErrGameNotFinished) { + t.Fatalf("unfinished results: %v", err) + } + for _, tc := range []struct{ password, participation string }{ + {"wrong", "pending"}, {"secret", "cancelled"}, + } { + if _, err := pool.Exec(ctx, "UPDATE teams SET participation_status=$1", tc.participation); err != nil { + t.Fatal(err) + } + if _, err := service.GetTeamAnswers(ctx, 1, tc.password); !errors.Is(err, teams_repo.ErrTeamNotFound) { + t.Fatalf("read access: %v", err) + } + if err := service.SubmitTeamAnswers(ctx, 1, tc.password, nil); !errors.Is(err, teams_repo.ErrTeamNotFound) { + t.Fatalf("write access: %v", err) + } + } +} diff --git a/internal/services/game_service/service.go b/internal/services/game_service/service.go index dfe66e0..58db7bf 100644 --- a/internal/services/game_service/service.go +++ b/internal/services/game_service/service.go @@ -6,12 +6,10 @@ import ( "evening_detective_server/internal/modules/cleaner" "evening_detective_server/internal/modules/game_model" "evening_detective_server/internal/modules/storytelling" - "evening_detective_server/internal/repos" "evening_detective_server/internal/repos/actions_repo" "evening_detective_server/internal/repos/applications_repo" "evening_detective_server/internal/repos/games_repo" "evening_detective_server/internal/repos/scenarios_repo" - "evening_detective_server/internal/repos/team_answers_repo" "evening_detective_server/internal/repos/teams_repo" "evening_detective_server/internal/services/scenarios_service" "time" @@ -26,7 +24,6 @@ type GameService struct { storyteller storytelling.IStory actionsRepo *actions_repo.ActionsRepo applicationsRepo *applications_repo.ApplicationsRepo - answersRepo *team_answers_repo.TeamAnswersRepo cleaner cleaner.ICleaner } @@ -39,7 +36,6 @@ func NewGameService( storyteller storytelling.IStory, actionsRepo *actions_repo.ActionsRepo, applicationsRepo *applications_repo.ApplicationsRepo, - answersRepo *team_answers_repo.TeamAnswersRepo, cleaner cleaner.ICleaner, ) *GameService { return &GameService{ @@ -51,7 +47,6 @@ func NewGameService( storyteller: storyteller, actionsRepo: actionsRepo, applicationsRepo: applicationsRepo, - answersRepo: answersRepo, cleaner: cleaner, } } @@ -334,258 +329,3 @@ func (s *GameService) DeleteLastTeamAction(ctx context.Context, teamId int) erro return s.actionsRepo.DeleteLastActionByTeamId(ctx, teamId) } - -var ( - // ErrGameFinished — после завершения игры ответы команд больше нельзя - // изменять: организатор оценивает их в панели игры. - ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять") - // ErrGameNotStarted — игра ещё не начата, отвечать на вопросы нельзя. - ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя") - // ErrQuestionNotFound — вопрос с таким кодом отсутствует в сценарии игры. - ErrQuestionNotFound = errors.New("Вопрос не найден") - // ErrGameNotFinished — итоги игры доступны только после её завершения. - ErrGameNotFinished = errors.New("Итоги доступны после завершения игры") -) - -// getTeamAndGame проверяет команду по паролю и возвращает её вместе с игрой. -func (s *GameService) getTeamAndGame(ctx context.Context, teamId int, password string) (*repos.Team, *repos.Game, error) { - team, err := s.teamsRepo.GetTeamByID(ctx, teamId) - if err != nil { - return nil, nil, err - } - if team.Password != password || team.ParticipationStatus == "cancelled" { - return nil, nil, teams_repo.ErrTeamNotFound - } - game, err := s.gamesRepo.GetGameByID(ctx, team.GameId) - if err != nil { - return nil, nil, err - } - return team, game, nil -} - -// getScenarioQuestionsByCode возвращает вопросы сценария игры с индексацией по коду. -func (s *GameService) getScenarioQuestionsByCode(ctx context.Context, scenarioId int) ([]*storytelling.Question, map[string]*storytelling.Question, error) { - scenario, err := s.scenarioService.GetFullScenarioByID(ctx, scenarioId) - if err != nil { - return nil, nil, err - } - byCode := make(map[string]*storytelling.Question, len(scenario.Story.Questions)) - for _, question := range scenario.Story.Questions { - byCode[question.Code] = question - } - return scenario.Story.Questions, byCode, nil -} - -// GetTeamAnswers возвращает ответы команды на вопросы дела (без баллов: -// баллы видны только организаторам). Ответы на удалённые из сценария -// вопросы не возвращаются. -func (s *GameService) GetTeamAnswers(ctx context.Context, teamId int, password string) ([]Answer, error) { - _, game, err := s.getTeamAndGame(ctx, teamId, password) - 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 - } - teamAnswers, err := s.answersRepo.GetAnswersByTeamID(ctx, teamId) - if err != nil { - return nil, err - } - answers := make([]Answer, 0, len(teamAnswers)) - for _, teamAnswer := range teamAnswers { - if _, ok := byCode[teamAnswer.QuestionCode]; !ok { - continue - } - answers = append(answers, Answer{ - QuestionCode: teamAnswer.QuestionCode, - Text: teamAnswer.Text, - }) - } - return answers, nil -} - -// SubmitTeamAnswers сохраняет ответы команды на вопросы дела (upsert). -// Доступно только пока игра идёт (статусы run/pause): после завершения -// ответы фиксируются для оценки организатором. Переданные коды должны -// существовать в сценарии игры; пустая строка очищает ответ. -func (s *GameService) SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []Answer) error { - _, game, err := s.getTeamAndGame(ctx, teamId, password) - if err != nil { - return err - } - if game.Format == "offline" { - return errors.New("В offline-игре ответы через сайт недоступны") - } - switch game.Status { - case "run", "pause": - // игра идёт — можно отвечать - default: - if game.Status == "finish" { - return ErrGameFinished - } - return ErrGameNotStarted - } - _, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) - if err != nil { - return err - } - rows := make([]repos.TeamAnswer, 0, len(answers)) - for _, answer := range answers { - if _, ok := byCode[answer.QuestionCode]; !ok { - return ErrQuestionNotFound - } - rows = append(rows, repos.TeamAnswer{ - QuestionCode: answer.QuestionCode, - Text: answer.Text, - }) - } - return s.answersRepo.UpsertAnswers(ctx, teamId, rows) -} - -// GetGameAnswers возвращает полную картину для оценки организатором: -// вопросы сценария с правильными ответами и ответы всех команд с баллами. -// Команда может не дать ответ на вопрос — такой вопрос попадает в список -// ответов команды с пустым текстом и без балла (организатор видит его и -// может выставить балл, в т.ч. штрафной). Ответы на удалённые из сценария -// вопросы не возвращаются (как в GetTeamAnswers). -func (s *GameService) GetGameAnswers(ctx context.Context, gameId int) (*GameAnswers, error) { - game, err := s.gamesRepo.GetGameByID(ctx, gameId) - if err != nil { - return nil, err - } - questions, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) - if err != nil { - return nil, err - } - teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId) - if err != nil { - return nil, err - } - rows, err := s.answersRepo.GetAnswersByGameID(ctx, gameId) - if err != nil { - return nil, err - } - answerByTeamAndCode := make(map[int]map[string]AnswerScore, len(teams)) - for _, row := range rows { - if _, ok := byCode[row.QuestionCode]; !ok { - continue - } - score := row.Score - if answerByTeamAndCode[row.TeamId] == nil { - answerByTeamAndCode[row.TeamId] = make(map[string]AnswerScore, len(questions)) - } - answerByTeamAndCode[row.TeamId][row.QuestionCode] = AnswerScore{ - QuestionCode: row.QuestionCode, - Text: row.Text, - Score: score, - } - } - teamAnswers := make([]TeamAnswers, 0, len(teams)) - for _, team := range teams { - byCode := answerByTeamAndCode[team.ID] - // Все вопросы сценария в порядке сценария; неотвеченные — с пустым - // текстом и без балла, чтобы организатор видел пропуски. - answers := make([]AnswerScore, 0, len(questions)) - for _, question := range questions { - if answer, ok := byCode[question.Code]; ok { - answers = append(answers, answer) - continue - } - answers = append(answers, AnswerScore{QuestionCode: question.Code}) - } - teamAnswers = append(teamAnswers, TeamAnswers{ - Team: game_model.MapTeam(team), - Answers: answers, - }) - } - return &GameAnswers{ - Questions: questions, - Teams: teamAnswers, - }, nil -} - -// SetTeamAnswerScore выставляет балл команде за ответ на вопрос дела; -// score == nil сбрасывает балл. Балл может быть любым целым числом -// (в т.ч. отрицательным — штрафные баллы). Вопрос должен существовать -// в сценарии игры. -func (s *GameService) SetTeamAnswerScore(ctx context.Context, teamId int, questionCode string, score *int) error { - team, err := s.teamsRepo.GetTeamByID(ctx, teamId) - if err != nil { - return err - } - game, err := s.gamesRepo.GetGameByID(ctx, team.GameId) - if err != nil { - return err - } - _, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) - if err != nil { - return err - } - if _, ok := byCode[questionCode]; !ok { - return ErrQuestionNotFound - } - return s.answersRepo.SetAnswerScore(ctx, teamId, questionCode, score) -} - -// GetGameResults возвращает итоговую таблицу игры: вопросы сценария (шапка -// таблицы, без правильных ответов) и команды, отсортированные по сумме -// баллов, с баллами по каждому вопросу (в порядке сценария), числом ходов и -// местом. Доступно только после завершения игры (статус finish); неоценённые -// вопросы в сумме считаются как 0. -func (s *GameService) GetGameResults(ctx context.Context, gameId int) (*GameResults, error) { - game, err := s.gamesRepo.GetGameByID(ctx, gameId) - if err != nil { - return nil, err - } - if game.Status != "finish" { - return nil, ErrGameNotFinished - } - questions, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId) - if err != nil { - return nil, err - } - teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId) - if err != nil { - return nil, err - } - teamIds := make([]int, 0, len(teams)) - for _, team := range teams { - teamIds = append(teamIds, team.ID) - } - actionsCountByTeamIDs, err := s.actionsRepo.GetActionsCountByTeamIDs(ctx, teamIds) - if err != nil { - return nil, err - } - rows, err := s.answersRepo.GetAnswersByGameID(ctx, gameId) - if err != nil { - return nil, err - } - answerByTeamAndCode := make(map[int]map[string]AnswerScore, len(teams)) - for _, row := range rows { - if _, ok := byCode[row.QuestionCode]; !ok { - continue - } - if answerByTeamAndCode[row.TeamId] == nil { - answerByTeamAndCode[row.TeamId] = make(map[string]AnswerScore, len(questions)) - } - answerByTeamAndCode[row.TeamId][row.QuestionCode] = AnswerScore{ - QuestionCode: row.QuestionCode, - Text: row.Text, - Score: row.Score, - } - } - mappedTeams := make([]*game_model.Team, 0, len(teams)) - for _, team := range teams { - mapped := game_model.MapTeam(team) - mapped.ActionsCount = actionsCountByTeamIDs[team.ID] - mappedTeams = append(mappedTeams, mapped) - } - return &GameResults{ - Questions: questions, - Teams: rankTeams(buildTeamResults(questions, mappedTeams, answerByTeamAndCode)), - }, nil -} diff --git a/internal/services/mcp_service/http_test.go b/internal/services/mcp_service/http_test.go index 8479b79..2003c4c 100644 --- a/internal/services/mcp_service/http_test.go +++ b/internal/services/mcp_service/http_test.go @@ -20,7 +20,7 @@ import ( // Проверяются initialize, tools/list (точное множество) и tools/call // connect — как это будет делать внешний MCP-клиент. func TestStreamableHTTPEndToEnd(t *testing.T) { - svc := NewMCPService(&fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + svc := NewMCPService(&fakeGamePlayer{story: defaultStory(), game: defaultGame()}, &fakeAnswerSubmitter{}) mcpHTTP := server.NewStreamableHTTPServer(svc.Server()) srv := httptest.NewServer(mcpHTTP) diff --git a/internal/services/mcp_service/service.go b/internal/services/mcp_service/service.go index 75292f5..a27ef67 100644 --- a/internal/services/mcp_service/service.go +++ b/internal/services/mcp_service/service.go @@ -17,7 +17,7 @@ import ( "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos/actions_repo" - "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/answer_service" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -33,7 +33,11 @@ const defaultCallTimeout = 10 * time.Second type GamePlayer interface { GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) AddTeamAction(ctx context.Context, teamId int, password, actionCode string, viaMCP bool) error - SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []game_service.Answer) error +} + +// AnswerSubmitter сохраняет ответы команды независимо от прохождения истории. +type AnswerSubmitter interface { + SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []answer_service.Answer) error } // MCPService собирает MCP-сервер с игровыми инструментами. Сервис не @@ -41,12 +45,13 @@ type GamePlayer interface { // пароль явно — либо из ссылки на игру через инструмент connect, либо // напрямую. type MCPService struct { - game GamePlayer + game GamePlayer + answers AnswerSubmitter } // NewMCPService создаёт сервис поверх игровых сервисов. -func NewMCPService(game GamePlayer) *MCPService { - return &MCPService{game: game} +func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService { + return &MCPService{game: game, answers: answers} } // Server собирает и возвращает MCP-сервер с зарегистрированными @@ -283,7 +288,7 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest type submitAnswersArgs struct { teamID int64 password string - answers []game_service.Answer + answers []answer_service.Answer } // parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers: @@ -303,7 +308,7 @@ func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) { if !ok { return submitAnswersArgs{}, fmt.Errorf("требуется answers (массив ответов)") } - answers := make([]game_service.Answer, 0, len(rawAnswers)) + answers := make([]answer_service.Answer, 0, len(rawAnswers)) for i, raw := range rawAnswers { item, ok := raw.(map[string]any) if !ok { @@ -314,7 +319,7 @@ func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) { if questionCode == "" { return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i) } - answers = append(answers, game_service.Answer{ + answers = append(answers, answer_service.Answer{ QuestionCode: questionCode, Text: answerText, }) @@ -335,7 +340,7 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() - if err := s.game.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil { + if err := s.answers.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil { return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil } diff --git a/internal/services/mcp_service/service_test.go b/internal/services/mcp_service/service_test.go index 02ff38d..ee5615c 100644 --- a/internal/services/mcp_service/service_test.go +++ b/internal/services/mcp_service/service_test.go @@ -10,7 +10,7 @@ import ( "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos/actions_repo" - "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/answer_service" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" @@ -40,7 +40,15 @@ func (f *fakeGamePlayer) AddTeamAction(_ context.Context, teamID int, password, return nil } -func (f *fakeGamePlayer) SubmitTeamAnswers(_ context.Context, teamID int, password string, answers []game_service.Answer) error { +type fakeAnswerSubmitter struct { + err error + teamID int + password string + answers []answer_service.Answer +} + +func (f *fakeAnswerSubmitter) SubmitTeamAnswers(_ context.Context, teamID int, password string, answers []answer_service.Answer) error { + f.teamID, f.password, f.answers = teamID, password, answers if f.err != nil { return f.err } @@ -77,13 +85,15 @@ func defaultGame() *game_model.Game { // mcpFixture поднимает in-process MCP-клиент над сервисом с моком // GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис. type mcpFixture struct { - client *client.Client + client *client.Client + answers *fakeAnswerSubmitter } -func newMCPFixture(t *testing.T, player GamePlayer) *mcpFixture { +func newMCPFixture(t *testing.T, player *fakeGamePlayer) *mcpFixture { t.Helper() - svc := NewMCPService(player) + answers := &fakeAnswerSubmitter{err: player.err} + svc := NewMCPService(player, answers) mcpClient, err := client.NewInProcessClient(svc.Server()) if err != nil { @@ -105,7 +115,7 @@ func newMCPFixture(t *testing.T, player GamePlayer) *mcpFixture { t.Fatalf("client.Initialize() error = %v", err) } - return &mcpFixture{client: mcpClient} + return &mcpFixture{client: mcpClient, answers: answers} } // call вызывает инструмент с аргументами и возвращает текст результата. @@ -400,12 +410,12 @@ func TestParseConnectURL(t *testing.T) { // TestNewMCPService — конструктор не требует БД и возвращает сервис. func TestNewMCPService(t *testing.T) { - svc := NewMCPService(&fakeGamePlayer{}) + svc := NewMCPService(&fakeGamePlayer{}, &fakeAnswerSubmitter{}) if svc == nil { t.Fatal("NewMCPService() = nil") } - if svc.game == nil { - t.Fatal("NewMCPService(): game == nil") + if svc.game == nil || svc.answers == nil { + t.Fatal("NewMCPService(): missing game or answers dependency") } } @@ -430,6 +440,11 @@ func TestSubmitAnswers(t *testing.T) { if !strings.Contains(got, "entrance") { t.Fatalf("submit_answers: %q; want story JSON в ответе", got) } + if f.answers.teamID != 10 || f.answers.password != "team-pass-1" || len(f.answers.answers) != 2 || + f.answers.answers[0] != (answer_service.Answer{QuestionCode: "q1", Text: "Дворецкий"}) || + f.answers.answers[1] != (answer_service.Answer{QuestionCode: "q2", Text: ""}) { + t.Fatalf("answers not forwarded to AnswerSubmitter: %+v", f.answers) + } } func TestSubmitAnswersValidation(t *testing.T) { diff --git a/internal/services/participation_service/participation_test.go b/internal/services/participation_service/participation_test.go index f494fdf..50a9b53 100644 --- a/internal/services/participation_service/participation_test.go +++ b/internal/services/participation_service/participation_test.go @@ -3,6 +3,7 @@ package participation_service import ( "context" "errors" + "evening_detective_server/internal/services/answer_service" "fmt" "os" "strings" @@ -222,14 +223,15 @@ func TestParticipationPostgres(t *testing.T) { t.Fatal("cancelled team in active list") } } - gameService := game_service.NewGameService(nil, repo, nil, nil, "", nil, nil, nil, nil, nil) + gameService := game_service.NewGameService(nil, repo, nil, nil, "", nil, nil, nil, nil) if _, _, _, err := gameService.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) { t.Fatalf("cancelled team got story: %v", err) } if err := gameService.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) { t.Fatalf("cancelled team added action: %v", err) } - if _, err := gameService.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) { + answerService := answer_service.NewAnswerService(nil, repo, nil, nil, nil) + if _, err := answerService.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) { t.Fatalf("cancelled team got answers: %v", err) } token6 := participationToken("secret", 6)