add AnswerService

This commit is contained in:
2026-09-17 02:38:05 +07:00
parent c70d839038
commit 87bd07019a
18 changed files with 448 additions and 304 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ import (
// Проверяются initialize, tools/list (точное множество) и tools/call
// connect — как это будет делать внешний MCP-клиент.
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())
srv := httptest.NewServer(mcpHTTP)
+14 -9
View File
@@ -17,7 +17,7 @@ import (
"evening_detective_server/internal/modules/storytelling"
"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/server"
@@ -33,7 +33,11 @@ const defaultCallTimeout = 10 * time.Second
type GamePlayer interface {
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
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-сервер с игровыми инструментами. Сервис не
@@ -41,12 +45,13 @@ type GamePlayer interface {
// пароль явно — либо из ссылки на игру через инструмент connect, либо
// напрямую.
type MCPService struct {
game GamePlayer
game GamePlayer
answers AnswerSubmitter
}
// NewMCPService создаёт сервис поверх игровых сервисов.
func NewMCPService(game GamePlayer) *MCPService {
return &MCPService{game: game}
func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService {
return &MCPService{game: game, answers: answers}
}
// Server собирает и возвращает MCP-сервер с зарегистрированными
@@ -283,7 +288,7 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest
type submitAnswersArgs struct {
teamID int64
password string
answers []game_service.Answer
answers []answer_service.Answer
}
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
@@ -303,7 +308,7 @@ func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
if !ok {
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 {
item, ok := raw.(map[string]any)
if !ok {
@@ -314,7 +319,7 @@ func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
if questionCode == "" {
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i)
}
answers = append(answers, game_service.Answer{
answers = append(answers, answer_service.Answer{
QuestionCode: questionCode,
Text: answerText,
})
@@ -335,7 +340,7 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
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
}
+24 -9
View File
@@ -10,7 +10,7 @@ import (
"evening_detective_server/internal/modules/storytelling"
"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/mcp"
@@ -40,7 +40,15 @@ 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 {
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 {
return f.err
}
@@ -77,13 +85,15 @@ func defaultGame() *game_model.Game {
// mcpFixture поднимает in-process MCP-клиент над сервисом с моком
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
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()
svc := NewMCPService(player)
answers := &fakeAnswerSubmitter{err: player.err}
svc := NewMCPService(player, answers)
mcpClient, err := client.NewInProcessClient(svc.Server())
if err != nil {
@@ -105,7 +115,7 @@ func newMCPFixture(t *testing.T, player GamePlayer) *mcpFixture {
t.Fatalf("client.Initialize() error = %v", err)
}
return &mcpFixture{client: mcpClient}
return &mcpFixture{client: mcpClient, answers: answers}
}
// call вызывает инструмент с аргументами и возвращает текст результата.
@@ -400,12 +410,12 @@ func TestParseConnectURL(t *testing.T) {
// TestNewMCPService — конструктор не требует БД и возвращает сервис.
func TestNewMCPService(t *testing.T) {
svc := NewMCPService(&fakeGamePlayer{})
svc := NewMCPService(&fakeGamePlayer{}, &fakeAnswerSubmitter{})
if svc == nil {
t.Fatal("NewMCPService() = nil")
}
if svc.game == nil {
t.Fatal("NewMCPService(): game == nil")
if svc.game == nil || svc.answers == nil {
t.Fatal("NewMCPService(): missing game or answers dependency")
}
}
@@ -430,6 +440,11 @@ func TestSubmitAnswers(t *testing.T) {
if !strings.Contains(got, "entrance") {
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) {