squad-proto/src/sim/affix.h
z.kirill d3aa044cbe Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.

Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.

ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.

Замеры поймали три расхождения дизайн-документа с самим собой:

  * формула редкости противоречила таблице рядом с ней: 2.7% находок с
    аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
    до схождения с таблицей на дне (12% обычных на сотом);
  * бюджет пускал восемь высших аффиксов вместо обещанных шести;
  * предельная вещь собиралась к семидесятому этажу вместо сотого.

Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.

Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:

  аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
           пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000

Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.

Попутно, на глубине 30 и только там:

  * «зачистка поля» в проверках не держалась — у твари есть откат
    воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
    вне схватки и расход света на стоянке молча мерили бой. На восьмом
    этаже тварей мало, и проверка проходила по удаче;
  * порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
    дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
    строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
    списывается только в ADVANCING: топтание и драка бесплатны, тратит
    РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.

Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.

--accept PASS без изменений: в песочнице находок нет вовсе.

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

161 lines
9.1 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
// -----------------------------------------------------------------------------
// Редкости и аффиксы: то, что превращает находку в РЕШЕНИЕ.
//
// До этой вехи предмет был строкой каталога: «карабин» на первом этаже и
// «карабин» на сотом — одна и та же вещь. Идти за ней в темноту было незачем,
// а весь спуск держался на количестве находок, а не на их качестве.
//
// Аффикс = тег + тир + рулон (docs/22-loot.md §3). Тег говорит, ЧТО он даёт;
// тир — насколько сильным он бывает на этой глубине; рулон — конкретное число.
//
// ТРИ ПРАВИЛА, КОТОРЫЕ ЗДЕСЬ ЗАЩИЩАЮТСЯ АРИФМЕТИКОЙ, А НЕ СПИСКАМИ ЗАПРЕТОВ:
//
// 1. БЮДЖЕТ. У предмета есть бюджет по уровню, аффикс стоит по тиру. Шесть
// высших аффиксов не влезают никуда, кроме предмета сотого уровня, — и
// это единственный барьер, который не приходится подпирать частными
// исключениями. Список исключений разрастается и однажды противоречит
// сам себе.
// 2. ОДИН ТЕГ ОДИН РАЗ. Иначе «+урон» шесть раз становится единственной
// правильной сборкой, а весь остальной список — мусором.
// 3. КИНЕТИКА НЕ ИСЧЕЗАЕТ. Конверсий не больше 0.85 в сумме: оружие без
// физического урона перестало бы работать по целям без сопротивлений
// вовсе (sim/element.h).
//
// Аффиксы НЕ заводят новых каналов влияния. Каждый тег кладётся в то, что уже
// есть: урон оружия, сопротивления, стихии, статы бойца, ресурсы вылазки. Это
// не экономия, а то же правило, что и у снаряжения: у предмета нет ручки к
// правилу «не бить своих», и появиться она не может (sim/items.h).
// -----------------------------------------------------------------------------
#include <cstdint>
#include "sim/element.h"
#include "sim/items.h"
// Сами типы (Rarity, AffixTag, Affix, ItemInstance) объявлены в sim/items.h:
// экземпляр предмета — это то, что боец несёт, и место ему там. Здесь — вся
// арифметика вокруг них.
inline const char* RarityName(Rarity r)
{
switch (r)
{
case Rarity::MARKED: return "MARKED";
case Rarity::PROTOTYPE: return "PROTOTYPE";
case Rarity::RELIC: return "RELIC";
default: return "COMMON";
}
}
// Цвет рамки и подписи. Байты RGB: в sim/ типов рендера нет.
struct RarityColor { uint8_t r, g, b; };
inline RarityColor RarityColorOf(Rarity r)
{
switch (r)
{
case Rarity::MARKED: return {120, 190, 235};
case Rarity::PROTOTYPE: return {170, 140, 240};
case Rarity::RELIC: return {235, 165, 70};
default: return {190, 190, 190};
}
}
// Доступный тир на глубине и стоимость аффикса в бюджете предмета.
constexpr int MAX_AFFIX_TIER = 9;
inline int MaxAffixTier(int depth)
{
const int t = 1 + (depth > 0 ? depth : 1) / 12;
return (t < MAX_AFFIX_TIER) ? t : MAX_AFFIX_TIER;
}
// Цена тира и бюджет предмета. Числа НЕ те, что стояли в docs/22-loot.md §3:
// там было cost = 6 + 4·T при бюджете 40 + 3·ilvl, и утверждалось, что на
// предмете сотого уровня это даёт «шесть аффиксов девятого тира и ни одним
// больше». На деле давало восемь (340 / 42 = 8.1), а предмет-максимум
// собирался уже к семидесятому этажу.
//
// Пересчитано так, чтобы обещание выполнялось арифметически:
//
// 6 · cost(9) = 294 <= B(100) = 295 < 7 · cost(9) = 343
//
// То есть предельная вещь существует ровно на дне и ровно одна. Проверяется
// инструментом `--tool affix`, а не глазами.
inline float AffixCost(int tier) { return 4.0f + 5.0f * float(tier); }
inline float ItemBudget(int ilvl) { return 45.0f + 2.5f * float(ilvl); }
// Диапазон рулона тега на первом тире. Дальше растёт как T^1.35 — так высокий
// тир заметно лучше низкого, но не превращает предмет в другую игру.
struct AffixRange { float lo, hi; };
inline AffixRange AffixBase(AffixTag t)
{
switch (t)
{
case AffixTag::DAMAGE: return {0.05f, 0.09f};
case AffixTag::PEN: return {0.03f, 0.06f};
case AffixTag::CONVERT: return {0.08f, 0.14f};
case AffixTag::ARMOR: return {0.02f, 0.04f};
case AffixTag::HEALTH: return {4.0f, 8.0f};
case AffixTag::SPEED: return {0.02f, 0.035f};
case AffixTag::SIGHT: return {0.03f, 0.05f};
case AffixTag::AMMO: return {8.0f, 14.0f};
case AffixTag::CARRY: return {0.30f, 0.55f};
case AffixTag::LIGHT: return {18.0f, 30.0f};
default: return {0.0f, 0.0f};
}
}
const char* AffixName(AffixTag t);
// Строка для подсказки: «DAMAGE +14%». Пишет в буфер, потому что подсказки
// собираются на стеке и аллокаций в UI за кадр быть не должно.
void AffixLabel(const Affix& a, char* out, int cap);
// Какие теги вообще ложатся на этот вид предмета. Каталог, а не «на что
// похоже»: конверсия на куртке — это урон, которого у куртки нет.
bool AffixFitsKind(AffixTag t, ItemKind k);
// --- накопленные модификаторы -------------------------------------------------
// Сумма аффиксов надетого. Считается в SyncLoadout один раз за шаг, дальше все
// читают готовые числа: лазать по аффиксам из правил боя нельзя, там про
// предметы не знают вообще.
struct AffixMods
{
float damageMul = 1.0f; // множитель урона оружия
float pen = 0.0f; // пробитие сопротивлений
ElementMix convert; // во что переведена часть урона
float armor = 0.0f;
float hp = 0.0f;
float speedMul = 1.0f;
float sightMul = 1.0f;
float ammo = 0.0f;
float carry = 0.0f;
float light = 0.0f;
void Add(const Affix& a);
void Add(const ItemInstance& it);
};
// Смешать собственную стихию оружия с конверсией аффиксов.
//
// Конверсия ЗАМЕНЯЕТ часть смеси, а не добавляется поверх: у оружия смесь одна
// и сумма долей всегда единица (sim/element.h). Иначе «дробовик с жаром плюс
// 20% холода» давал бы 120% урона, и стихии стали бы каналом наращивания
// чисел — ровно тем, чем они не должны быть.
ElementMix MergeConversion(const ElementMix& base, const ElementMix& convert);
// --- бросок предмета ----------------------------------------------------------
// Сид передаётся снаружи: генерация уровня детерминирована по сиду, и находки
// обязаны быть частью этой же детерминированности. Иначе «тот же уровень» — это
// уже не тот же уровень, и сравнить два прогона нельзя.
ItemInstance RollItem(ItemId base, int depth, uint32_t& rng);
// Редкость по глубине. Отдельно от RollItem, потому что её проверяют
// инструментом на десятках тысяч бросков (`--tool drops`).
Rarity RollRarity(int depth, uint32_t& rng);
// Сколько аффиксов у этой редкости (нижняя и верхняя граница).
int AffixCountFor(Rarity r, uint32_t& rng);