add intro

This commit is contained in:
2026-08-28 22:02:58 +07:00
parent 5fcbf6f2c5
commit 1a05799b2d
17 changed files with 1103 additions and 364 deletions
+22
View File
@@ -580,6 +580,28 @@ func (s *server) DeleteScenarioPlace(ctx context.Context, req *proto.DeleteScena
return &proto.DeleteScenarioPlaceRsp{}, 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) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
err := s.scenarioService.UpdateScenarioIntro(
ctx,
int(req.Id),
convertIntroduction(req.Introduction),
claims.UserID,
roles.HasRole(claims, roles.Admin),
)
if err != nil {
return &proto.UpdateScenarioIntroRsp{
Error: err.Error(),
}, nil
}
return &proto.UpdateScenarioIntroRsp{}, nil
}
// DownloadScenarioArchive отдаёт ZIP-архив сценария; ошибки — gRPC-статусами.
func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
+22 -1
View File
@@ -47,7 +47,18 @@ func mapStory(o *storytelling.Story) *proto.Story {
return nil
}
return &proto.Story{
Places: mapPlaces(o.Places),
Introduction: mapIntroduction(o.Introduction),
Places: mapPlaces(o.Places),
}
}
func mapIntroduction(o *storytelling.Introduction) *proto.Introduction {
if o == nil {
return nil
}
return &proto.Introduction{
Text: o.Text,
Audio: o.Audio,
}
}
@@ -174,3 +185,13 @@ func convertKey(o *proto.Key) *storytelling.Key {
Name: textFormatter.FormatString(o.Name),
}
}
func convertIntroduction(o *proto.Introduction) *storytelling.Introduction {
if o == nil || (o.Text == "" && o.Audio == "") {
return nil
}
return &storytelling.Introduction{
Text: textFormatter.FormatText(o.Text),
Audio: o.Audio,
}
}
@@ -104,6 +104,11 @@ func (a *scenarioArchive) Pack(
return nil, err
}
}
if story.Introduction != nil {
if err := addRef(story.Introduction.Audio); err != nil {
return nil, err
}
}
for _, place := range story.Places {
if err := addRef(place.Image); err != nil {
return nil, err
@@ -137,6 +142,9 @@ func (a *scenarioArchive) Pack(
application.Image = rewriteRef(application.Image, archivePathOf)
}
}
if story.Introduction != nil {
story.Introduction.Audio = rewriteRef(story.Introduction.Audio, archivePathOf)
}
doc := ScenarioJSON{
Version: Version,
@@ -101,6 +101,48 @@ func TestPackUnpackRoundTrip(t *testing.T) {
}
}
func TestPackUnpackIntroAudioRoundTrip(t *testing.T) {
description := "desc"
image := "cover.png"
scenario := &repos.Scenario{
ID: 1,
Name: "С введением",
Description: &description,
Image: &image,
Scenario: `{"introduction":{"text":"Введение","audio":"intro.mp3"},"places":[
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png"}
]}`,
}
files := memoryFiles{
"cover.png": []byte("cover-bytes"),
"club.png": []byte("club-bytes"),
"intro.mp3": []byte("intro-audio-bytes"),
}
data, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
bundle, err := testArchive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Story.Introduction == nil {
t.Fatal("Story.Introduction = nil, want введение")
}
if bundle.Scenario.Story.Introduction.Text != "Введение" {
t.Errorf("Introduction.Text = %q, want %q", bundle.Scenario.Story.Introduction.Text, "Введение")
}
if bundle.Scenario.Story.Introduction.Audio != "images/intro.mp3" {
t.Errorf("Introduction.Audio = %q, want %q", bundle.Scenario.Story.Introduction.Audio, "images/intro.mp3")
}
if _, ok := bundle.Files["images/intro.mp3"]; !ok {
t.Errorf("в архиве нет аудиофайла %q", "images/intro.mp3")
}
}
func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
description := "desc"
scenario := &repos.Scenario{
+14 -1
View File
@@ -4,13 +4,26 @@ type IStory interface {
GetStory(scenario *Story, codes []string) *Story
}
// История - это набор точек
// История - это введение и набор точек
type Story struct {
// Введение - то, что игрок получает в начале игры
Introduction *Introduction `json:"introduction,omitempty"`
// Список точек
Places []*Place `json:"places"`
}
// Введение - текст и аудио, которые игрок получает в начале игры
type Introduction struct {
// Текст введения
Text string `json:"text"`
// Аудио введения
Audio string `json:"audio,omitempty"`
}
// Точка - что-то куда можно сходить
// Это может быть место - аптека, остановка, площадь...
// Это может быть персонаж - пострадавший, продавец, полицейский...
+8
View File
@@ -21,6 +21,14 @@ func (s *story) GetStory(
codesWithUseActions := map[string]struct{}{}
codesHidden := map[string]struct{}{}
givenApplications := map[string]struct{}{}
// Введение передаётся игроку всегда — и до первого хода (начало игры),
// и в дальнейшем (как заголовок истории).
if scenario.Introduction != nil {
story.Introduction = &Introduction{
Text: s.cleaner.ClearText(scenario.Introduction.Text),
Audio: scenario.Introduction.Audio,
}
}
for i, code := range codes {
var prevPlace *Place
if i > 0 {
@@ -28,6 +28,52 @@ func Test_story_GetStory(t *testing.T) {
},
},
},
{
name: "Введение передаётся в начале игры (без действий)",
scenario: &Story{
Introduction: &Introduction{
Text: "Текст введения.([Ы])",
Audio: "intro.mp3",
},
},
codes: []string{},
want: &Story{
Introduction: &Introduction{
Text: "Текст введения.",
Audio: "intro.mp3",
},
},
},
{
name: "Введение передаётся вместе с точками",
scenario: &Story{
Introduction: &Introduction{
Text: "Текст введения.",
Audio: "intro.mp3",
},
Places: []*Place{
{
Code: "Ы",
Name: "Название точки",
Text: "Текст точки.",
},
},
},
codes: []string{"Ы"},
want: &Story{
Introduction: &Introduction{
Text: "Текст введения.",
Audio: "intro.mp3",
},
Places: []*Place{
{
Code: "Ы",
Name: "Название точки",
Text: "Текст точки.",
},
},
},
},
{
name: "Получение точки",
scenario: &Story{
+1 -1
View File
@@ -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()