From 99f0e50f9a4f9ecf77097c80bc1b0d69cc034508 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Fri, 10 Jul 2026 01:30:09 +0700 Subject: [PATCH] storytelling --- .gitignore | 2 + .vscode/settings.json | 1 + go.mod | 10 +- internal/modules/storytelling/interface.go | 76 +++ internal/modules/storytelling/story.go | 172 +++++ internal/modules/storytelling/story_test.go | 677 ++++++++++++++++++++ 6 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 internal/modules/storytelling/interface.go create mode 100644 internal/modules/storytelling/story.go create mode 100644 internal/modules/storytelling/story_test.go diff --git a/.gitignore b/.gitignore index 532819e..65d2f87 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ go.work go.sum .env + +.VSCodeCounter/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 0c17c4c..c16250a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "Аркхема", "pgxpool", "timestamppb" ] diff --git a/go.mod b/go.mod index fa734c9..4145538 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,20 @@ go 1.25.0 require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 + github.com/stretchr/testify v1.11.1 google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 google.golang.org/grpc v1.64.0 google.golang.org/protobuf v1.34.1 ) -require github.com/vearutop/statigz v1.4.0 // indirect +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/vearutop/statigz v1.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) require ( github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/internal/modules/storytelling/interface.go b/internal/modules/storytelling/interface.go new file mode 100644 index 0000000..419b4a6 --- /dev/null +++ b/internal/modules/storytelling/interface.go @@ -0,0 +1,76 @@ +package storytelling + +type IStory interface { + GetStory(scenario *Story, codes []string) *Story +} + +// История - это набор точек +type Story struct { + + // Список точек + Places []*Place `json:"places"` +} + +// Точка - что-то куда можно сходить +// Это может быть место - аптека, остановка, площадь... +// Это может быть персонаж - пострадавший, продавец, полицейский... +// Это может быть описание действия игрока - ответ на вопрос, реакция на выбитую дверь... +type Place struct { + + // Код - то что вбивает игрок + Code string `json:"code"` + + // Название места, имя персонажа, заданный игроком вопрос... + Name string `json:"name"` + + // Текст - получаемая текстовая информация + Text string `json:"text"` + + // Картинка - получаемая визуальная информация + Image string `json:"image"` + + // Признак скрытой карточки - некоторые места изначально не доступны + Hidden bool `json:"hidden"` + + // Приложения - получаемые улики + Applications []*Application `json:"applications,omitempty"` + + // Двери к другим точкам - действия или диалоги, открывающие другие точки + Doors []*Door `json:"doors"` + + // Получаемые ключи - ключи используются для получения доступа к скрытым дверям + Keys []*Key `json:"keys"` +} + +// Приложение - улика +// Это может быть оружие, отпечатки, фотография... +type Application struct { + + // Название улики + Name string `json:"name"` + + // Картинка + Image string `json:"image"` +} + +// Дверь - действие или диалог +type Door struct { + + // Код + Code string `json:"code"` + + // Название - заданный вопрос, проделанное действие... + Name string `json:"name"` + + // Некоторые двери изначально не доступны + // Ключи требуемые для получения доступа к скрытым дверям + // Если указаны ключи то дверь считается скрытой + Keys []*Key `json:"keys"` +} + +// Аналог карт допуска в "Тайны Аркхема" +type Key struct { + + // Название + Name string `json:"name"` +} diff --git a/internal/modules/storytelling/story.go b/internal/modules/storytelling/story.go new file mode 100644 index 0000000..8001fce --- /dev/null +++ b/internal/modules/storytelling/story.go @@ -0,0 +1,172 @@ +package storytelling + +type story struct{} + +func NewStory() IStory { + return &story{} +} + +func (s *story) GetStory( + scenario *Story, + codes []string, +) *Story { + story := &Story{} + keys := map[string]struct{}{} + codesWithUseActions := map[string]struct{}{} + codesHidden := map[string]struct{}{} + for i, code := range codes { + var prevPlace *Place + if i > 0 { + prevPlace, _ = s.findPlace(scenario, codes[i-1]) + } + currentPlace, _ := s.findPlace(scenario, codes[i]) + var nextPlace *Place + if i < len(codes)-1 { + nextPlace, _ = s.findPlace(scenario, codes[i+1]) + } + // Если следующая точка это открытие двери в текущей точке, то текущая точка считается использованной для действия + if nextPlace != nil && currentPlace != nil { + openDoor := false + for _, door := range currentPlace.Doors { + if door.Code == nextPlace.Code { + codesWithUseActions[currentPlace.Code] = struct{}{} + openDoor = true + break + } + } + if openDoor { + // Запрещаем смотреть не выбранные действия + for _, door := range currentPlace.Doors { + if door.Code != nextPlace.Code { + codesHidden[door.Code] = struct{}{} + } + } + } + } + // Собираем ключи + if currentPlace != nil { + for _, key := range currentPlace.Keys { + keys[key.Name] = struct{}{} + } + } + place, ok := s.findResultPlace(scenario, code, prevPlace, codesHidden) + if !ok { + story.Places = append(story.Places, s.buildNotFoundPlace(code)) + continue + } + story.Places = append( + story.Places, + s.mapPlace(place, nextPlace, codesWithUseActions, keys), + ) + } + return story +} + +func (s *story) findPlace( + scenario *Story, + code string, +) (*Place, bool) { + for _, place := range scenario.Places { + if place.Code == code { + return place, true + } + } + return nil, false +} + +func (s *story) findResultPlace( + scenario *Story, + code string, + prevPlace *Place, + codesHidden map[string]struct{}, +) (*Place, bool) { + if _, ok := codesHidden[code]; ok { + return nil, false + } + open := false + if prevPlace != nil { + for _, door := range prevPlace.Doors { + if door.Code == code { + open = true + break + } + } + } + for _, place := range scenario.Places { + if place.Code == code { + if place.Hidden && !open { + return nil, false + } + return place, true + } + } + return nil, false +} + +func (s *story) buildNotFoundPlace(code string) *Place { + return &Place{ + Code: code, + Name: "Не найдено", + Text: "Такой точки не существует.", + } +} + +func (s *story) mapPlace( + place *Place, + nextPlace *Place, + codesWithUseActions map[string]struct{}, + keys map[string]struct{}, +) *Place { + var applications []*Application + if len(place.Applications) > 0 { + applications = make([]*Application, 0, len(place.Applications)) + for _, application := range place.Applications { + applications = append( + applications, + &Application{ + Name: application.Name, + Image: application.Image, + }, + ) + } + } + var doors []*Door + _, useAction := codesWithUseActions[place.Code] + if len(place.Doors) > 0 && !useAction { + doors = make([]*Door, 0, len(place.Doors)) + for _, door := range place.Doors { + if nextPlace != nil { + if door.Code != nextPlace.Code { + continue + } + } + hasKeys := true + for _, doorKey := range door.Keys { + if _, ok := keys[doorKey.Name]; !ok { + hasKeys = false + } + } + if !hasKeys { + continue + } + doors = append( + doors, + &Door{ + Code: door.Code, + Name: door.Name, + }, + ) + } + } + if len(doors) == 0 { + doors = nil + } + return &Place{ + Code: place.Code, + Name: place.Name, + Text: place.Text, + Image: place.Image, + Applications: applications, + Doors: doors, + } +} diff --git a/internal/modules/storytelling/story_test.go b/internal/modules/storytelling/story_test.go new file mode 100644 index 0000000..156c1af --- /dev/null +++ b/internal/modules/storytelling/story_test.go @@ -0,0 +1,677 @@ +package storytelling + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_story_GetStory(t *testing.T) { + tests := []struct { + name string + scenario *Story + codes []string + want *Story + }{ + { + name: "Попытка получить несуществующую точку", + scenario: &Story{}, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Не найдено", + Text: "Такой точки не существует.", + }, + }, + }, + }, + { + name: "Получение точки", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + }, + { + name: "Получение 2 точек", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки Ы", + Text: "Текст точки Ы.", + }, + { + Code: "Д", + Name: "Название точки Д", + Text: "Текст точки Д.", + }, + }, + }, + codes: []string{"Ы", "Д"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки Ы", + Text: "Текст точки Ы.", + }, + { + Code: "Д", + Name: "Название точки Д", + Text: "Текст точки Д.", + }, + }, + }, + }, + { + name: "Получение точки с картинкой", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Image: "image.png", + }, + }, + }, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Image: "image.png", + }, + }, + }, + }, + { + name: "Попытка получить скрытую точку", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Hidden: true, + }, + }, + }, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Не найдено", + Text: "Такой точки не существует.", + }, + }, + }, + }, + { + name: "Получение точки с уликой", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Applications: []*Application{ + { + Name: "Название улики", + Image: "image.png", + }, + }, + }, + }, + }, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Applications: []*Application{ + { + Name: "Название улики", + Image: "image.png", + }, + }, + }, + }, + }, + }, + { + name: "Получение точки с действием", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + }, + }, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + }, + }, + }, + { + name: "Не открываем скрытую точку действием", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + { + Code: "Д", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + codes: []string{"Ы", "Д"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Д", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + }, + { + name: "Открываем скрытую точку действием", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + Hidden: true, + }, + }, + }, + codes: []string{"Ы", "Ы-1"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + }, + { + name: "Не открываем скрытую точку действием, а потом возвращаемся", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + { + Code: "Д", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + codes: []string{"Ы", "Д", "Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Д", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + }, + }, + }, + { + name: "Открываем скрытую точку действием, а потом возвращаемся", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + Hidden: true, + }, + }, + }, + codes: []string{"Ы", "Ы-1", "Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + }, + { + name: "Получение точки со скрытым действием", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + Keys: []*Key{ + { + Name: "ключ", + }, + }, + }, + }, + }, + }, + }, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + }, + { + name: "Получение точки с ключом", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Keys: []*Key{ + { + Name: "ключ", + }, + }, + }, + }, + }, + codes: []string{"Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + }, + { + name: "Получение ключа и открытие скрытого действия", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Keys: []*Key{ + { + Name: "ключ", + }, + }, + }, + { + Code: "Д", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + Keys: []*Key{ + { + Name: "ключ", + }, + }, + }, + }, + }, + }, + }, + codes: []string{"Ы", "Д"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Д", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + }, + }, + }, + }, + }, + { + name: "После выбора одного из действий остальные не доступны", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Ы-1", + Name: "Действие 1", + }, + { + Code: "Ы-2", + Name: "Действие 1", + }, + }, + }, + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + Hidden: true, + }, + { + Code: "Ы-2", + Name: "Название точки", + Text: "Текст точки.", + Hidden: true, + }, + }, + }, + codes: []string{"Ы", "Ы-1", "Ы-2", "Ы", "Ы-2"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы-2", + Name: "Не найдено", + Text: "Такой точки не существует.", + }, + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы-2", + Name: "Не найдено", + Text: "Такой точки не существует.", + }, + }, + }, + }, + { + name: "Если ключей не хватает то скрытые двери не видим", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + Keys: []*Key{ + { + Name: "ключ 1", + }, + }, + }, + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Д", + Name: "Действие", + Keys: []*Key{ + { + Name: "ключ 1", + }, + { + Name: "ключ 2", + }, + }, + }, + }, + }, + }, + }, + codes: []string{"Ы-1", "Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + }, + }, + }, + }, + { + name: "Собираем ключи и видим скрытые двери", + scenario: &Story{ + Places: []*Place{ + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + Keys: []*Key{ + { + Name: "ключ 1", + }, + }, + }, + { + Code: "Ы-2", + Name: "Название точки", + Text: "Текст точки.", + Keys: []*Key{ + { + Name: "ключ 2", + }, + }, + }, + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Д", + Name: "Действие", + Keys: []*Key{ + { + Name: "ключ 1", + }, + { + Name: "ключ 2", + }, + }, + }, + }, + }, + }, + }, + codes: []string{"Ы-1", "Ы-2", "Ы"}, + want: &Story{ + Places: []*Place{ + { + Code: "Ы-1", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы-2", + Name: "Название точки", + Text: "Текст точки.", + }, + { + Code: "Ы", + Name: "Название точки", + Text: "Текст точки.", + Doors: []*Door{ + { + Code: "Д", + Name: "Действие", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewStory().GetStory(tt.scenario, tt.codes) + assert.Equal(t, got, tt.want) + }) + } +}