Статус: planned — ТЗ утверждено 2026-07-26. Фаза A реализована в тот же день (см. §12); фазы B–G ещё нет.

Уточнение контракта, добытое реализацией. §10 обещал, что сигнатуры ISaveService не изменятся. Изменились: Load<T> заменён на TryLoad<T>, возвращающий SaveLoadResult<T>. Без этого «сейва нет» и «сейв из более новой версии игры» приходили вызывающему одинаковым null, и ветка «не грузим и не затираем» из §5 была бы недостижима. Актуальное «как есть» — сейвы (CLAUDE.md §Сохранения).


Полное ТЗ слоя сохранений: иерархия профиль → гильдия → забег, раскладка на диске, версионирование игры и схем, миграции, Steam Cloud, мультиплеерные швы и порядок внедрения. Заменяет собой прежний узкий взгляд «один файл забега» из сейвы (CLAUDE.md §Сохранения). Смежное: флоу забега (код Assets/_Project/Scripts/Game/Flow/), Journal - Host-Authoritative, Not Lockstep, дата-слой (код Assets/_Project/Scripts/Data/, правила — скилл xgaida-x-nixi-data-authoring), Planning - Steam Workshop.


1. Что решаем и чего НЕ решаем

Слой сохранений отвечает на пять вопросов: игрок вышел в любой момент и вернулся; гильдия и мета пережили забег; данные доехали на второй компьютер; обновление игры не съело прогресс; кооп не потерял забег из-за чужого интернета.

Вне рамок этого ТЗ: античит и защита от правки сейвов (см. §11), Workshop-UGC (Planning - Steam Workshop), облачные аккаунты помимо Steam.


2. Принятые решения (Макс, 2026-07-26)

РешениеПричина
S1Выход посреди боя = откат к началу узла. Снапшот живой симуляции не делаемБой детерминирован саб-сидом, ретрай = тот же бой (флоу забега (код Assets/_Project/Scripts/Game/Flow/) §6). Игрок теряет минуту, не прогресс. Снапшот сима ломался бы на каждой правке боевки
S2Steam Cloud = Auto-Cloud по маске путейНоль кода. Ручной ISteamRemoteStorage — только если реально прижмёт
S3Кооп: гильдия у хоста, открытия — каждому в свой профильГость не уходит с пустыми руками, но гильдия остаётся одна
S4Забег ломать можно, мету — никогдаЗабег стоит 40 минут, гильдия — месяцы. Несовместимость забега объявляем честно, profile мигрируем любой ценой
S5Иерархия: профиль → гильдии → забегПрофиль — мета аккаунта (переключаемый: соло / игры с друзьями). Гильдия — дом. Забег живёт внутри гильдии
S64 профиля, 8 гильдий на профиль; оба числа — в GameConfigЛимит правится ассетом, без пересборки (HARD-правило владельца значения)
S7Гостевой вечер даёт запись в Летопись «гостевал у …»Память есть, механической силы нет. Работает на столп «Нарратив рождается сам»

3. Модель данных

Профиль (мета аккаунта, до 4, переключаемый)
├── открытия: Судьбы, прегены, опции, Капитаны
├── статистика игрока, внутренние достижения
└── Гильдии (до 8 на профиль)
    ├── дом: имя, Летопись, история походов
    ├── Сосуды (люди; переносятся между забегами)
    └── Забег — не более одного активного на гильдию

Из иерархии бесплатно следует многослотовость сохранений: слот и есть гильдия. Отдельного экрана «Save 1 / Save 2» не будет — игрок выбирает дом.

Единственный владелец каждого факта

ФактВладелецВ облако
Открытия, прегены, Судьбы, статистика игрокаprofileда
Имя дома, Летопись, Сосуды, история походовguildда
Сид, акт, карта, золото, ростер, инвентарь мементоrun (внутри гильдии)да
Громкости, язык, геймплейные тумблерыprefsда
Разрешение, режим окна, частота обновленияmachineнет

Разделение настроек надвое обязательно: синхронизированное разрешение экрана превращает второй компьютер в лотерею. Настройки не входят в профиль — громкость не должна прыгать от переключения аккаунта.

Настройки дисплея (реализованы 2026-07-26)

Local/machine.json за ILocalSaveService, применяет DisplayService на старте сессии. Три настройки: разрешение, режим окна (WindowMode: эксклюзивный полноэкранный / без рамок / оконный), частота обновления. Поля nullable: «не задано» означает «взять с монитора» — нативное разрешение и наибольшую частоту. Записывать вычисленные значения нельзя: игрок, сменивший монитор, остался бы с разрешением старого.

