Files
evening_detective_server/internal/services/game_service/service.go
T
2026-09-17 02:29:13 +07:00

592 lines
19 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 game_service
import (
"context"
"errors"
"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"
)
type GameService struct {
gamesRepo *games_repo.GamesRepo
teamsRepo *teams_repo.TeamsRepo
scenariosRepo *scenarios_repo.ScenariosRepo
scenarioService *scenarios_service.ScenarioService
domain string
storyteller storytelling.IStory
actionsRepo *actions_repo.ActionsRepo
applicationsRepo *applications_repo.ApplicationsRepo
answersRepo *team_answers_repo.TeamAnswersRepo
cleaner cleaner.ICleaner
}
func NewGameService(
gamesRepo *games_repo.GamesRepo,
teamsRepo *teams_repo.TeamsRepo,
scenariosRepo *scenarios_repo.ScenariosRepo,
scenarioService *scenarios_service.ScenarioService,
domain string,
storyteller storytelling.IStory,
actionsRepo *actions_repo.ActionsRepo,
applicationsRepo *applications_repo.ApplicationsRepo,
answersRepo *team_answers_repo.TeamAnswersRepo,
cleaner cleaner.ICleaner,
) *GameService {
return &GameService{
gamesRepo: gamesRepo,
teamsRepo: teamsRepo,
scenariosRepo: scenariosRepo,
scenarioService: scenarioService,
domain: domain,
storyteller: storyteller,
actionsRepo: actionsRepo,
applicationsRepo: applicationsRepo,
answersRepo: answersRepo,
cleaner: cleaner,
}
}
func (s *GameService) AddGame(
ctx context.Context,
name string,
description string,
startAt time.Time,
scenarioId int32,
format string,
) (int, error) {
if format == "" {
format = "online"
}
if format != "online" && format != "offline" {
return 0, errors.New("Формат игры должен быть online или offline")
}
id, err := s.gamesRepo.AddGame(ctx, name, description, startAt, scenarioId, format)
if err != nil {
return 0, err
}
return id, nil
}
func (s *GameService) GetGame(ctx context.Context, id int) (*game_model.Game, error) {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {
return nil, err
}
res := game_model.MapGame(game, s.domain)
return res, nil
}
func (s *GameService) GetGameMedia(ctx context.Context, id int) (*game_model.Game, error) {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {
return nil, err
}
gameResult := game_model.MapGame(game, s.domain)
gameResult.Scenario, err = s.scenarioService.GetFullScenarioByID(ctx, game.ScenarioId)
if err != nil {
return nil, err
}
return gameResult, nil
}
func (s *GameService) GetFullGame(
ctx context.Context,
id int,
) (*game_model.Game, error) {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {
return nil, err
}
scenario, err := s.scenariosRepo.GetScenarioByID(ctx, game.ScenarioId)
if err != nil {
return nil, err
}
game.Scenario = scenario
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, id)
if err != nil {
return nil, err
}
game.Teams = teams
res := game_model.MapGame(game, s.domain)
teamIds := make([]int, 0, len(game.Teams))
for _, team := range game.Teams {
teamIds = append(teamIds, team.ID)
}
actionsCountByTeamIDs, err := s.actionsRepo.GetActionsCountByTeamIDs(ctx, teamIds)
if err != nil {
return nil, err
}
needApplicationsByTeamIDs, err := s.applicationsRepo.GetApplicationsByTeamIDsAndState(ctx, teamIds, "need")
if err != nil {
return nil, err
}
for _, team := range res.Teams {
team.ActionsCount = actionsCountByTeamIDs[team.ID]
team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID], s.domain)
}
return res, nil
}
func (s *GameService) DeleteGame(
ctx context.Context,
id int,
) error {
return s.gamesRepo.DeleteGameByID(ctx, id)
}
func (s *GameService) GetGames(
ctx context.Context,
) ([]*game_model.Game, error) {
games, err := s.gamesRepo.GetGames(ctx)
if err != nil {
return nil, err
}
return game_model.MapGames(games, s.domain), nil
}
func (s *GameService) UpdateGame(
ctx context.Context,
id int,
name string,
description string,
startAt time.Time,
scenarioId int32,
format string,
) error {
if format == "" {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {
return err
}
format = game.Format
}
if format != "online" && format != "offline" {
return errors.New("Формат игры должен быть online или offline")
}
return s.gamesRepo.UpdateGame(ctx, id, name, description, startAt, scenarioId, format)
}
func (s *GameService) StartGame(ctx context.Context, id int) error {
return s.gamesRepo.StartGame(ctx, id)
}
func (s *GameService) PauseGame(ctx context.Context, id int) error {
return s.gamesRepo.PauseGame(ctx, id)
}
func (s *GameService) PlayGame(ctx context.Context, id int) error {
return s.gamesRepo.PlayGame(ctx, id)
}
func (s *GameService) FinishGame(ctx context.Context, id int) error {
return s.gamesRepo.FinishGame(ctx, id)
}
func (s *GameService) ResetGame(ctx context.Context, id int) error {
return s.gamesRepo.ResetGame(ctx, id)
}
func (s *GameService) GiveTeamApplications(ctx context.Context, teamId int, name string) error {
return s.applicationsRepo.UpdateApplicationStatus(ctx, teamId, name, "done")
}
func (s *GameService) GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) {
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
if err != nil {
return nil, nil, nil, err
}
if team.Password != password || team.ParticipationStatus == "cancelled" {
return nil, nil, nil, teams_repo.ErrTeamNotFound
}
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
if err != nil {
return nil, nil, nil, err
}
if game.Status == "finish" {
return &storytelling.Story{}, game_model.MapGame(game, s.domain), nil, nil
}
actions, err := s.actionsRepo.GetActionsByTeamID(ctx, teamId)
if err != nil {
return nil, nil, nil, err
}
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, game.ScenarioId)
if err != nil {
return nil, nil, nil, err
}
codes := make([]string, len(actions))
for i, action := range actions {
codes[i] = action.Code
}
story := s.storyteller.GetStory(scenario.Story, codes)
filterTeamStory(story, game.Format)
return story, game_model.MapGame(game, s.domain), actions, nil
}
func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password string, actionCode string, viaMCP bool) error {
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
if err != nil {
return err
}
if team.Password != password || team.ParticipationStatus == "cancelled" {
return teams_repo.ErrTeamNotFound
}
_, err = s.actionsRepo.AddAction(ctx, teamId, actionCode, viaMCP)
if err != nil {
return err
}
if team.ParticipationStatus == "pending" {
if err := s.teamsRepo.ConfirmParticipationByFirstAction(ctx, teamId); err != nil {
return err
}
}
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
if err != nil {
return err
}
actionDetails, err := s.actionsRepo.GetActionsByTeamID(ctx, teamId)
if err != nil {
return err
}
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, game.ScenarioId)
if err != nil {
return err
}
actions := make([]string, len(actionDetails))
for i, action := range actionDetails {
actions[i] = action.Code
}
teamStory := s.storyteller.GetStory(scenario.Story, actions)
lastPlace := teamStory.Places[len(teamStory.Places)-1]
for _, application := range lastPlace.Applications {
app := newApplication(application, s.domain)
app.Name = s.cleaner.ClearText(app.Name)
if _, err := s.applicationsRepo.AddApplication(ctx, teamId, app.Name, app.Image, app.FileType); err != nil {
return err
}
}
return nil
}
func (s *GameService) DeleteLastTeamAction(ctx context.Context, teamId 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
}
actionDetails, err := s.actionsRepo.GetActionsByTeamID(ctx, teamId)
if err != nil {
return err
}
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, game.ScenarioId)
if err != nil {
return err
}
actions := make([]string, len(actionDetails))
for i, action := range actionDetails {
actions[i] = action.Code
}
teamStory := s.storyteller.GetStory(scenario.Story, actions)
lastPlace := teamStory.Places[len(teamStory.Places)-1]
for _, application := range lastPlace.Applications {
if err := s.applicationsRepo.DeleteApplicationByTeamIdAndName(ctx, teamId, application.Name); err != nil {
return err
}
}
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
}