go_games_collection/wayfarer_quest_loader.go
2026-07-07 09:12:09 +03:00

231 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}