package main import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // ПРИМЕЧАНИЕ: помимо 8 встроенных квестов (см. wayfarer_quests.go), // можно добавлять собственные квесты, не трогая код коллекции — // достаточно положить .yaml файл в директорию "quests" рядом с // исполняемым файлом (или в текущей рабочей директории). Формат // файла и полное описание — в rules.go (правила Странника) и в // wayfarer_quest_loader_example.yaml. // // Загрузка максимально терпима к ошибкам: файл с некорректным // синтаксисом, отсутствующими полями или неполным переводом просто // пропускается с предупреждением в stderr — никогда не приводит к // падению всей программы. Ключ квеста, уже занятый одним из // встроенных (или другим уже загруженным файлом), тоже пропускается // с предупреждением, а не молча перезаписывает существующий. // wfLocalizedText — пара текстов на русском и английском в одном // YAML-поле. type wfLocalizedText struct { RU string `yaml:"ru"` EN string `yaml:"en"` } type wayfarerQuestChoiceYAML struct { Label wfLocalizedText `yaml:"label"` NextStep *int `yaml:"next_step"` Terminal bool `yaml:"terminal"` Success bool `yaml:"success"` Result wfLocalizedText `yaml:"result"` CreditsDelta float64 `yaml:"credits_delta"` HullDelta int `yaml:"hull_delta"` CargoIdx *int `yaml:"cargo_idx"` CargoDelta int `yaml:"cargo_delta"` KillsDelta int `yaml:"kills_delta"` } type wayfarerQuestStepYAML struct { Text wfLocalizedText `yaml:"text"` Choices []wayfarerQuestChoiceYAML `yaml:"choices"` } type wayfarerQuestYAML struct { Key string `yaml:"key"` Title wfLocalizedText `yaml:"title"` Brief wfLocalizedText `yaml:"brief"` Steps []wayfarerQuestStepYAML `yaml:"steps"` } // wfLoadedText — один текст для регистрации в общей карте переводов. type wfLoadedText struct { key, ru, en string } // convertYAMLQuest превращает разобранный YAML в WayfarerQuestDef и // список текстов для регистрации переводов. sourceName — имя файла, // только для сообщений об ошибках. func convertYAMLQuest(y wayfarerQuestYAML, sourceName string) (WayfarerQuestDef, []wfLoadedText, error) { var def WayfarerQuestDef if y.Key == "" { return def, nil, fmt.Errorf("%s: не указано поле key", sourceName) } if len(y.Steps) == 0 { return def, nil, fmt.Errorf("%s: квест %q — нет ни одного шага (steps)", sourceName, y.Key) } def.Key = y.Key def.TitleKey = y.Key + ".title" def.BriefKey = y.Key + ".brief" var texts []wfLoadedText texts = append(texts, wfLoadedText{def.TitleKey, y.Title.RU, y.Title.EN}, wfLoadedText{def.BriefKey, y.Brief.RU, y.Brief.EN}, ) for si, step := range y.Steps { stepTextKey := fmt.Sprintf("%s.step%d", y.Key, si) texts = append(texts, wfLoadedText{stepTextKey, step.Text.RU, step.Text.EN}) if len(step.Choices) == 0 { return def, nil, fmt.Errorf("%s: квест %q, шаг %d — нет вариантов выбора (choices)", sourceName, y.Key, si) } stepOut := WayfarerQuestStep{TextKey: stepTextKey} for ci, choice := range step.Choices { labelKey := fmt.Sprintf("%s.choice%d.%d", y.Key, si, ci) texts = append(texts, wfLoadedText{labelKey, choice.Label.RU, choice.Label.EN}) choiceOut := WayfarerQuestChoice{ LabelKey: labelKey, Terminal: choice.Terminal, Success: choice.Success, CreditsDelta: choice.CreditsDelta, HullDelta: choice.HullDelta, CargoDelta: choice.CargoDelta, KillsDelta: choice.KillsDelta, CargoIdx: -1, } if choice.CargoIdx != nil { choiceOut.CargoIdx = *choice.CargoIdx } if choice.Terminal { resultKey := fmt.Sprintf("%s.result%d.%d", y.Key, si, ci) texts = append(texts, wfLoadedText{resultKey, choice.Result.RU, choice.Result.EN}) choiceOut.ResultKey = resultKey } else { if choice.NextStep == nil { return def, nil, fmt.Errorf("%s: квест %q, шаг %d, выбор %d — не указан next_step (обязателен, если это не конец квеста)", sourceName, y.Key, si, ci) } if *choice.NextStep < 0 || *choice.NextStep >= len(y.Steps) { return def, nil, fmt.Errorf("%s: квест %q, шаг %d, выбор %d — next_step %d вне диапазона шагов (0..%d)", sourceName, y.Key, si, ci, *choice.NextStep, len(y.Steps)-1) } choiceOut.NextStep = *choice.NextStep } stepOut.Choices = append(stepOut.Choices, choiceOut) } def.Steps = append(def.Steps, stepOut) } return def, texts, nil } // LoadWayfarerQuestsFromDir читает все *.yaml/*.yml файлы в dir и // возвращает успешно разобранные квесты и список предупреждений по // пропущенным файлам (пустая/отсутствующая директория — не ошибка, // просто нет пользовательских квестов). existingKeys — ключи, уже // занятые (встроенными или ранее загруженными квестами); функция не // мутирует эту карту. func LoadWayfarerQuestsFromDir(dir string, existingKeys map[string]bool) ([]WayfarerQuestDef, []string) { entries, err := os.ReadDir(dir) if err != nil { return nil, nil } taken := make(map[string]bool, len(existingKeys)) for k := range existingKeys { taken[k] = true } var loaded []WayfarerQuestDef var warnings []string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") { continue } path := filepath.Join(dir, name) data, err := os.ReadFile(path) if err != nil { warnings = append(warnings, fmt.Sprintf("%s: не удалось прочитать файл: %v", name, err)) continue } var y wayfarerQuestYAML if err := yaml.Unmarshal(data, &y); err != nil { warnings = append(warnings, fmt.Sprintf("%s: ошибка разбора YAML: %v", name, err)) continue } def, texts, err := convertYAMLQuest(y, name) if err != nil { warnings = append(warnings, err.Error()) continue } if taken[def.Key] { warnings = append(warnings, fmt.Sprintf("%s: квест с ключом %q уже занят, файл пропущен", name, def.Key)) continue } incomplete := false for _, tx := range texts { if tx.ru == "" || tx.en == "" { warnings = append(warnings, fmt.Sprintf("%s: у ключа %q не заполнен перевод (ru=%q en=%q)", name, tx.key, tx.ru, tx.en)) incomplete = true } } if incomplete { continue } for _, tx := range texts { translations[tx.key] = translationEntry{ru: tx.ru, en: tx.en} } loaded = append(loaded, def) taken[def.Key] = true } return loaded, warnings } // loadCustomWayfarerQuests ищет пользовательские квесты в директории // "quests" рядом с исполняемым файлом и в текущей рабочей директории // (та же логика поиска, что и у .env для сида галактики), добавляя // найденные к WayfarerQuestDefs. Предупреждения о пропущенных файлах // выводятся в stderr, не мешая TUI. func loadCustomWayfarerQuests() { existingKeys := make(map[string]bool, len(WayfarerQuestDefs)) for _, q := range WayfarerQuestDefs { existingKeys[q.Key] = true } for _, dir := range wayfarerEnvSearchDirs() { questsDir := filepath.Join(dir, "quests") loaded, warnings := LoadWayfarerQuestsFromDir(questsDir, existingKeys) for _, w := range warnings { fmt.Fprintln(os.Stderr, "квесты: "+w) } for _, q := range loaded { WayfarerQuestDefs = append(WayfarerQuestDefs, q) existingKeys[q.Key] = true } } }