add questions

This commit is contained in:
2026-09-01 01:01:37 +07:00
parent 4431a4d491
commit 69666ee264
23 changed files with 4151 additions and 368 deletions
+102 -1
View File
@@ -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) {
+63 -3
View File
@@ -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)
}
}