Предмет перестал быть строкой каталога. 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>
284 lines
12 KiB
C++
284 lines
12 KiB
C++
#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;
|
||
}
|