Четыре факта из документации Unity, которые задали дизайн:

  1. Частоту обновления можно менять только в эксклюзивном полноэкранном. В остальных режимах её задаёт композитор рабочего стола, поэтому в UI выбор надо гасить (RefreshRateSelectable), а не показывать неработающим.
  2. Unity сам сохраняет разрешение в реестр Windows (Screenmanager Resolution Width/Height) и применяет его до первой сцены — то есть у факта уже есть владелец. Мы применяем своё поверх, и реестр становится следствием нашего файла, а не вторым источником правды.
  3. Эксклюзивный полноэкранный работает только с разрешениями из Screen.resolutions; передать неподдерживаемое — не ошибка, а тихая просадка производительности. Отсюда подбор ближайшего.
  4. Частота — рациональное число (RefreshRate {numerator, denominator}): 59.94 Гц это 60000/1001, и в double такое не уложить без потери точности. В файле хранятся оба числа.

В редакторе режим не применяется: Screen.SetResolution перекраивал бы Game view.


4. Раскладка на диске и Steam Cloud

%LOCALAPPDATA%Low/Alebardium/Guildmaster/     ← корень из КОДОВЫХ имён (GameDataPath)
├── Saves/                                  ← синхронизируется Steam Cloud
│   ├── prefs.json
│   └── profiles/
│       └── <profileId>/
│           ├── profile.json
│           └── guilds/
│               └── <guildId>/
│                   ├── guild.json
│                   └── run.json
└── Local/                                  ← НЕ синхронизируется
    └── machine.json

Маска Auto-Cloud (настраивается в партнёрке Steam, кода не требует):

ПолеЗначение
Root PathWinAppDataLocalLow
SubdirectoryAlebardium/Guildmaster/Saves
Pattern*.json
Recursiveда

Подкаталог в маске — кодовые имена, а не название игры (см. ниже). Переименование игры маску не ломает.

Приятный факт: текущая схема именования бэкапов уже совместима — run.json.bak, run.json.tmp и run.json.corrupt не попадают под *.json и в облако не поедут. Требование к будущему коду: не менять суффиксы местами (run.bak.json сломает это молча).

Готча первого порядка: путь завязан на имя игры — ЗАКРЫТА 2026-07-27

Application.persistentDataPath = AppData/LocalLow/{companyName}/{productName}, то есть путь к сохранениям растёт из маркетингового имени. Название игры мы недавно меняли, и формальная проверка на товарный знак ещё не завершена: смена productName после релиза увела бы игру на пустой каталог — сейвы «пропали», маска Auto-Cloud указывает в никуда, откатить нельзя.

Решение: корень данных собирается из кодовых имён (GameDataPath), а не из настроек проекта: LocalLow/Alebardium/Guildmaster/. Платформенную папку берём как родителя persistentDataPath через два уровня — она от company/product не зависит. Имя игры теперь можно менять свободно, и на маску это тоже не влияет.

Инвариант закреплён тестом GameDataPathTests: корень не должен содержать Application.productName. Кодовые имена (Alebardium, Guildmaster) не переименовывать никогда — теперь именно они держат совместимость.

Прочие свойства Auto-Cloud, принятые как есть

  • Синк происходит при выходе из игры. Краш = последняя партия изменений не уехала; локально файл цел (атомарная запись), потери на одной машине нет.
  • Конфликт версий между двумя ПК разруливает диалог Steam, не мы.
  • Auto-Cloud не работает при запуске мимо Steam — это нормально, данные остаются локальными.

5. Версионирование: две независимые шкалы

Их постоянно путают. Разводим:

ШкалаЧто этоГде живётКто на неё смотрит
Версия игрыSemVer, сейчас 0.1.0 в bundleVersionProjectSettings, git-тег, строка в каждом файле сейваЛюди: багрепорты, «в какой версии сломалось»
schemaVersionЦелое, своё у каждого типа файлаКонверт файлаКод: грузить, мигрировать или отказать

Игра версии 0.4.7 спокойно читает run схемы 2 и profile схемы 5. Версия игры никогда не участвует в решении о загрузке — только в диагностике.

Когда бампать schemaVersion

ИзменениеБампПочему
Добавили поле с безопасным дефолтомнетСтарый файл разберётся, поле возьмёт дефолт
Удалили поленетЛишнее в JSON игнорируется
Переименовали поледаДанные иначе молча потеряются
Сменили тип поляда
Сменили смысл или единицы при том же имени и типедаСамый коварный случай: формат совпадает, игра ломается тихо

