squad-proto/src/sim/affix.cpp

284 lines
12 KiB
C++
Raw Normal View History

Редкости и аффиксы: находка стала решением (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 06:35:00 +07:00
#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;
}