Статус: 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). Игрок теряет минуту, не прогресс. Снапшот сима ломался бы на каждой правке боевки |
| S2 | Steam Cloud = Auto-Cloud по маске путей | Ноль кода. Ручной ISteamRemoteStorage — только если реально прижмёт |
| S3 | Кооп: гильдия у хоста, открытия — каждому в свой профиль | Гость не уходит с пустыми руками, но гильдия остаётся одна |
| S4 | Забег ломать можно, мету — никогда | Забег стоит 40 минут, гильдия — месяцы. Несовместимость забега объявляем честно, profile мигрируем любой ценой |
| S5 | Иерархия: профиль → гильдии → забег | Профиль — мета аккаунта (переключаемый: соло / игры с друзьями). Гильдия — дом. Забег живёт внутри гильдии |
| S6 | 4 профиля, 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, которые задали дизайн:
- Частоту обновления можно менять только в эксклюзивном полноэкранном. В остальных режимах её
задаёт композитор рабочего стола, поэтому в UI выбор надо гасить (
RefreshRateSelectable), а не показывать неработающим. - Unity сам сохраняет разрешение в реестр Windows (
Screenmanager Resolution Width/Height) и применяет его до первой сцены — то есть у факта уже есть владелец. Мы применяем своё поверх, и реестр становится следствием нашего файла, а не вторым источником правды. - Эксклюзивный полноэкранный работает только с разрешениями из
Screen.resolutions; передать неподдерживаемое — не ошибка, а тихая просадка производительности. Отсюда подбор ближайшего. - Частота — рациональное число (
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 Path | WinAppDataLocalLow |
| Subdirectory | Alebardium/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 в bundleVersion | ProjectSettings, 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) и prefs | run, 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/RunState | SchemaVersion начать читать; SlotOwner → LastOwnerKey |
Новое: ProfileState, GuildState | DTO меты; те же правила — плоские, по строковым 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, лимиты в GameConfig | A |
| C. Миграции | SaveMigrator на Newtonsoft, санация по IContentDatabase | A |
| D. Cloud | productName | A |
| E. UI | Экраны выбора профиля и гильдии, удаление с подтверждением, «Продолжить» на главном | B |
| F. Кооп-швы | LastOwnerKey, сессионная таблица владения, сверка версий в лобби | B |
| G. Кооп-живучесть | Реконнект между узлами, спасательная копия (proposed) | F, Фаза MP |
Фаза A не зависит ни от чего и чинит два реальных дефекта — с неё и начинать.
13. Открытые вопросы
- UX входа: три уровня (профиль → гильдия → забег) — это три экрана до игры. Предложение (proposed): игра помнит последний профиль и гильдию, главное меню сразу показывает «Продолжить», выбор профиля живёт отдельной неприметной кнопкой.
- Удаление гильдии — деструктив на месяцы Летописи. Предложение (proposed): подтверждение вводом имени дома + «удалённое лежит неделю».
- Форма Летописи и содержимое открытий — дизайн, не техника; блокирует финальную схему
profile.jsonиguild.json. Ведёт meta-progression. - Цена спасательной копии (§9) — решение по объёму работ.