squad-proto/src/sim/profile.h
z.kirill d60f9e0ce3 Версия профиля и миграция (E1)
Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда
появились улучшения. Оба раза старый файл читался молча и частично, а игрок
видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат
третий раз, поэтому версия заводится до них, а не после.

Правило чтения несимметрично намеренно:

  файл СТАРШЕ игры  — читается с миграцией (версии нет = формат 1)
  файл НОВЕЕ игры   — не читается вовсе, с объяснением в сообщении

Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой,
значит стереть десятки часов; отказ читать всего лишь просит не запускать
старую сборку.

Проверяется обеими сторонами в --descend: поддельный профиль формата 1
читается и сохраняет глубину и схрон, поддельный профиль из будущего
отвергается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:16:08 +03:00

161 lines
9.5 KiB
C++
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.

#pragma once
// -----------------------------------------------------------------------------
// Профиль: всё, что переживает забег.
//
// Игра стала экстракшен-шутером, и это меняет смысл спуска целиком. Раньше
// глубина была прогрессом: чем дальше, тем лучше. Теперь она СТАВКА — унесённое
// остаётся, потерянное не возвращается, и каждый этаж это заново заданный
// вопрос «ещё ниже или назад».
//
// Отсюда всё устройство файла:
// * снаряжение бойцов лежит ЗДЕСЬ, а не собирается заново при старте. То, в
// чём отряд ушёл вниз, — это то, в чём он вернулся с прошлой вылазки;
// * схрон — общий запас сверх надетого. Он и есть накопленный результат;
// * максимальная глубина открывает чекпоинты. Без них десятитысячный этаж
// недостижим не по сложности, а по времени.
//
// Формат текстовый и построчный по той же причине, что у комнат: профиль лежит
// рядом с игрой, его правят руками при отладке, и он обязан читаться глазами.
//
// Профиль — состояние ИГРОКА, а не мира, поэтому он не в ecs::World и не
// сбрасывается ни Reset, ни Descend.
// -----------------------------------------------------------------------------
#include <string>
#include <vector>
#include "sim/items.h"
#include "tuning.h"
// Через сколько этажей ставится чекпоинт. Пять — это «достаточно редко, чтобы
// дойти до следующего было событием, и достаточно часто, чтобы потеря забега
// не откатывала в самое начало».
constexpr int CHECKPOINT_STEP = 5;
// Сколько предметов помещается в схрон. Не «на всякий случай»: схрон без
// потолка превращает экстракшен в склад, и решение «что взять вниз» исчезает.
constexpr int STASH_CAP = 48;
// Чем комплектуется боец, у которого не осталось ничего. Это дно, ниже
// которого забег не проваливается: без него полный вайп означал бы отряд с
// голыми руками и конец игры вместо нового захода.
struct StarterKit
{
ItemId weapon = ItemId::RIFLE;
ItemId armor = ItemId::JACKET;
};
// -----------------------------------------------------------------------------
// Во что вкладывается вынесенный опыт.
//
// Улучшения РЕСУРСНЫЕ, а не силовые: больше патронов, больше переноски, дальше
// видно, больше запаса здоровья. Игра про исчерпание, и вкладываться в неё надо
// в то, что кончается, — иначе прокачка съедает сам жанр: отряд, который бьёт
// вдвое сильнее, перестаёт бояться, а страх тут единственная механика, которую
// нельзя восстановить балансом.
//
// Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре
// остаётся снаряжение, и второе личное измерение превратило бы сборы перед
// вылазкой в бухгалтерию на пятерых.
// -----------------------------------------------------------------------------
enum class Upgrade : uint8_t
{
AMMO = 0, // запас патронов на вылазку
CARRY, // норма переноски до перегруза
SIGHT, // радиус обзора
VIGOR, // запас здоровья
COUNT,
};
constexpr int UPGRADE_MAX_LEVEL = 5;
// Шаг улучшения за уровень.
constexpr float UPGRADE_AMMO_STEP = 20.0f;
constexpr float UPGRADE_CARRY_STEP = 1.5f;
constexpr float UPGRADE_SIGHT_STEP = 0.06f;
constexpr float UPGRADE_VIGOR_STEP = 12.0f;
// Цена следующего уровня. Растёт линейно: первая покупка обязана случиться на
// второй-третьей вылазке, иначе опыт снова «просто число», а десятая — стоить
// заметно, иначе кривая кончается.
inline float UpgradeCost(int currentLevel) { return 150.0f * float(currentLevel + 1); }
const char* UpgradeName(Upgrade u);
const char* UpgradeEffect(Upgrade u);
// -----------------------------------------------------------------------------
// Версия формата профиля.
//
// Заведена не «на будущее»: формат уже ломался дважды за три вехи — когда сумка
// стала общей и когда появились улучшения. Оба раза старый файл читался молча и
// частично, а игрок видел это как «схрон куда-то делся».
//
// 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent
// 2 — общая сумка, улучшения отряда
//
// Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не
// читается вовсе. Второе важнее первого: молча обнулить профиль, который
// записала более новая сборка, — это стереть десятки часов, а отказ читать
// всего лишь просит не запускать старую сборку.
// -----------------------------------------------------------------------------
constexpr int PROFILE_VERSION = 2;
struct Profile
{
// Какой версией записан прочитанный файл. Новый профиль сразу текущей.
int version = PROFILE_VERSION;
// Уровни улучшений, по одному на каждое. Живут в профиле, а не в игре:
// это то, что переживает вылазку.
int upgrade[int(Upgrade::COUNT)] = {};
int UpgradeLevel(Upgrade u) const { return upgrade[int(u)]; }
bool CanBuy(Upgrade u) const
{
return upgrade[int(u)] < UPGRADE_MAX_LEVEL && bankedXp >= UpgradeCost(upgrade[int(u)]);
}
bool Buy(Upgrade u)
{
if (!CanBuy(u)) return false;
bankedXp -= UpgradeCost(upgrade[int(u)]);
++upgrade[int(u)];
return true;
}
// Снаряжение пяти бойцов ровно в том виде, в каком они вернулись.
Inventory loadout[NUM_AGENTS];
// Общий запас сверх надетого.
std::vector<ItemId> stash;
// --- прогресс -------------------------------------------------------------
int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж
int runs = 0; // сколько вылазок начато
int extractions = 0; // сколько закончилось эвакуацией
int wipes = 0;
float bankedXp = 0.0f; // опыт, вынесенный из успешных вылазок
// --- чекпоинты ------------------------------------------------------------
// Самый глубокий чекпоинт, с которого можно начать. Глубина 1 доступна
// всегда: начать сверху — законный выбор, а не наказание.
int DeepestCheckpoint() const;
// Чекпоинт номер i (0 — первый этаж). Возвращает глубину.
static int CheckpointDepth(int index) { return 1 + index * CHECKPOINT_STEP; }
int CheckpointCount() const;
// --- схрон ----------------------------------------------------------------
bool StashPush(ItemId id); // false — схрон полон
bool StashRemoveAt(int index);
int StashCount() const { return int(stash.size()); }
// Сбросить снаряжение к стартовому комплекту. Схрон и прогресс не трогает:
// потерять вылазку — не то же самое, что потерять всё нажитое.
void ResetLoadouts();
bool Load(const std::string& path, std::string* err = nullptr);
bool Save(const std::string& path, std::string* err = nullptr) const;
};
// Где лежит профиль: рядом с бинарником или в корне репозитория. Возвращает
// путь, даже если файла ещё нет, — по нему и будет первая запись.
std::string DefaultProfilePath();