add AnswerService

This commit is contained in:
2026-09-17 02:38:05 +07:00
parent c70d839038
commit 87bd07019a
18 changed files with 448 additions and 304 deletions
+7 -7
View File
@@ -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{
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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(),
+2 -2
View File
@@ -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{
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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, "не удалось получить результаты")
}
+4
View File
@@ -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,
@@ -1,4 +1,4 @@
package game_service
package answer_service
import (
"evening_detective_server/internal/modules/game_model"
@@ -1,4 +1,4 @@
package game_service
package answer_service
import (
"evening_detective_server/internal/modules/game_model"
@@ -1,4 +1,4 @@
package game_service
package answer_service
import (
"evening_detective_server/internal/modules/game_model"
+293
View File
@@ -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
}
@@ -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)
}
}
}
-260
View File
@@ -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
}
+1 -1
View File
@@ -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)
+14 -9
View File
@@ -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
}
+24 -9
View File
@@ -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) {
@@ -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)