generated from VLADIMIR/template
add intro
This commit is contained in:
@@ -67,7 +67,7 @@ func (s *MCPService) Server() *server.MCPServer {
|
||||
srv.AddTool(
|
||||
mcp.NewTool(
|
||||
"get_team_story",
|
||||
mcp.WithDescription("Получить текущую историю команды: видимые точки сценария (текст, двери, улики) и информацию об игре. Команда идентифицируется паролем."),
|
||||
mcp.WithDescription("Получить текущую историю команды: введение сценария (текст и аудио), видимые точки сценария (текст, двери, улики) и информацию об игре. Команда идентифицируется паролем."),
|
||||
mcp.WithNumber("team_id", mcp.Required(), mcp.Description("ID команды")),
|
||||
mcp.WithString("password", mcp.Required(), mcp.Description("Пароль команды")),
|
||||
),
|
||||
|
||||
@@ -38,6 +38,10 @@ func (f *fakeGamePlayer) AddTeamAction(_ context.Context, teamID int, password,
|
||||
|
||||
func defaultStory() *storytelling.Story {
|
||||
return &storytelling.Story{
|
||||
Introduction: &storytelling.Introduction{
|
||||
Text: "Добро пожаловать в особняк.",
|
||||
Audio: "http://storage.test/api/files/intro.mp3",
|
||||
},
|
||||
Places: []*storytelling.Place{
|
||||
{
|
||||
Code: "entrance",
|
||||
@@ -254,6 +258,9 @@ func TestGetTeamStory(t *testing.T) {
|
||||
if !strings.Contains(got, "entrance") {
|
||||
t.Fatalf("get_team_story: %q; want story JSON", got)
|
||||
}
|
||||
if !strings.Contains(got, `"introduction"`) || !strings.Contains(got, "Добро пожаловать в особняк.") {
|
||||
t.Fatalf("get_team_story: %q; want introduction in story JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTeamStoryBusinessError(t *testing.T) {
|
||||
|
||||
@@ -77,6 +77,9 @@ func mapStory(o string, domain string) (*storytelling.Story, error) {
|
||||
place.Image = domain + place.Image
|
||||
}
|
||||
}
|
||||
if res.Introduction != nil && res.Introduction.Audio != "" {
|
||||
res.Introduction.Audio = domain + res.Introduction.Audio
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,25 @@ func (s *ScenarioService) DeleteScenarioPlace(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioIntro сохраняет введение сценария (текст и аудио).
|
||||
func (s *ScenarioService) UpdateScenarioIntro(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
introduction *storytelling.Introduction,
|
||||
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.Introduction = introduction
|
||||
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 {
|
||||
@@ -385,6 +404,12 @@ func (s *ScenarioService) UploadArchive(
|
||||
}
|
||||
}
|
||||
}
|
||||
if scenario.Story.Introduction != nil {
|
||||
scenario.Story.Introduction.Audio, err = rewrite(scenario.Story.Introduction.Audio)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
storyJSON, err := normalizeStory(scenario.Story)
|
||||
if err != nil {
|
||||
@@ -423,6 +448,9 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
|
||||
for _, place := range story.Places {
|
||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
||||
}
|
||||
if story.Introduction != nil {
|
||||
story.Introduction.Audio = strings.TrimPrefix(story.Introduction.Audio, s.domain)
|
||||
}
|
||||
storyString, err := normalizeStory(story)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -338,6 +338,76 @@ func TestUploadArchive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveWithIntro(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
srcStorage := newFakeStorage()
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Author: &repos.User{ID: 7},
|
||||
Status: "draft",
|
||||
Scenario: `{"introduction":{"text":"Введение","audio":"intro.mp3"},"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
repo.byID[scenario.ID] = scenario
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "intro.mp3", Data: []byte("intro-audio-bytes")})
|
||||
|
||||
// Экспорт (Pack кладёт аудио введения в архив), затем импорт в «чистые»
|
||||
// repo и storage: ссылки должны быть переписаны на новые имена.
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
|
||||
importRepo := newFakeScenariosRepo()
|
||||
importStorage := newFakeStorage()
|
||||
importSvc := newTestService(importRepo, importStorage)
|
||||
|
||||
id, err := importSvc.UploadArchive(context.Background(), archive, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
|
||||
saved := importRepo.get(id)
|
||||
story := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
|
||||
t.Fatalf("story не разобрался: %v", err)
|
||||
}
|
||||
if story.Introduction == nil {
|
||||
t.Fatal("Introduction = nil, want введение после импорта")
|
||||
}
|
||||
if story.Introduction.Text != "Введение" {
|
||||
t.Errorf("Introduction.Text = %q, want %q", story.Introduction.Text, "Введение")
|
||||
}
|
||||
if story.Introduction.Audio == "" || strings.HasPrefix(story.Introduction.Audio, "images/") || story.Introduction.Audio == "intro.mp3" {
|
||||
t.Errorf("Introduction.Audio = %q, want новое случайное имя без images/", story.Introduction.Audio)
|
||||
}
|
||||
|
||||
// Аудио введения загружено в storage под новым именем.
|
||||
files := importStorage.names()
|
||||
found := false
|
||||
for _, name := range files {
|
||||
file, err := importStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
if string(file.Data) == "intro-audio-bytes" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("аудио введения не загружено в storage; файлы: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveMissingImage(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
@@ -509,6 +579,85 @@ func TestDownloadArchiveMissingImage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateScenarioIntro(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Установка введения автором.
|
||||
err := svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{
|
||||
Text: "Введение",
|
||||
Audio: "intro.mp3",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioIntro: %v", err)
|
||||
}
|
||||
|
||||
// В хранилище — голое имя аудио, без домена.
|
||||
parsed := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Introduction == nil {
|
||||
t.Fatal("Introduction = nil, want введение в сохранённой истории")
|
||||
}
|
||||
if parsed.Introduction.Text != "Введение" {
|
||||
t.Errorf("Introduction.Text = %q, want %q", parsed.Introduction.Text, "Введение")
|
||||
}
|
||||
if parsed.Introduction.Audio != "intro.mp3" {
|
||||
t.Errorf("Introduction.Audio = %q, want %q (без домена)", parsed.Introduction.Audio, "intro.mp3")
|
||||
}
|
||||
|
||||
// Наружу — аудио с доменом.
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
if full.Story.Introduction == nil {
|
||||
t.Fatal("Story.Introduction = nil, want введение в полном сценарии")
|
||||
}
|
||||
if full.Story.Introduction.Audio != testDomain+"intro.mp3" {
|
||||
t.Errorf("Introduction.Audio = %q, want %q", full.Story.Introduction.Audio, testDomain+"intro.mp3")
|
||||
}
|
||||
|
||||
// Редактирование точки не должно затрагивать введение и голое имя аудио.
|
||||
err = svc.UpdateScenarioPlace(context.Background(), 1, "club", &storytelling.Place{
|
||||
Code: "club",
|
||||
Name: "Клуб",
|
||||
Text: "Обновлённый текст.",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioPlace: %v", err)
|
||||
}
|
||||
parsed = &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Introduction == nil || parsed.Introduction.Text != "Введение" || parsed.Introduction.Audio != "intro.mp3" {
|
||||
t.Errorf("введение повреждено после UpdateScenarioPlace: %+v", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Очистка введения (nil) — поле исчезает из истории.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, nil, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioIntro(nil): %v", err)
|
||||
}
|
||||
parsed = &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Introduction != nil {
|
||||
t.Errorf("Introduction = %+v, want nil после очистки", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять введение.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{Text: "x"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
|
||||
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user