Files
evening_detective_server/internal/services/answer_service/service.go
T
2026-10-01 13:13:03 +07:00

304 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
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
}
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})
}
mappedTeam := game_model.MapTeam(team)
mappedTeam.ActionsCount = actionsCountByTeamIDs[team.ID]
teamAnswers = append(teamAnswers, TeamAnswers{
Team: mappedTeam,
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
}