Матрица исходов загрузки

СитуацияДействие
Версия совпалаГрузим
Версия файла старееПрогоняем цепочку миграций; нет нужного шага → отказ с явным текстом
Версия файла новее нашейНе грузим и не затираем. Говорим «сохранение из более новой версии игры»
Файл нечитаемКарантин .corrupt + попытка .bakуже реализовано
Файл читается, payload пустСчитаем повреждённым, ветка выше

Случай «новее» не гипотетический: игрок откатился на прошлый билд через Steam beta-branch. Без этой ветки JsonUtility разберёт чужой файл в наполовину пустой DTO с валидным видом, и первый же автосейв затрёт живой забег.


6. Конверт и механизм миграций

Каждый файл — конверт с метаданными поверх полезной нагрузки:

{
  "schemaVersion": 3,
  "gameVersion": "0.4.7",
  "savedAtUtc": "2026-07-26T14:03:00Z",
  "payload": { "…": "…" }
}

gameVersion и savedAtUtc — исключительно для диагностики.

Миграции требуют Newtonsoft.Json. Пакет 3.2.2 уже стоит в проекте и не используется (Journal - Library Picks And The Alternatives We Turned Down). JsonUtility не умеет работать с деревом JSON — без Newtonsoft пришлось бы вечно держать классы RunStateV1, V2, V3 и цепочку конвертеров между ними. С деревом миграция — это «переложить узлы»:

// proposed
interface ISaveMigration
{
    int  From { get; }            // из какой версии
    JObject Apply(JObject payload); // в From+1
}

Мигратор собирает шаги в цепочку и гонит файл от его версии до текущей. Шаги не удаляются никогда — иначе игрок, вернувшийся через год, окажется без пути наверх.

Ассиметрия по решению S4: у run цепочка может быть оборвана намеренно (объявляем забег несовместимым), у profile и guild цепочка обязана быть непрерывной всегда.


7. Точки сохранения и поведение при крахе

Дрейф, найденный при сверке с кодом (2026-07-26)

сейвы (CLAUDE.md §Сохранения) и флоу забега (код Assets/_Project/Scripts/Game/Flow/) §5 утверждают «три точки автосейва». В коде RunStateService.Autosave() вызывается из девяти мест: GameFlow (×3), ActRunner (×2), ShopController (×3), RewardPresenter, EventEffectApplier, DeploymentController. Фактическая политика давно другая — «сохраняем после каждого значимого изменения забега», и она лучше заявленной. Доку привести к коду.

Целевая политика

Сохраняем после каждого изменения durable-состояния: покупка, награда, эффект события, ход по карте, правка расстановки, начало и конец узла. Запись дёшева (килобайты, атомарна), а цена пропущенной точки — потерянный ход игрока.

Краш-стойкость уже есть и не требует работы: запись идёт во временный файл с последующей подменой, прежняя версия уезжает в .bak, нечитаемый файл — в .corrupt. Оборвать файл на середине физически нечем.

Что добавить (proposed): флаг CleanExit — сбрасывается в false на старте забега, ставится в true при штатном выходе. Даёт честное «игра вылетела, вот последняя точка» вместо молчания и служит сигналом в телеметрии.


8. Санация контента на загрузке

Сейв ссылается на контент строковыми id. Патч удалил мементо — сейв обязан это пережить: неизвестные id отбрасываются, игроку показывается «часть предметов исчезла из-за обновления».

Не падать и не молчать. Молчание здесь — худший вариант: игрок решит, что игра съела вещи. Проверка идёт против IContentDatabase сразу после миграций, до передачи состояния в игру.


9. Мультиплеер

Опирается на host-authoritative модель (Journal - Host-Authoritative, Not Lockstep).

Кто и что пишет

РольПишетНе пишет
Хостrun, guild своей гильдии, свой profile
Клиенттолько свой profile (открытия по S3) и prefsrun, guild — никогда

Единственный писатель забега — хост. Второго владельца записи не появляется by design.

Владение слотами: убрать индексы

RunState.SlotOwner сейчас int[] — индекс игрока. Сломается на первом реконнекте и на «вчера играли вчетвером, сегодня вдвоём». Владение — сессионная величина, в durable-сейве ему место только как подсказке:

  • durable: LastOwnerKey — стабильный ключ игрока (SteamID64 строкой);
  • сессионное: таблица владения, которую назначает лобби при сборе состава.

Это шов, дорогой в ретрофите и почти бесплатный сейчас.

Идентичность игрока: две разные личности

