generated from VLADIMIR/template
add questions
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
proto "evening_detective_server/proto"
|
||||
)
|
||||
|
||||
func mapAnswers(o []game_service.Answer) []*proto.Answer {
|
||||
res := make([]*proto.Answer, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, &proto.Answer{
|
||||
QuestionCode: item.QuestionCode,
|
||||
Answer: item.Text,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func convertAnswers(o []*proto.Answer) []game_service.Answer {
|
||||
res := make([]game_service.Answer, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, game_service.Answer{
|
||||
QuestionCode: item.QuestionCode,
|
||||
Text: item.Answer,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mapAnswerScores(o []game_service.AnswerScore) []*proto.AnswerScore {
|
||||
res := make([]*proto.AnswerScore, 0, len(o))
|
||||
for _, item := range o {
|
||||
score := &proto.AnswerScore{
|
||||
QuestionCode: item.QuestionCode,
|
||||
Answer: item.Text,
|
||||
}
|
||||
if item.Score != nil {
|
||||
v := int32(*item.Score)
|
||||
score.Score = &v
|
||||
}
|
||||
res = append(res, score)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mapTeamAnswers(o []game_service.TeamAnswers) []*proto.TeamAnswers {
|
||||
res := make([]*proto.TeamAnswers, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, &proto.TeamAnswers{
|
||||
Team: mapTeam(item.Team),
|
||||
Answers: mapAnswerScores(item.Answers),
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -599,6 +599,73 @@ func (s *server) DeleteScenarioPlace(ctx context.Context, req *proto.DeleteScena
|
||||
return &proto.DeleteScenarioPlaceRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) AddScenarioQuestion(ctx context.Context, req *proto.AddScenarioQuestionReq) (*proto.AddScenarioQuestionRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
err := s.scenarioService.AddScenarioQuestion(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
convertQuestion(req.Question),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.AddScenarioQuestionRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.AddScenarioQuestionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) UpdateScenarioQuestion(ctx context.Context, req *proto.UpdateScenarioQuestionReq) (*proto.UpdateScenarioQuestionRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
err := s.scenarioService.UpdateScenarioQuestion(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Code,
|
||||
convertQuestion(req.Question),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.UpdateScenarioQuestionRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.UpdateScenarioQuestionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) DeleteScenarioQuestion(ctx context.Context, req *proto.DeleteScenarioQuestionReq) (*proto.DeleteScenarioQuestionRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
err := s.scenarioService.DeleteScenarioQuestion(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Code,
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.DeleteScenarioQuestionRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.DeleteScenarioQuestionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) UpdateScenarioIntro(ctx context.Context, req *proto.UpdateScenarioIntroReq) (*proto.UpdateScenarioIntroRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
@@ -762,6 +829,25 @@ func (s *server) GetGame(ctx context.Context, req *proto.GetGameReq) (*proto.Get
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGameAnswers(ctx context.Context, req *proto.GetGameAnswersReq) (*proto.GetGameAnswersRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
gameAnswers, err := s.gameService.GetGameAnswers(ctx, int(req.GameId))
|
||||
if err != nil {
|
||||
return &proto.GetGameAnswersRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.GetGameAnswersRsp{
|
||||
Questions: mapQuestions(gameAnswers.Questions),
|
||||
Teams: mapTeamAnswers(gameAnswers.Teams),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGames(ctx context.Context, req *proto.GetGamesReq) (*proto.GetGamesRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
@@ -1022,3 +1108,63 @@ func (s *server) DeleteLastTeamAction(ctx context.Context, req *proto.DeleteLast
|
||||
|
||||
return &proto.DeleteLastTeamActionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersReq) (*proto.GetTeamAnswersRsp, error) {
|
||||
answers, err := s.gameService.GetTeamAnswers(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Password,
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.GetTeamAnswersRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.GetTeamAnswersRsp{
|
||||
Answers: mapAnswers(answers),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) SubmitTeamAnswers(ctx context.Context, req *proto.SubmitTeamAnswersReq) (*proto.SubmitTeamAnswersRsp, error) {
|
||||
err := s.gameService.SubmitTeamAnswers(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Password,
|
||||
convertAnswers(req.Answers),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.SubmitTeamAnswersRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.SubmitTeamAnswersRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) SetTeamAnswerScore(ctx context.Context, req *proto.SetTeamAnswerScoreReq) (*proto.SetTeamAnswerScoreRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
var score *int
|
||||
if req.Score != nil {
|
||||
v := int(req.GetScore())
|
||||
score = &v
|
||||
}
|
||||
|
||||
err := s.gameService.SetTeamAnswerScore(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.QuestionCode,
|
||||
score,
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.SetTeamAnswerScoreRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.SetTeamAnswerScoreRsp{}, nil
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func mapStory(o *storytelling.Story) *proto.Story {
|
||||
return &proto.Story{
|
||||
Introduction: mapIntroduction(o.Introduction),
|
||||
Places: mapPlaces(o.Places),
|
||||
Questions: mapQuestions(o.Questions),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,3 +198,33 @@ func convertIntroduction(o *proto.Introduction) *storytelling.Introduction {
|
||||
Audio: o.Audio,
|
||||
}
|
||||
}
|
||||
|
||||
func mapQuestions(o []*storytelling.Question) []*proto.Question {
|
||||
res := make([]*proto.Question, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, mapQuestion(item))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mapQuestion(o *storytelling.Question) *proto.Question {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &proto.Question{
|
||||
Code: o.Code,
|
||||
Text: o.Text,
|
||||
Answer: o.Answer,
|
||||
}
|
||||
}
|
||||
|
||||
func convertQuestion(o *proto.Question) *storytelling.Question {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &storytelling.Question{
|
||||
Code: o.Code,
|
||||
Text: textFormatter.FormatText(o.Text),
|
||||
Answer: textFormatter.FormatText(o.Answer),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ type IStory interface {
|
||||
GetStory(scenario *Story, codes []string) *Story
|
||||
}
|
||||
|
||||
// История - это введение и набор точек
|
||||
// История - это введение, набор точек и вопросы дела
|
||||
type Story struct {
|
||||
|
||||
// Введение - то, что игрок получает в начале игры
|
||||
@@ -12,6 +12,24 @@ type Story struct {
|
||||
|
||||
// Список точек
|
||||
Places []*Place `json:"places"`
|
||||
|
||||
// Вопросы дела - список вопросов, на которые команда должна ответить
|
||||
Questions []*Question `json:"questions,omitempty"`
|
||||
}
|
||||
|
||||
// Вопрос дела - вопрос, который автор сценария добавляет вместе с
|
||||
// правильным ответом; команда отвечает на вопросы в конце игры
|
||||
type Question struct {
|
||||
|
||||
// Код - идентификатор вопроса, используется при отправке ответов
|
||||
Code string `json:"code"`
|
||||
|
||||
// Текст вопроса
|
||||
Text string `json:"text"`
|
||||
|
||||
// Правильный ответ - виден только автору и организаторам,
|
||||
// команде не передаётся
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
// Введение - текст и аудио, которые игрок получает в начале игры
|
||||
|
||||
@@ -29,6 +29,15 @@ func (s *story) GetStory(
|
||||
Audio: scenario.Introduction.Audio,
|
||||
}
|
||||
}
|
||||
// Вопросы дела передаются команде всегда — с начала игры; правильные
|
||||
// ответы (Answer) команде не передаются: они нужны только автору и
|
||||
// организаторам при оценке ответов.
|
||||
for _, question := range scenario.Questions {
|
||||
story.Questions = append(story.Questions, &Question{
|
||||
Code: question.Code,
|
||||
Text: s.cleaner.ClearText(question.Text),
|
||||
})
|
||||
}
|
||||
for i, code := range codes {
|
||||
var prevPlace *Place
|
||||
if i > 0 {
|
||||
|
||||
@@ -807,3 +807,113 @@ func Test_story_GetStory(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_story_GetStoryQuestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scenario *Story
|
||||
codes []string
|
||||
want *Story
|
||||
}{
|
||||
{
|
||||
name: "Вопросы передаются команде с начала игры (без действий)",
|
||||
scenario: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
Answer: "Дворецкий",
|
||||
},
|
||||
},
|
||||
},
|
||||
codes: []string{},
|
||||
want: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Правильные ответы не передаются команде",
|
||||
scenario: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1.([секрет])",
|
||||
Answer: "Ответ 1",
|
||||
},
|
||||
{
|
||||
Code: "q2",
|
||||
Text: "Вопрос 2",
|
||||
Answer: "Ответ 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
codes: []string{},
|
||||
want: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1.",
|
||||
},
|
||||
{
|
||||
Code: "q2",
|
||||
Text: "Вопрос 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Вопросы передаются вместе с точками и введением",
|
||||
scenario: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Введение.",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
Places: []*Place{
|
||||
{
|
||||
Code: "Ы",
|
||||
Name: "Название точки",
|
||||
Text: "Текст точки.",
|
||||
},
|
||||
},
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1",
|
||||
Answer: "Ответ 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
codes: []string{"Ы"},
|
||||
want: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Введение.",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
Places: []*Place{
|
||||
{
|
||||
Code: "Ы",
|
||||
Name: "Название точки",
|
||||
Text: "Текст точки.",
|
||||
},
|
||||
},
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := NewStory(cleaner.NewCleaner()).GetStory(tt.scenario, tt.codes)
|
||||
assert.Equal(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package repos
|
||||
|
||||
// TeamAnswer — ответ команды на вопрос дела (вопросы живут в сценарии,
|
||||
// ответы — в таблице team_answers).
|
||||
type TeamAnswer struct {
|
||||
TeamId int
|
||||
QuestionCode string
|
||||
Text string
|
||||
// Score — балл, выставленный организатором; nil — ещё не оценено.
|
||||
Score *int
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package team_answers_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"evening_detective_server/internal/repos"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAnswerNotFound = errors.New("Ответ не найден")
|
||||
)
|
||||
|
||||
type TeamAnswersRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTeamAnswersRepo(pool *pgxpool.Pool) *TeamAnswersRepo {
|
||||
return &TeamAnswersRepo{
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
// UpsertAnswers сохраняет ответы команды: существующие перезаписываются,
|
||||
// отсутствующие — вставляются (UNIQUE (team_id, question_code)).
|
||||
func (r *TeamAnswersRepo) UpsertAnswers(
|
||||
ctx context.Context,
|
||||
teamId int,
|
||||
answers []repos.TeamAnswer,
|
||||
) error {
|
||||
for _, answer := range answers {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`INSERT INTO team_answers (team_id, question_code, answer)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (team_id, question_code)
|
||||
DO UPDATE SET
|
||||
answer = EXCLUDED.answer,
|
||||
updated_at = NOW()`,
|
||||
teamId,
|
||||
answer.QuestionCode,
|
||||
answer.Text,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TeamAnswersRepo) GetAnswersByTeamID(
|
||||
ctx context.Context,
|
||||
teamId int,
|
||||
) ([]*repos.TeamAnswer, error) {
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
`SELECT
|
||||
team_id,
|
||||
question_code,
|
||||
answer,
|
||||
score
|
||||
FROM team_answers
|
||||
WHERE team_id = $1
|
||||
ORDER BY question_code ASC`,
|
||||
teamId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var answers []*repos.TeamAnswer
|
||||
for rows.Next() {
|
||||
answer := &repos.TeamAnswer{}
|
||||
err := rows.Scan(
|
||||
&answer.TeamId,
|
||||
&answer.QuestionCode,
|
||||
&answer.Text,
|
||||
&answer.Score,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, answer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// GetAnswersByGameID возвращает ответы всех команд игры (вместе с баллами).
|
||||
func (r *TeamAnswersRepo) GetAnswersByGameID(
|
||||
ctx context.Context,
|
||||
gameId int,
|
||||
) ([]*repos.TeamAnswer, error) {
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
`SELECT
|
||||
teams.id,
|
||||
team_answers.question_code,
|
||||
team_answers.answer,
|
||||
team_answers.score
|
||||
FROM team_answers
|
||||
JOIN teams ON teams.id = team_answers.team_id
|
||||
WHERE teams.game_id = $1
|
||||
ORDER BY teams.id ASC, team_answers.question_code ASC`,
|
||||
gameId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var answers []*repos.TeamAnswer
|
||||
for rows.Next() {
|
||||
answer := &repos.TeamAnswer{}
|
||||
err := rows.Scan(
|
||||
&answer.TeamId,
|
||||
&answer.QuestionCode,
|
||||
&answer.Text,
|
||||
&answer.Score,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, answer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// SetAnswerScore выставляет балл за ответ; score == nil сбрасывает балл.
|
||||
func (r *TeamAnswersRepo) SetAnswerScore(
|
||||
ctx context.Context,
|
||||
teamId int,
|
||||
questionCode string,
|
||||
score *int,
|
||||
) error {
|
||||
tag, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE team_answers
|
||||
SET
|
||||
score = $1,
|
||||
updated_at = NOW()
|
||||
WHERE team_id = $2 AND question_code = $3`,
|
||||
score,
|
||||
teamId,
|
||||
questionCode,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAnswerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package game_service
|
||||
|
||||
import "evening_detective_server/internal/modules/storytelling"
|
||||
|
||||
// Answer — ответ команды на вопрос дела (для публичного API команды).
|
||||
type Answer struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
}
|
||||
|
||||
// AnswerScore — ответ команды с баллом организатора (для панели оценки).
|
||||
type AnswerScore struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
// Score — балл; nil — ещё не оценено.
|
||||
Score *int
|
||||
}
|
||||
|
||||
// TeamAnswers — ответы одной команды игры.
|
||||
type TeamAnswers struct {
|
||||
Team *Team
|
||||
Answers []AnswerScore
|
||||
}
|
||||
|
||||
// GameAnswers — полная картина для оценки организатором: вопросы сценария
|
||||
// (с правильными ответами) и ответы всех команд с баллами.
|
||||
type GameAnswers struct {
|
||||
Questions []*storytelling.Question
|
||||
Teams []TeamAnswers
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package game_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/password_generator"
|
||||
"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"
|
||||
@@ -24,6 +27,7 @@ type GameService struct {
|
||||
storyteller storytelling.IStory
|
||||
actionsRepo *actions_repo.ActionsRepo
|
||||
applicationsRepo *applications_repo.ApplicationsRepo
|
||||
answersRepo *team_answers_repo.TeamAnswersRepo
|
||||
cleaner cleaner.ICleaner
|
||||
}
|
||||
|
||||
@@ -37,6 +41,7 @@ func NewGameService(
|
||||
storyteller storytelling.IStory,
|
||||
actionsRepo *actions_repo.ActionsRepo,
|
||||
applicationsRepo *applications_repo.ApplicationsRepo,
|
||||
answersRepo *team_answers_repo.TeamAnswersRepo,
|
||||
cleaner cleaner.ICleaner,
|
||||
) *GameService {
|
||||
return &GameService{
|
||||
@@ -49,6 +54,7 @@ func NewGameService(
|
||||
storyteller: storyteller,
|
||||
actionsRepo: actionsRepo,
|
||||
applicationsRepo: applicationsRepo,
|
||||
answersRepo: answersRepo,
|
||||
cleaner: cleaner,
|
||||
}
|
||||
}
|
||||
@@ -306,3 +312,175 @@ 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("Вопрос не найден")
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
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.GetTeamsByGameID(ctx, gameId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.answersRepo.GetAnswersByGameID(ctx, gameId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answersByTeam := make(map[int][]AnswerScore, len(teams))
|
||||
for _, row := range rows {
|
||||
if _, ok := byCode[row.QuestionCode]; !ok {
|
||||
continue
|
||||
}
|
||||
score := row.Score
|
||||
answersByTeam[row.TeamId] = append(answersByTeam[row.TeamId], AnswerScore{
|
||||
QuestionCode: row.QuestionCode,
|
||||
Text: row.Text,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
teamAnswers := make([]TeamAnswers, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
teamAnswers = append(teamAnswers, TeamAnswers{
|
||||
Team: mapTeam(team),
|
||||
Answers: answersByTeam[team.ID],
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const defaultCallTimeout = 10 * time.Second
|
||||
type GamePlayer interface {
|
||||
GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_service.Game, error)
|
||||
AddTeamAction(ctx context.Context, teamId int, password, actionCode string) error
|
||||
SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []game_service.Answer) error
|
||||
}
|
||||
|
||||
// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не
|
||||
@@ -52,7 +53,7 @@ func (s *MCPService) Server() *server.MCPServer {
|
||||
srv := server.NewMCPServer(
|
||||
"evening-detective-mcp",
|
||||
"0.1.0",
|
||||
server.WithInstructions("Инструменты для игры «Вечерний детектив»: подключитесь к игре по ссылке (connect) — организатор выдаёт ссылку вида /team-story/{id}?password=..., — затем смотрите историю команды (get_team_story) и делайте ходы (make_move) по кодам точек сценария."),
|
||||
server.WithInstructions("Инструменты для игры «Вечерний детектив»: подключитесь к игре по ссылке (connect) — организатор выдаёт ссылку вида /team-story/{id}?password=..., — затем смотрите историю команды (get_team_story), делайте ходы (make_move) по кодам точек сценария и отвечайте на вопросы дела (submit_answers)."),
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
@@ -85,6 +86,34 @@ func (s *MCPService) Server() *server.MCPServer {
|
||||
s.handleMakeMove,
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
mcp.NewToolWithRawSchema(
|
||||
"submit_answers",
|
||||
"Отправить ответы команды на вопросы дела (список вопросов — в истории команды через get_team_story). Работает, пока игра идёт; после завершения игры ответы изменить нельзя. После успешной отправки возвращает обновлённую историю команды. Команда идентифицируется паролем.",
|
||||
json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "number", "description": "ID команды"},
|
||||
"password": {"type": "string", "description": "Пароль команды"},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"description": "Ответы команды на вопросы дела; переданные коды перезаписываются, пустая строка очищает ответ",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questionCode": {"type": "string", "description": "Код вопроса из истории команды"},
|
||||
"answer": {"type": "string", "description": "Текст ответа команды"}
|
||||
},
|
||||
"required": ["questionCode", "answer"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["team_id", "password", "answers"]
|
||||
}`),
|
||||
),
|
||||
s.handleSubmitAnswers,
|
||||
)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -248,6 +277,78 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// submitAnswersArgs — разобранные аргументы инструмента submit_answers.
|
||||
type submitAnswersArgs struct {
|
||||
teamID int64
|
||||
password string
|
||||
answers []game_service.Answer
|
||||
}
|
||||
|
||||
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
|
||||
// answers — массив объектов {questionCode, answer} (JSON-массив приходит
|
||||
// как []any с map[string]any элементами). Ключ questionCode совпадает с
|
||||
// REST JSON (camelCase) — единый формат для клиентов.
|
||||
func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
|
||||
teamID, ok := argInt64(args["team_id"])
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется team_id (число)")
|
||||
}
|
||||
password, _ := args["password"].(string)
|
||||
if password == "" {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется password")
|
||||
}
|
||||
rawAnswers, ok := args["answers"].([]any)
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется answers (массив ответов)")
|
||||
}
|
||||
answers := make([]game_service.Answer, 0, len(rawAnswers))
|
||||
for i, raw := range rawAnswers {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: ожидается объект {questionCode, answer}", i)
|
||||
}
|
||||
questionCode, _ := item["questionCode"].(string)
|
||||
answerText, _ := item["answer"].(string)
|
||||
if questionCode == "" {
|
||||
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i)
|
||||
}
|
||||
answers = append(answers, game_service.Answer{
|
||||
QuestionCode: questionCode,
|
||||
Text: answerText,
|
||||
})
|
||||
}
|
||||
return submitAnswersArgs{
|
||||
teamID: teamID,
|
||||
password: password,
|
||||
answers: answers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, err := parseSubmitAnswersArgs(req.GetArguments())
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := s.game.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
story, game, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
jsonBody, err := storyJSON(story, game)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// argInt64 достаёт int64 из аргумента инструмента: числа приходят как
|
||||
// float64 (JSON), строки — как string.
|
||||
func argInt64(v any) (int64, bool) {
|
||||
|
||||
@@ -36,6 +36,13 @@ 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 {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultStory() *storytelling.Story {
|
||||
return &storytelling.Story{
|
||||
Introduction: &storytelling.Introduction{
|
||||
@@ -136,10 +143,10 @@ func (f *mcpFixture) toolNames(t *testing.T) []string {
|
||||
|
||||
func assertExactTools(t *testing.T, names []string) {
|
||||
t.Helper()
|
||||
if len(names) != 3 {
|
||||
t.Fatalf("tools = %v; want ровно 3 инструмента", names)
|
||||
if len(names) != 4 {
|
||||
t.Fatalf("tools = %v; want ровно 4 инструмента", names)
|
||||
}
|
||||
for _, want := range []string{"connect", "get_team_story", "make_move"} {
|
||||
for _, want := range []string{"connect", "get_team_story", "make_move", "submit_answers"} {
|
||||
found := false
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
@@ -400,3 +407,56 @@ func TestDefaultCallTimeout(t *testing.T) {
|
||||
t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswers(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "submit_answers", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "team-pass-1",
|
||||
"answers": []any{
|
||||
map[string]any{"questionCode": "q1", "answer": "Дворецкий"},
|
||||
map[string]any{"questionCode": "q2", "answer": ""},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(got, "entrance") {
|
||||
t.Fatalf("submit_answers: %q; want story JSON в ответе", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswersValidation(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{"нет team_id", map[string]any{"password": "x", "answers": []any{}}, "team_id"},
|
||||
{"нет password", map[string]any{"team_id": float64(1), "answers": []any{}}, "password"},
|
||||
{"нет answers", map[string]any{"team_id": float64(1), "password": "x"}, "answers"},
|
||||
{"элемент не объект", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{"str"}}, "answers[0]"},
|
||||
{"нет questionCode", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{map[string]any{"answer": "x"}}}, "questionCode"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := f.call(t, "submit_answers", tc.args)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("submit_answers: %q; want подстроку %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswersBusinessError(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("Игра завершена, ответы больше нельзя изменять")})
|
||||
|
||||
got := f.call(t, "submit_answers", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "wrong",
|
||||
"answers": []any{map[string]any{"questionCode": "q1", "answer": "x"}},
|
||||
})
|
||||
if !strings.Contains(got, "Игра завершена") {
|
||||
t.Fatalf("submit_answers с ошибкой сервиса: %q; want error text", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,75 @@ func (s *ScenarioService) UpdateScenarioIntro(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// AddScenarioQuestion добавляет вопрос дела в конец списка вопросов сценария.
|
||||
func (s *ScenarioService) AddScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
question *storytelling.Question,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
story, err := s.getStory(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
story.Questions = append(story.Questions, question)
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioQuestion обновляет вопрос дела по его коду. Смена кода
|
||||
// оставляет строки team_answers старого кода в БД
|
||||
func (s *ScenarioService) UpdateScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
code string,
|
||||
question *storytelling.Question,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
story, err := s.getStory(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range story.Questions {
|
||||
if story.Questions[i].Code == code {
|
||||
story.Questions[i] = question
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// DeleteScenarioQuestion удаляет вопрос дела по его коду.
|
||||
func (s *ScenarioService) DeleteScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
code string,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
story, err := s.getStory(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range story.Questions {
|
||||
if story.Questions[i].Code == code {
|
||||
story.Questions = append(story.Questions[:i], story.Questions[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.Story, error) {
|
||||
storyString, err := s.scenariosRepo.GetStoryByScenarioID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -432,7 +501,6 @@ func (s *ScenarioService) UploadArchive(
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
|
||||
for _, place := range story.Places {
|
||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
||||
@@ -478,6 +546,23 @@ func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
}
|
||||
story.Places = cleanPlaces
|
||||
|
||||
// Коды вопросов уникальны, пустые коды отбрасываются (как у точек).
|
||||
questionCodes := map[string]struct{}{}
|
||||
for _, question := range story.Questions {
|
||||
questionCodes[question.Code] = struct{}{}
|
||||
}
|
||||
if len(questionCodes) != len(story.Questions) {
|
||||
return "", errors.New("Такой код вопроса уже существует")
|
||||
}
|
||||
cleanQuestions := make([]*storytelling.Question, 0, len(story.Questions))
|
||||
for _, question := range story.Questions {
|
||||
if question.Code == "" {
|
||||
continue
|
||||
}
|
||||
cleanQuestions = append(cleanQuestions, question)
|
||||
}
|
||||
story.Questions = cleanQuestions
|
||||
|
||||
return convertStory(story)
|
||||
}
|
||||
|
||||
|
||||
@@ -686,3 +686,132 @@ func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Добавление вопроса автором.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
Answer: "Дворецкий",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion: %v", err)
|
||||
}
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q2",
|
||||
Text: "Орудие?",
|
||||
Answer: "Канделябр",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion: %v", err)
|
||||
}
|
||||
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
if len(full.Story.Questions) != 2 {
|
||||
t.Fatalf("len(Questions) = %d, want 2", len(full.Story.Questions))
|
||||
}
|
||||
// Вопросы добавляются в конец списка: q1 добавлен первым.
|
||||
if full.Story.Questions[0].Code != "q1" || full.Story.Questions[0].Answer != "Дворецкий" {
|
||||
t.Errorf("Questions[0] = %+v, want q1 с правильным ответом", full.Story.Questions[0])
|
||||
}
|
||||
if full.Story.Questions[1].Code != "q2" || full.Story.Questions[1].Answer != "Канделябр" {
|
||||
t.Errorf("Questions[1] = %+v, want q2 с правильным ответом", full.Story.Questions[1])
|
||||
}
|
||||
|
||||
// Обновление вопроса по коду.
|
||||
err = svc.UpdateScenarioQuestion(context.Background(), 1, "q1", &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца на самом деле?",
|
||||
Answer: "Экономка",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioQuestion: %v", err)
|
||||
}
|
||||
full, err = svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, q := range full.Story.Questions {
|
||||
if q.Code == "q1" {
|
||||
found = true
|
||||
if q.Text != "Кто убийца на самом деле?" || q.Answer != "Экономка" {
|
||||
t.Errorf("q1 = %+v, want обновлённый вопрос", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("вопрос q1 не найден после обновления")
|
||||
}
|
||||
|
||||
// Удаление вопроса.
|
||||
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q2", 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteScenarioQuestion: %v", err)
|
||||
}
|
||||
full, err = svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
if len(full.Story.Questions) != 1 || full.Story.Questions[0].Code != "q1" {
|
||||
t.Errorf("Questions = %+v, want только q1", full.Story.Questions)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять вопросы.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q3"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("AddScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q1", 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("DeleteScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
|
||||
// Админ может менять вопросы чужого сценария.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q3", Text: "Вопрос админа",
|
||||
}, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion админ: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoryQuestionCodes(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Дубликат кода вопроса — ошибка.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion q1: %v", err)
|
||||
}
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "Такой код вопроса уже существует") {
|
||||
t.Errorf("AddScenarioQuestion с дублем кода: err = %v, want ошибку о дубле", err)
|
||||
}
|
||||
|
||||
// Пустой код — вопрос отбрасывается при сохранении.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "", Text: "без кода"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion с пустым кодом: %v", err)
|
||||
}
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
for _, q := range full.Story.Questions {
|
||||
if q.Code == "" {
|
||||
t.Error("вопрос с пустым кодом сохранился")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user