squad-proto/src/sim/affix.cpp
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

284 lines
12 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.

#include "sim/affix.h"
#include <cmath>
#include <cstdio>
namespace
{
// Свой генератор, а не <random>: броски обязаны быть одинаковыми на всех
// платформах и сборках. `std::uniform_real_distribution` этого не обещает, и
// «тот же сид — тот же уровень» на нём разъезжается между компиляторами.
uint32_t NextRand(uint32_t& s)
{
s = s * 1664525u + 1013904223u;
return s;
}
float Frand(uint32_t& s) { return float(NextRand(s) & 0xFFFFFF) / float(0x1000000); }
int RandRange(uint32_t& s, int lo, int hi) // [lo, hi]
{
if (hi <= lo) return lo;
return lo + int(NextRand(s) % uint32_t(hi - lo + 1));
}
// Теги, которые ложатся на оружие, броню и карман. Списком, потому что это
// каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на
// таблицу, а не выводя из вида предмета.
const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT,
AffixTag::AMMO, AffixTag::SPEED};
const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED,
AffixTag::CARRY, AffixTag::AMMO};
const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH,
AffixTag::SPEED, AffixTag::CARRY};
} // namespace
const char* AffixName(AffixTag t)
{
switch (t)
{
case AffixTag::DAMAGE: return "DAMAGE";
case AffixTag::PEN: return "PIERCE";
case AffixTag::CONVERT: return "CONVERT";
case AffixTag::ARMOR: return "ARMOR";
case AffixTag::HEALTH: return "HEALTH";
case AffixTag::SPEED: return "SPEED";
case AffixTag::SIGHT: return "SIGHT";
case AffixTag::AMMO: return "ROUNDS";
case AffixTag::CARRY: return "CARRY";
case AffixTag::LIGHT: return "LIGHT";
default: return "-";
}
}
void AffixLabel(const Affix& a, char* out, int cap)
{
if (!out || cap <= 0) return;
out[0] = '\0';
switch (a.tag)
{
// Проценты — там, где число и есть доля. Смешивать «+14%» и «+14» в
// одном списке нельзя: игрок читает подсказку целиком и сравнивает
// строки между собой.
case AffixTag::DAMAGE:
case AffixTag::PEN:
case AffixTag::ARMOR:
case AffixTag::SPEED:
case AffixTag::SIGHT:
std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag),
int(a.value * 100.0f + 0.5f), int(a.tier));
break;
case AffixTag::CONVERT:
std::snprintf(out, size_t(cap), "%d%% DAMAGE AS %s T%d",
int(a.value * 100.0f + 0.5f), ElementName(Element(a.param)),
int(a.tier));
break;
case AffixTag::CARRY:
std::snprintf(out, size_t(cap), "CARRY +%.1f T%d", double(a.value), int(a.tier));
break;
default:
std::snprintf(out, size_t(cap), "%s +%d T%d", AffixName(a.tag), int(a.value + 0.5f),
int(a.tier));
break;
}
}
bool AffixFitsKind(AffixTag t, ItemKind k)
{
auto has = [&](const AffixTag* list, int n) {
for (int i = 0; i < n; ++i)
if (list[i] == t) return true;
return false;
};
switch (k)
{
case ItemKind::FIREARM:
case ItemKind::BLADE: return has(WEAPON_TAGS, int(sizeof(WEAPON_TAGS) / sizeof(AffixTag)));
case ItemKind::ARMOR: return has(ARMOR_TAGS, int(sizeof(ARMOR_TAGS) / sizeof(AffixTag)));
case ItemKind::TRINKET: return has(TRINKET_TAGS, int(sizeof(TRINKET_TAGS) / sizeof(AffixTag)));
// Расходники не катаются. Бинт с аффиксами — это не решение, а шум:
// его применяют автоматически и выбрасывают.
default: return false;
}
}
void AffixMods::Add(const Affix& a)
{
switch (a.tag)
{
case AffixTag::DAMAGE: damageMul += a.value; break;
case AffixTag::PEN: pen += a.value; break;
case AffixTag::ARMOR: armor += a.value; break;
case AffixTag::HEALTH: hp += a.value; break;
case AffixTag::SPEED: speedMul += a.value; break;
case AffixTag::SIGHT: sightMul += a.value; break;
case AffixTag::AMMO: ammo += a.value; break;
case AffixTag::CARRY: carry += a.value; break;
case AffixTag::LIGHT: light += a.value; break;
case AffixTag::CONVERT:
{
// Конверсии складываются между собой, а кинетика — это остаток.
// Складывать смеси иначе (перемножением долей) значило бы, что два
// аффикса по 20% дают 36%, и игрок никогда не сойдётся с подсказкой.
const int e = int(a.param);
if (e <= 0 || e >= ELEMENT_COUNT) break;
convert.share[e] += a.value;
float sum = 0.0f;
for (int i = 1; i < ELEMENT_COUNT; ++i) sum += convert.share[i];
if (sum > 0.85f) // кинетика не исчезает никогда (sim/element.h)
{
const float k = 0.85f / sum;
for (int i = 1; i < ELEMENT_COUNT; ++i) convert.share[i] *= k;
sum = 0.85f;
}
convert.share[int(Element::KIN)] = 1.0f - sum;
break;
}
default: break;
}
}
void AffixMods::Add(const ItemInstance& it)
{
for (int i = 0; i < it.Count(); ++i) Add(it.affix[i]);
}
ElementMix MergeConversion(const ElementMix& base, const ElementMix& convert)
{
// Доля, которую забрала конверсия, вычитается из КИНЕТИКИ оружия, а не из
// его собственной стихии: дробовик с жаром, на который выкатили холод,
// остаётся зажигательным — просто теперь ещё и холодным.
float taken = 0.0f;
for (int e = 1; e < ELEMENT_COUNT; ++e) taken += convert.share[e];
if (taken <= 0.0f) return base;
ElementMix out = base;
const float kin = base.share[int(Element::KIN)];
const float use = (taken < kin) ? taken : kin; // кинетики меньше, чем забирают
const float k = (taken > 0.0f) ? use / taken : 0.0f;
for (int e = 1; e < ELEMENT_COUNT; ++e) out.share[e] += convert.share[e] * k;
out.share[int(Element::KIN)] = kin - use;
return out;
}
Rarity RollRarity(int depth, uint32_t& rng)
{
// Шанс «не обычного» растёт с глубиной и упирается в 0.88. Потолок нужен:
// без него на дне обычных вещей не остаётся вовсе, и редкость перестаёт
// что-либо значить — сравнивать станет не с чем.
//
// Наклон 0.011 подобран замером (`--tool drops`), а не взят из документа:
// в docs/22-loot.md §1 формула и таблица противоречили друг другу. Формула
// (0.02 + 0.0075·d) давала на первом этаже 2.7% небанальных находок, а
// таблица рядом — 22%. Выбрана формула, потому что первый этаж обязан быть
// скучным по замыслу игры, а наклон поднят так, чтобы сойтись с таблицей на
// дне: 12% обычных на сотом этаже.
const float d = float(depth > 0 ? depth : 1);
const float pRare = std::min(0.88f, 0.02f + 0.011f * d);
if (Frand(rng) >= pRare) return Rarity::COMMON;
// Внутри «не обычного» — своя лестница, тоже по глубине: реликты внизу
// становятся обычным делом, наверху их нет вовсе.
const float r = Frand(rng);
const float pRelic = std::min(0.30f, 0.004f * d);
const float pProto = std::min(0.45f, 0.06f + 0.006f * d);
if (r < pRelic) return Rarity::RELIC;
if (r < pRelic + pProto) return Rarity::PROTOTYPE;
return Rarity::MARKED;
}
int AffixCountFor(Rarity r, uint32_t& rng)
{
switch (r)
{
case Rarity::MARKED: return RandRange(rng, 1, 2);
case Rarity::PROTOTYPE: return RandRange(rng, 3, 4);
case Rarity::RELIC: return RandRange(rng, 5, 6);
default: return 0;
}
}
ItemInstance RollItem(ItemId base, int depth, uint32_t& rng)
{
ItemInstance it(base);
if (base == ItemId::NONE) return it;
const ItemDef def = ItemDefOf(base);
it.rarity = AffixFitsKind(AffixTag::DAMAGE, def.kind) ||
AffixFitsKind(AffixTag::ARMOR, def.kind) ||
AffixFitsKind(AffixTag::SIGHT, def.kind)
? RollRarity(depth, rng)
: Rarity::COMMON;
int want = AffixCountFor(it.rarity, rng);
if (want <= 0) return it;
// Уровень предмета — это глубина, на которой он найден. Отсюда и бюджет:
// одна и та же куртка внизу держит больше аффиксов, чем наверху.
float budget = ItemBudget(depth);
const int tierCap = MaxAffixTier(depth);
// Список тегов, доступных этому виду. Порядок перемешивается броском, а не
// берётся как есть: иначе первый тег в таблице оказывался бы на каждом
// предмете в игре.
AffixTag pool[int(AffixTag::COUNT)];
int poolCount = 0;
for (int t = 1; t < int(AffixTag::COUNT); ++t)
if (AffixFitsKind(AffixTag(t), def.kind)) pool[poolCount++] = AffixTag(t);
if (poolCount == 0) return it;
for (int i = poolCount - 1; i > 0; --i)
{
const int j = RandRange(rng, 0, i);
const AffixTag tmp = pool[i];
pool[i] = pool[j];
pool[j] = tmp;
}
if (want > poolCount) want = poolCount; // один тег один раз (правило 2)
if (want > MAX_AFFIXES) want = MAX_AFFIXES;
for (int i = 0; i < want; ++i)
{
// Тир — случайный до потолка глубины, но не дороже остатка бюджета.
// Именно так бюджет и ограничивает: не «нельзя шесть высших», а
// «на шестой уже не хватило».
int tier = RandRange(rng, 1, tierCap);
while (tier > 1 && AffixCost(tier) > budget) --tier;
if (AffixCost(tier) > budget) break;
budget -= AffixCost(tier);
const AffixRange base_ = AffixBase(pool[i]);
const float scale = std::pow(float(tier), 1.35f);
Affix a;
a.tag = pool[i];
a.tier = uint8_t(tier);
a.value = (base_.lo + (base_.hi - base_.lo) * Frand(rng)) * scale;
// Стихия конверсии — любая, кроме кинетики: «перевести урон в
// кинетику» не значит ничего.
if (a.tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1));
it.affix[it.count++] = a;
}
// Аффиксов могло влезть меньше, чем обещала редкость. Понижаем её до
// фактического содержимого: «реликт» с двумя аффиксами — это обман,
// причём тот самый, который игрок замечает первым.
if (it.count <= 0) it.rarity = Rarity::COMMON;
else if (it.count <= 2) it.rarity = Rarity::MARKED;
else if (it.count <= 4) it.rarity = Rarity::PROTOTYPE;
else it.rarity = Rarity::RELIC;
return it;
}