231 lines
8.6 KiB
Go
231 lines
8.6 KiB
Go
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
|
||
}
|
||
}
|
||
}
|