Профиль — локальная сущность с собственным profileId (GUID). Привязывать профиль к SteamID нельзя: смена аккаунта или запуск без Steam оставит игрока без прогресса. SteamID служит только сессии и владению слотами.

Реконнект

  • Между узлами: хост стримит полный RunState вернувшемуся — состояние весит килобайты, дельты не нужны.
  • Посреди боя (proposed): реконнект применяется на границе узла, вернувшийся досматривает текущий бой зрителем. Для автобатлера почти незаметно — ввода в бою всё равно нет. Полноценный mid-battle синк упирается в тот же снапшот симуляции, что отклонён решением S1.

Проверка совместимости в лобби

Хост и клиент обязаны иметь одинаковую версию игры: расхождение схем и баланса при host-authoritative проявится как необъяснимое расхождение картинки. Сверка версии при входе в лобби с внятным отказом — копейки в реализации, целый класс багов мимо.

Спасательная копия забега (proposed, требует решения по цене)

Хост ушёл — забег умирает вместе с его сейвом; host-migration отложена осознанно. Дешёвое лекарство: хост на каждой границе узла шлёт снапшот RunState клиентам, те держат его теневой копией. Хост пропал — любой перехостит и продолжит. Это не host-migration, это «не потерять три часа». Оценка: пара дней.


10. Швы в коде

КусокЧто с ним
Core/Persistence/ISaveServiceКлюч становится путём внутри дерева (profiles/{id}/guilds/{gid}/run) — сигнатуры Save/Load/Exists/Delete не меняются. Добавить перечисление: IEnumerable<string> List(string prefix) — без него не собрать список профилей и гильдий
Game/Services/JsonFileSaveServiceПоддержать вложенные пути и конверт; атомарность и карантин оставить как есть
Game/Services/SettingsServiceДефект: пишет мимо ISaveService своим File.WriteAllText — без атомарности, без .bak, без версии. Два владельца записи на диск, прямое нарушение HARD-правила. Перевести за шов, разделить на prefs и machine
Guild/RunStateSchemaVersion начать читать; SlotOwnerLastOwnerKey
Новое: ProfileState, GuildStateDTO меты; те же правила — плоские, по строковым id, без ссылок на SO
Новое: SaveMigrator, ISaveMigrationЦепочка миграций на Newtonsoft
Новое: IProfileServiceСписок, создание, переключение, удаление профилей и гильдий
Data/Definitions/GameConfigДобавить лимиты: профилей (4) и гильдий на профиль (8)

11. Осознанно не делаем

Шифрование и подпись сейвов. Игра — кооп против ИИ; читер вредит только себе и друзьям. Читаемый JSON даёт бесплатные багрепорты, моддинг и возможность починить игроку забег руками. Steam Cloud от шифрования только страдает.

Снапшот живой симуляции — решение S1.

Host migration в реальном времени — отложена ранее, см. сейвы (CLAUDE.md §Сохранения).


12. Порядок внедрения

ФазаСоставЗависит от
A. ФундаментПодкаталог Saves/, конверт, чтение schemaVersion + три исхода, настройки за ISaveService, разделение prefs/machine + настройки дисплеяничего — можно делать сразу
B. ИерархияProfileState, GuildState, пути-ключи, List, DeleteTree, IProfileService, лимиты в GameConfigA
C. МиграцииSaveMigrator на Newtonsoft, санация по IContentDatabaseA
D. Cloudфиксация productName (закрыто развязкой пути 2026-07-27); осталось: включить маску Auto-Cloud в партнёрке и проверить на двух машинахA
E. UIЭкраны выбора профиля и гильдии, удаление с подтверждением, «Продолжить» на главномB
F. Кооп-швыLastOwnerKey, сессионная таблица владения, сверка версий в лоббиB
G. Кооп-живучестьРеконнект между узлами, спасательная копия (proposed)F, Фаза MP

Фаза A не зависит ни от чего и чинит два реальных дефекта — с неё и начинать.


13. Открытые вопросы

  • UX входа: три уровня (профиль → гильдия → забег) — это три экрана до игры. Предложение (proposed): игра помнит последний профиль и гильдию, главное меню сразу показывает «Продолжить», выбор профиля живёт отдельной неприметной кнопкой.
  • Удаление гильдии — деструктив на месяцы Летописи. Предложение (proposed): подтверждение вводом имени дома + «удалённое лежит неделю».
  • Форма Летописи и содержимое открытий — дизайн, не техника; блокирует финальную схему profile.json и guild.json. Ведёт meta-progression.
  • Цена спасательной копии (§9) — решение по объёму работ.