generated from VLADIMIR/template
add AnswerService
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
|||||||
"evening_detective_server/internal/repos/team_answers_repo"
|
"evening_detective_server/internal/repos/team_answers_repo"
|
||||||
"evening_detective_server/internal/repos/teams_repo"
|
"evening_detective_server/internal/repos/teams_repo"
|
||||||
"evening_detective_server/internal/repos/users_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/file_service"
|
||||||
"evening_detective_server/internal/services/game_service"
|
"evening_detective_server/internal/services/game_service"
|
||||||
"evening_detective_server/internal/services/mcp_service"
|
"evening_detective_server/internal/services/mcp_service"
|
||||||
@@ -143,10 +144,11 @@ func main() {
|
|||||||
storyteller,
|
storyteller,
|
||||||
actionsRepo,
|
actionsRepo,
|
||||||
applicationsRepo,
|
applicationsRepo,
|
||||||
answersRepo,
|
|
||||||
cleaner,
|
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"))
|
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"))
|
participationService := participation_service.NewParticipationService(teamRepo, os.Getenv("APP_BASE_URL"))
|
||||||
@@ -199,6 +201,7 @@ func main() {
|
|||||||
fileService,
|
fileService,
|
||||||
scenarioService,
|
scenarioService,
|
||||||
gameService,
|
gameService,
|
||||||
|
answerService,
|
||||||
teamService,
|
teamService,
|
||||||
participationService,
|
participationService,
|
||||||
screenRepo,
|
screenRepo,
|
||||||
@@ -306,7 +309,7 @@ func main() {
|
|||||||
// REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому
|
// REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому
|
||||||
// http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway.
|
// http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway.
|
||||||
mcpHTTP := server.NewStreamableHTTPServer(
|
mcpHTTP := server.NewStreamableHTTPServer(
|
||||||
mcp_service.NewMCPService(gameService).Server(),
|
mcp_service.NewMCPService(gameService, answerService).Server(),
|
||||||
server.WithDisableLocalhostProtection(true),
|
server.WithDisableLocalhostProtection(true),
|
||||||
)
|
)
|
||||||
mainMux.Handle("/api/mcp", mcpHTTP)
|
mainMux.Handle("/api/mcp", mcpHTTP)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"evening_detective_server/internal/services/game_service"
|
"evening_detective_server/internal/services/answer_service"
|
||||||
proto "evening_detective_server/proto"
|
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))
|
res := make([]*proto.Answer, 0, len(o))
|
||||||
for _, item := range o {
|
for _, item := range o {
|
||||||
res = append(res, &proto.Answer{
|
res = append(res, &proto.Answer{
|
||||||
@@ -16,10 +16,10 @@ func mapAnswers(o []game_service.Answer) []*proto.Answer {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertAnswers(o []*proto.Answer) []game_service.Answer {
|
func convertAnswers(o []*proto.Answer) []answer_service.Answer {
|
||||||
res := make([]game_service.Answer, 0, len(o))
|
res := make([]answer_service.Answer, 0, len(o))
|
||||||
for _, item := range o {
|
for _, item := range o {
|
||||||
res = append(res, game_service.Answer{
|
res = append(res, answer_service.Answer{
|
||||||
QuestionCode: item.QuestionCode,
|
QuestionCode: item.QuestionCode,
|
||||||
Text: item.Answer,
|
Text: item.Answer,
|
||||||
})
|
})
|
||||||
@@ -27,7 +27,7 @@ func convertAnswers(o []*proto.Answer) []game_service.Answer {
|
|||||||
return res
|
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))
|
res := make([]*proto.AnswerScore, 0, len(o))
|
||||||
for _, item := range o {
|
for _, item := range o {
|
||||||
score := &proto.AnswerScore{
|
score := &proto.AnswerScore{
|
||||||
@@ -43,7 +43,7 @@ func mapAnswerScores(o []game_service.AnswerScore) []*proto.AnswerScore {
|
|||||||
return res
|
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))
|
res := make([]*proto.TeamAnswers, 0, len(o))
|
||||||
for _, item := range o {
|
for _, item := range o {
|
||||||
res = append(res, &proto.TeamAnswers{
|
res = append(res, &proto.TeamAnswers{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersReq) (*proto.GetTeamAnswersRsp, error) {
|
func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersReq) (*proto.GetTeamAnswersRsp, error) {
|
||||||
answers, err := s.gameService.GetTeamAnswers(
|
answers, err := s.answerService.GetTeamAnswers(
|
||||||
ctx,
|
ctx,
|
||||||
int(req.Id),
|
int(req.Id),
|
||||||
req.Password,
|
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) {
|
func (s *server) SubmitTeamAnswers(ctx context.Context, req *proto.SubmitTeamAnswersReq) (*proto.SubmitTeamAnswersRsp, error) {
|
||||||
err := s.gameService.SubmitTeamAnswers(
|
err := s.answerService.SubmitTeamAnswers(
|
||||||
ctx,
|
ctx,
|
||||||
int(req.Id),
|
int(req.Id),
|
||||||
req.Password,
|
req.Password,
|
||||||
@@ -56,7 +56,7 @@ func (s *server) SetTeamAnswerScore(ctx context.Context, req *proto.SetTeamAnswe
|
|||||||
score = &v
|
score = &v
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.gameService.SetTeamAnswerScore(
|
err := s.answerService.SetTeamAnswerScore(
|
||||||
ctx,
|
ctx,
|
||||||
int(req.Id),
|
int(req.Id),
|
||||||
req.QuestionCode,
|
req.QuestionCode,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (s *server) GetGameAnswers(ctx context.Context, req *proto.GetGameAnswersRe
|
|||||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
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 {
|
if err != nil {
|
||||||
return &proto.GetGameAnswersRsp{
|
return &proto.GetGameAnswersRsp{
|
||||||
Error: err.Error(),
|
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")
|
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 {
|
if err != nil {
|
||||||
return &proto.GetGameResultsRsp{
|
return &proto.GetGameResultsRsp{
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"evening_detective_server/internal/modules/storytelling"
|
"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"
|
proto "evening_detective_server/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ func mapResultsQuestions(o []*storytelling.Question) []*proto.ResultsQuestion {
|
|||||||
// mapTeamResults мапит строки итоговой таблицы. Команда мапится целиком
|
// mapTeamResults мапит строки итоговой таблицы. Команда мапится целиком
|
||||||
// (включая password/applications — аудитория та же, что у GetGameAnswers:
|
// (включая password/applications — аудитория та же, что у GetGameAnswers:
|
||||||
// организатор/автор); actionsCount продублирован на уровне строки таблицы.
|
// организатор/автор); actionsCount продублирован на уровне строки таблицы.
|
||||||
func mapTeamResults(o []game_service.TeamResult) []*proto.TeamResult {
|
func mapTeamResults(o []answer_service.TeamResult) []*proto.TeamResult {
|
||||||
res := make([]*proto.TeamResult, 0, len(o))
|
res := make([]*proto.TeamResult, 0, len(o))
|
||||||
for _, item := range o {
|
for _, item := range o {
|
||||||
res = append(res, &proto.TeamResult{
|
res = append(res, &proto.TeamResult{
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import (
|
|||||||
"evening_detective_server/internal/modules/game_model"
|
"evening_detective_server/internal/modules/game_model"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"evening_detective_server/internal/services/game_service"
|
"evening_detective_server/internal/services/answer_service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMapTeamResults(t *testing.T) {
|
func TestMapTeamResults(t *testing.T) {
|
||||||
sc := 5
|
sc := 5
|
||||||
results := []game_service.TeamResult{
|
results := []answer_service.TeamResult{
|
||||||
{
|
{
|
||||||
Team: &game_model.Team{
|
Team: &game_model.Team{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
@@ -17,7 +17,7 @@ func TestMapTeamResults(t *testing.T) {
|
|||||||
Status: "play",
|
Status: "play",
|
||||||
ActionsCount: 7,
|
ActionsCount: 7,
|
||||||
},
|
},
|
||||||
Answers: []game_service.AnswerScore{
|
Answers: []answer_service.AnswerScore{
|
||||||
{QuestionCode: "q1", Text: "ответ", Score: &sc},
|
{QuestionCode: "q1", Text: "ответ", Score: &sc},
|
||||||
},
|
},
|
||||||
TotalScore: 5,
|
TotalScore: 5,
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ func (s *server) GetScreenResults(ctx context.Context, req *proto.GetScreenResul
|
|||||||
if game.Status != "finish" {
|
if game.Status != "finish" {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "результаты доступны после завершения игры")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "не удалось получить результаты")
|
return nil, status.Error(codes.Internal, "не удалось получить результаты")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"evening_detective_server/internal/modules/string_tools"
|
"evening_detective_server/internal/modules/string_tools"
|
||||||
"evening_detective_server/internal/repos/game_map_repo"
|
"evening_detective_server/internal/repos/game_map_repo"
|
||||||
"evening_detective_server/internal/repos/screen_sessions_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/file_service"
|
||||||
"evening_detective_server/internal/services/game_service"
|
"evening_detective_server/internal/services/game_service"
|
||||||
"evening_detective_server/internal/services/participation_service"
|
"evening_detective_server/internal/services/participation_service"
|
||||||
@@ -25,6 +26,7 @@ type server struct {
|
|||||||
fileService *file_service.FileService
|
fileService *file_service.FileService
|
||||||
scenarioService *scenarios_service.ScenarioService
|
scenarioService *scenarios_service.ScenarioService
|
||||||
gameService *game_service.GameService
|
gameService *game_service.GameService
|
||||||
|
answerService *answer_service.AnswerService
|
||||||
teamService *team_service.TeamService
|
teamService *team_service.TeamService
|
||||||
participationService *participation_service.ParticipationService
|
participationService *participation_service.ParticipationService
|
||||||
screenRepo *screen_sessions_repo.ScreenSessionsRepo
|
screenRepo *screen_sessions_repo.ScreenSessionsRepo
|
||||||
@@ -37,6 +39,7 @@ func NewServer(
|
|||||||
fileService *file_service.FileService,
|
fileService *file_service.FileService,
|
||||||
scenarioService *scenarios_service.ScenarioService,
|
scenarioService *scenarios_service.ScenarioService,
|
||||||
gameService *game_service.GameService,
|
gameService *game_service.GameService,
|
||||||
|
answerService *answer_service.AnswerService,
|
||||||
teamService *team_service.TeamService,
|
teamService *team_service.TeamService,
|
||||||
participationService *participation_service.ParticipationService,
|
participationService *participation_service.ParticipationService,
|
||||||
screenRepo *screen_sessions_repo.ScreenSessionsRepo,
|
screenRepo *screen_sessions_repo.ScreenSessionsRepo,
|
||||||
@@ -48,6 +51,7 @@ func NewServer(
|
|||||||
fileService: fileService,
|
fileService: fileService,
|
||||||
scenarioService: scenarioService,
|
scenarioService: scenarioService,
|
||||||
gameService: gameService,
|
gameService: gameService,
|
||||||
|
answerService: answerService,
|
||||||
teamService: teamService,
|
teamService: teamService,
|
||||||
participationService: participationService,
|
participationService: participationService,
|
||||||
screenRepo: screenRepo,
|
screenRepo: screenRepo,
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package game_service
|
package answer_service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"evening_detective_server/internal/modules/game_model"
|
"evening_detective_server/internal/modules/game_model"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package game_service
|
package answer_service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"evening_detective_server/internal/modules/game_model"
|
"evening_detective_server/internal/modules/game_model"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package game_service
|
package answer_service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"evening_detective_server/internal/modules/game_model"
|
"evening_detective_server/internal/modules/game_model"
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,10 @@ import (
|
|||||||
"evening_detective_server/internal/modules/cleaner"
|
"evening_detective_server/internal/modules/cleaner"
|
||||||
"evening_detective_server/internal/modules/game_model"
|
"evening_detective_server/internal/modules/game_model"
|
||||||
"evening_detective_server/internal/modules/storytelling"
|
"evening_detective_server/internal/modules/storytelling"
|
||||||
"evening_detective_server/internal/repos"
|
|
||||||
"evening_detective_server/internal/repos/actions_repo"
|
"evening_detective_server/internal/repos/actions_repo"
|
||||||
"evening_detective_server/internal/repos/applications_repo"
|
"evening_detective_server/internal/repos/applications_repo"
|
||||||
"evening_detective_server/internal/repos/games_repo"
|
"evening_detective_server/internal/repos/games_repo"
|
||||||
"evening_detective_server/internal/repos/scenarios_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/repos/teams_repo"
|
||||||
"evening_detective_server/internal/services/scenarios_service"
|
"evening_detective_server/internal/services/scenarios_service"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,7 +24,6 @@ type GameService struct {
|
|||||||
storyteller storytelling.IStory
|
storyteller storytelling.IStory
|
||||||
actionsRepo *actions_repo.ActionsRepo
|
actionsRepo *actions_repo.ActionsRepo
|
||||||
applicationsRepo *applications_repo.ApplicationsRepo
|
applicationsRepo *applications_repo.ApplicationsRepo
|
||||||
answersRepo *team_answers_repo.TeamAnswersRepo
|
|
||||||
cleaner cleaner.ICleaner
|
cleaner cleaner.ICleaner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +36,6 @@ func NewGameService(
|
|||||||
storyteller storytelling.IStory,
|
storyteller storytelling.IStory,
|
||||||
actionsRepo *actions_repo.ActionsRepo,
|
actionsRepo *actions_repo.ActionsRepo,
|
||||||
applicationsRepo *applications_repo.ApplicationsRepo,
|
applicationsRepo *applications_repo.ApplicationsRepo,
|
||||||
answersRepo *team_answers_repo.TeamAnswersRepo,
|
|
||||||
cleaner cleaner.ICleaner,
|
cleaner cleaner.ICleaner,
|
||||||
) *GameService {
|
) *GameService {
|
||||||
return &GameService{
|
return &GameService{
|
||||||
@@ -51,7 +47,6 @@ func NewGameService(
|
|||||||
storyteller: storyteller,
|
storyteller: storyteller,
|
||||||
actionsRepo: actionsRepo,
|
actionsRepo: actionsRepo,
|
||||||
applicationsRepo: applicationsRepo,
|
applicationsRepo: applicationsRepo,
|
||||||
answersRepo: answersRepo,
|
|
||||||
cleaner: cleaner,
|
cleaner: cleaner,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,258 +329,3 @@ func (s *GameService) DeleteLastTeamAction(ctx context.Context, teamId int) erro
|
|||||||
|
|
||||||
return s.actionsRepo.DeleteLastActionByTeamId(ctx, teamId)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// Проверяются initialize, tools/list (точное множество) и tools/call
|
// Проверяются initialize, tools/list (точное множество) и tools/call
|
||||||
// connect — как это будет делать внешний MCP-клиент.
|
// connect — как это будет делать внешний MCP-клиент.
|
||||||
func TestStreamableHTTPEndToEnd(t *testing.T) {
|
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())
|
mcpHTTP := server.NewStreamableHTTPServer(svc.Server())
|
||||||
srv := httptest.NewServer(mcpHTTP)
|
srv := httptest.NewServer(mcpHTTP)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
|
|
||||||
"evening_detective_server/internal/modules/storytelling"
|
"evening_detective_server/internal/modules/storytelling"
|
||||||
"evening_detective_server/internal/repos/actions_repo"
|
"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/mcp"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
@@ -33,7 +33,11 @@ const defaultCallTimeout = 10 * time.Second
|
|||||||
type GamePlayer interface {
|
type GamePlayer interface {
|
||||||
GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error)
|
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
|
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-сервер с игровыми инструментами. Сервис не
|
// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не
|
||||||
@@ -42,11 +46,12 @@ type GamePlayer interface {
|
|||||||
// напрямую.
|
// напрямую.
|
||||||
type MCPService struct {
|
type MCPService struct {
|
||||||
game GamePlayer
|
game GamePlayer
|
||||||
|
answers AnswerSubmitter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMCPService создаёт сервис поверх игровых сервисов.
|
// NewMCPService создаёт сервис поверх игровых сервисов.
|
||||||
func NewMCPService(game GamePlayer) *MCPService {
|
func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService {
|
||||||
return &MCPService{game: game}
|
return &MCPService{game: game, answers: answers}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server собирает и возвращает MCP-сервер с зарегистрированными
|
// Server собирает и возвращает MCP-сервер с зарегистрированными
|
||||||
@@ -283,7 +288,7 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest
|
|||||||
type submitAnswersArgs struct {
|
type submitAnswersArgs struct {
|
||||||
teamID int64
|
teamID int64
|
||||||
password string
|
password string
|
||||||
answers []game_service.Answer
|
answers []answer_service.Answer
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
|
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
|
||||||
@@ -303,7 +308,7 @@ func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return submitAnswersArgs{}, fmt.Errorf("требуется answers (массив ответов)")
|
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 {
|
for i, raw := range rawAnswers {
|
||||||
item, ok := raw.(map[string]any)
|
item, ok := raw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -314,7 +319,7 @@ func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
|
|||||||
if questionCode == "" {
|
if questionCode == "" {
|
||||||
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i)
|
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i)
|
||||||
}
|
}
|
||||||
answers = append(answers, game_service.Answer{
|
answers = append(answers, answer_service.Answer{
|
||||||
QuestionCode: questionCode,
|
QuestionCode: questionCode,
|
||||||
Text: answerText,
|
Text: answerText,
|
||||||
})
|
})
|
||||||
@@ -335,7 +340,7 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe
|
|||||||
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
||||||
defer cancel()
|
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
|
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"evening_detective_server/internal/modules/storytelling"
|
"evening_detective_server/internal/modules/storytelling"
|
||||||
"evening_detective_server/internal/repos/actions_repo"
|
"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/client"
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
@@ -40,7 +40,15 @@ func (f *fakeGamePlayer) AddTeamAction(_ context.Context, teamID int, password,
|
|||||||
return nil
|
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 {
|
if f.err != nil {
|
||||||
return f.err
|
return f.err
|
||||||
}
|
}
|
||||||
@@ -78,12 +86,14 @@ func defaultGame() *game_model.Game {
|
|||||||
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
|
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
|
||||||
type mcpFixture struct {
|
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()
|
t.Helper()
|
||||||
|
|
||||||
svc := NewMCPService(player)
|
answers := &fakeAnswerSubmitter{err: player.err}
|
||||||
|
svc := NewMCPService(player, answers)
|
||||||
|
|
||||||
mcpClient, err := client.NewInProcessClient(svc.Server())
|
mcpClient, err := client.NewInProcessClient(svc.Server())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,7 +115,7 @@ func newMCPFixture(t *testing.T, player GamePlayer) *mcpFixture {
|
|||||||
t.Fatalf("client.Initialize() error = %v", err)
|
t.Fatalf("client.Initialize() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &mcpFixture{client: mcpClient}
|
return &mcpFixture{client: mcpClient, answers: answers}
|
||||||
}
|
}
|
||||||
|
|
||||||
// call вызывает инструмент с аргументами и возвращает текст результата.
|
// call вызывает инструмент с аргументами и возвращает текст результата.
|
||||||
@@ -400,12 +410,12 @@ func TestParseConnectURL(t *testing.T) {
|
|||||||
|
|
||||||
// TestNewMCPService — конструктор не требует БД и возвращает сервис.
|
// TestNewMCPService — конструктор не требует БД и возвращает сервис.
|
||||||
func TestNewMCPService(t *testing.T) {
|
func TestNewMCPService(t *testing.T) {
|
||||||
svc := NewMCPService(&fakeGamePlayer{})
|
svc := NewMCPService(&fakeGamePlayer{}, &fakeAnswerSubmitter{})
|
||||||
if svc == nil {
|
if svc == nil {
|
||||||
t.Fatal("NewMCPService() = nil")
|
t.Fatal("NewMCPService() = nil")
|
||||||
}
|
}
|
||||||
if svc.game == nil {
|
if svc.game == nil || svc.answers == nil {
|
||||||
t.Fatal("NewMCPService(): game == nil")
|
t.Fatal("NewMCPService(): missing game or answers dependency")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +440,11 @@ func TestSubmitAnswers(t *testing.T) {
|
|||||||
if !strings.Contains(got, "entrance") {
|
if !strings.Contains(got, "entrance") {
|
||||||
t.Fatalf("submit_answers: %q; want story JSON в ответе", got)
|
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) {
|
func TestSubmitAnswersValidation(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package participation_service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"evening_detective_server/internal/services/answer_service"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -222,14 +223,15 @@ func TestParticipationPostgres(t *testing.T) {
|
|||||||
t.Fatal("cancelled team in active list")
|
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) {
|
if _, _, _, err := gameService.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
|
||||||
t.Fatalf("cancelled team got story: %v", err)
|
t.Fatalf("cancelled team got story: %v", err)
|
||||||
}
|
}
|
||||||
if err := gameService.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) {
|
if err := gameService.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) {
|
||||||
t.Fatalf("cancelled team added action: %v", err)
|
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)
|
t.Fatalf("cancelled team got answers: %v", err)
|
||||||
}
|
}
|
||||||
token6 := participationToken("secret", 6)
|
token6 := participationToken("secret", 6)
|
||||||
|
|||||||
Reference in New Issue
Block a user