squad-proto/src/sim/profile.h
z.kirill 3ebd1f96fc Дерево улучшений вместо четырёх кнопок (M32)
Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены
целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни
на что не влиял, и через две вылазки экран превращался в кнопку
"продолжить".

Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это
пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии,
снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят
предыдущий в этой же ветви. Свободная сетка читалась бы как список;
цепочка читается как путь.

Ни один узел не заводит нового канала влияния: каждый кладёт своё число
туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый
же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в
свалку.

ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9%
одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с
фиксированными ценами выкупается целиком за половину глубокого прохода, и
выбор кончается к сороковому этажу навсегда.

Введено общее удорожание: каждая вложенная точка поднимает цену всех
следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное
дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около
шести с половиной полных проходов.

Старые покупки из профиля читаются, но не применяются: потраченный на них
опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя.

Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов:

  дерево: куплено уровней 45, закрытый узел закрыт
          урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22
          парирование 0.34 -> 0.49, свет 420 -> 620

Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже -
там одиночка с ножом гиб на первом же этаже по той же причине, по которой
это было в основном проекте.

Формат профиля 7: строки tree.

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

213 lines
13 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/craft.h"
#include "sim/merc.h"
#include "sim/tree.h"
#include <cstdint>
#include "sim/items.h"
#include "tuning.h"
// Через сколько этажей ставится чекпоинт. Пятьдесят: забег — это ДОЛГИЙ спуск,
// а не пятиэтажная петля. Чекпоинт через каждые пять превращал вылазку в
// перебежку между сохранениями, и весь риск умещался в пять этажей.
constexpr int CHECKPOINT_STEP = 50;
// Сколько предметов помещается в схрон. Не «на всякий случай»: схрон без
// потолка превращает экстракшен в склад, и решение «что взять вниз» исчезает.
constexpr int STASH_CAP = 48;
// Чем комплектуется боец, у которого не осталось ничего. Это дно, ниже
// которого забег не проваливается: без него полный вайп означал бы отряд с
// голыми руками и конец игры вместо нового захода.
struct StarterKit
{
ItemId weapon = ItemId::RIFLE;
ItemId armor = ItemId::JACKET;
ItemId trinket = ItemId::NONE;
};
// -----------------------------------------------------------------------------
// Наёмники. НАХОДЯТСЯ, а не покупаются: капсула на этаже — это место, до
// которого надо дойти, и риск, который надо пережить вместе с ним.
//
// Встречается редко: примерно раз в сто этажей и не чаще, чем раз в двадцать.
// Пятый оперативник обязан быть событием забега, а не строкой в списке покупок.
// -----------------------------------------------------------------------------
constexpr int RESCUE_EVERY = 100; // средняя редкость встречи, в этажах
constexpr int RESCUE_COOLDOWN = 20; // ближе этого две капсулы не встречаются
// Бросок наёмника. Сумма прибавок примерно одинакова, а распределение — нет:
// один крепкий, другой быстрый, третий глазастый. Так двое найденных не
// оказываются одним и тем же человеком с другим именем.
MercStats RollMerc(uint32_t& rng);
// Прежние четыре строки покупок (Upgrade::AMMO/CARRY/SIGHT/VIGOR) удалены
// целиком: их заменило дерево (sim/tree.h). Держать обе системы значило бы
// платить дважды за одно и то же и объяснять игроку, чем строка «+20 патронов»
// отличается от узла «DEEP POCKETS».
// -----------------------------------------------------------------------------
// Версия формата профиля.
//
// Заведена не «на будущее»: формат уже ломался дважды за три вехи — когда сумка
// стала общей и когда появились улучшения. Оба раза старый файл читался молча и
// частично, а игрок видел это как «схрон куда-то делся».
//
// 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent
// 2 — общая сумка, улучшения отряда
// 3 — размер отряда (найм): раньше бойцов всегда было пять
// 4 — экземпляры предметов: редкость и аффиксы в токене после '@'
// 5 — материалы: строка mat
// 6 — наёмники: строка merc и глубина последней встречи
// 7 — дерево улучшений вместо четырёх строк покупок
//
// Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не
// читается вовсе. Второе важнее первого: молча обнулить профиль, который
// записала более новая сборка, — это стереть десятки часов, а отказ читать
// всего лишь просит не запускать старую сборку.
// -----------------------------------------------------------------------------
constexpr int PROFILE_VERSION = 7;
struct Profile
{
// Какой версией записан прочитанный файл. Новый профиль сразу текущей.
int version = PROFILE_VERSION;
// Уровни узлов дерева. Живут в профиле, а не в игре: это то, что
// переживает вылазку (sim/tree.h).
int node[TREE_NODES] = {};
// Сколько всего точек вложено в дерево. По ней считается общее удорожание
// (sim/tree.h): каждая взятая точка поднимает цену следующих.
int TreeRanks() const
{
int n = 0;
for (int i = 0; i < TREE_NODES; ++i) n += node[i];
return n;
}
int NodeRank(int index) const
{
return (index >= 0 && index < TREE_NODES) ? node[index] : 0;
}
// Узел открыт, если предыдущий в его ветви взят хотя бы на уровень. Цепочка,
// а не свободная сетка: ветвь обязана читаться как путь.
bool NodeUnlocked(int index) const
{
const int req = TreeNodeRequires(index);
return req < 0 || NodeRank(req) > 0;
}
bool CanBuyNode(int index) const
{
if (index < 0 || index >= TREE_NODES) return false;
if (!NodeUnlocked(index)) return false;
const int rank = NodeRank(index);
if (rank >= TreeNodeAt(index).maxRank) return false;
return bankedXp >= TreeNodeCost(index, rank, TreeRanks());
}
bool BuyNode(int index)
{
if (!CanBuyNode(index)) return false;
bankedXp -= TreeNodeCost(index, NodeRank(index), TreeRanks());
++node[index];
return true;
}
// Сумма всего взятого. Считается при сборке этажа, дальше читаются готовые
// числа: правила боя про дерево не знают.
TreeBonus Tree() const
{
TreeBonus b;
for (int i = 0; i < TREE_NODES; ++i)
if (node[i] > 0) b.Add(TreeNodeAt(i).effect, TreeNodeAt(i).perRank * float(node[i]));
return b;
}
// Сколько оперативников в отряде. Игра начинается ОДНИМ (docs/20-gdd.md),
// потолок — NUM_AGENTS. Живёт в профиле, потому что это накопленный
// результат, а не состояние вылазки: нанятый не теряется при вайпе, он
// теряет только снаряжение, как и все.
int squadSize = 1;
// Числа найденных оперативников по слотам. У первого их нет: он и есть
// тот, с кого игра начинается.
MercStats merc[NUM_AGENTS];
// На какой глубине последний раз встретилась капсула. Нужна редкости:
// «не чаще раза в двадцать этажей» считается отсюда.
int lastRescueDepth = 0;
// Место в отряде ещё есть?
bool CanRescue() const { return squadSize < NUM_AGENTS; }
// Снаряжение бойцов ровно в том виде, в каком они вернулись. Массив всегда
// на все слоты: незанятые просто пустые.
Inventory loadout[NUM_AGENTS];
// Общий запас сверх надетого. Экземпляры, а не виды: схрон — это то, из
// чего собирают следующую вылазку, и вещь без аффиксов в нём была бы уже
// другой вещью (sim/affix.h).
std::vector<ItemInstance> stash;
// Материалы. ВАЛЮТА: не занимают ячеек, не весят, в схроне не лежат
// (sim/craft.h). Зачисляются только при эвакуации — ставка одна на всё.
int material[MATERIAL_COUNT] = {};
// --- прогресс -------------------------------------------------------------
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(const ItemInstance& 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();