Aetheria/Assets/Scripts/ServerShared/ItemManager.cs
Meta Vens 05b5a209db Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.

R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.

ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.

Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).

64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00

234 lines
8.6 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using Random = CultMath.Random;
using JM.LinqFaster;
using UniRx;
using float4 = CultMath.float4;
public class ItemManager
{
public Random Random = new Random((uint) (DateTime.Now.Ticks%uint.MaxValue));
private Action<string> _logger;
public CultCache ItemData { get; }
public ProvenanceLedger Lots { get; }
public GameplaySettings GameplaySettings { get; }
public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> logger)
{
ItemData = itemData;
Lots = lots;
GameplaySettings = settings;
_logger = logger;
}
public void Log(string s)
{
_logger(s);
}
// The one resolution path from an item instance to its design
public ItemData GetData(ItemInstance item) => ItemData.Get(item.Data);
public SimpleCommodityData GetData(SimpleCommodity item) => GetData((ItemInstance) item) as SimpleCommodityData;
public CraftedItemData GetData(CraftedItemInstance item) => GetData((ItemInstance) item) as CraftedItemData;
public EquippableItemData GetData(EquippableItem item) => GetData((ItemInstance) item) as EquippableItemData;
public float GetMass(ItemInstance item)
{
return item switch
{
CraftedItemInstance _ => GetData(item).Mass,
SimpleCommodity commodity => GetData(item).Mass * commodity.Quantity,
_ => 0
};
}
public float GetThermalMass(ItemInstance item)
{
var data = GetData(item);
return item switch
{
CraftedItemInstance _ => data.Mass * data.SpecificHeat,
SimpleCommodity commodity => data.Mass * data.SpecificHeat * commodity.Quantity,
_ => 0
};
}
// The one resolution path from a crafted instance to the lot it was minted from.
public Lot GetLot(CraftedItemInstance item) => Lots[item.Lot];
// Returns stat when not equipped: a null-entity context with no heat, no power and no modifiers. It says so
// in its own name (UnequippedStatContext) rather than being a third quiet dialect of the arithmetic.
public float Evaluate(PerformanceStat stat, EquippableItem item)
{
var data = GetData(item);
var result = stat.Evaluate(new UnequippedStatContext(this, item, data));
if (float.IsNaN(result))
throw new InvalidOperationException($"Performance Stat on {data.Name} evaluating as NaN: input data is invalid! Durability: {item.Durability} / {data.Durability}");
return result;
}
// The unequipped case: quality and durability read live off the item and its lot; heat and modifiers do not
// exist without an entity, so they resolve as the identity for their term rather than a bespoke formula.
private sealed class UnequippedStatContext : IStatContext
{
private readonly EquippableItem _item;
private readonly EquippableItemData _data;
private readonly float _durabilityExponent;
public Lot Lot { get; }
public UnequippedStatContext(ItemManager items, EquippableItem item, EquippableItemData data)
{
_item = item;
_data = data;
Lot = items.GetLot(item);
_durabilityExponent = lerp(
items.GameplaySettings.DurabilityQualityMin,
items.GameplaySettings.DurabilityQualityMax,
pow(Lot.Quality, items.GameplaySettings.DurabilityQualityExponent));
}
public float HeatFactor(float exponent) => 1f;
public float DurabilityFactor(float exponent) =>
pow(_item.Durability / _data.Durability, _durabilityExponent * exponent);
public float ConsumableProgressFactor(float exponent) => 1f;
public float PowerSupplyFactor(float exponent) => 1f;
public float ScaleModifier(PerformanceStat stat) => 1f;
public float ConstantModifier(PerformanceStat stat) => 0f;
}
public int GetPrice(CraftedItemInstance item)
{
var data = GetData(item);
return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price);
}
// The tier roll every mint applies, whether or not the lot fills roles.
public float RollQuality()
{
var quality = Random.NextFloat();
var tier = GameplaySettings.Tiers[0];
foreach (var t in GameplaySettings.Tiers)
{
if (t.Rarity > quality)
tier = t;
}
return tier.Quality;
}
// Builds one of a manufacturer's branded products: the unit's own workmanship rolled as before, and each of
// the design's roles filled with a part whose quality is drawn from that manufacturer's distribution for it.
public int CreateLot(FactionProductData product)
{
var design = ItemData.Get(product.Design);
var lot = new Lot
{
Design = ItemData.RefOf<ItemData>(design),
Origin = new Attributed { Faction = product.Manufacturer },
Quality = RollQuality(),
Roles = new List<RoleFill>()
};
if (design.Roles != null)
foreach (var role in design.Roles)
{
var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
lot.Roles.Add(new RoleFill
{
Role = role.Name,
Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
});
}
return Lots.Add(lot);
}
// No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`.
public int CreateLot(CraftedItemData design, CultRecordRef<Faction> maker, float quality)
{
var lot = new Lot
{
Design = ItemData.RefOf<ItemData>(design),
Origin = new Attributed { Faction = maker },
Quality = quality
};
return Lots.Add(lot);
}
// The one writer of a crafted instance's Data: copied from the lot's design, never independently set.
public CraftedItemInstance CreateInstance(int lot)
{
var l = Lots[lot];
var design = ItemData.Get(l.Design) as CraftedItemData;
if (design is EquippableItemData equippableItemData)
{
return new EquippableItem
{
Data = l.Design, Lot = lot, Durability = equippableItemData.Durability
};
}
return new CompoundCommodity
{
Data = l.Design, Lot = lot
};
}
public CraftedItemInstance CreateInstance(FactionProductData product)
{
var design = ItemData.Get(product.Design);
if (design == null)
{
_logger($"Product {product.Name} names a design that does not exist!");
return null;
}
return CreateInstance(CreateLot(product));
}
// Who made an item and what it is branded as, derived from its lot's provenance. Null maker (an unset faction,
// or an Extracted lot with none) means no brand at all; a set maker with no matching product still shows the
// maker, with no product name or flavour text.
public (Faction Maker, FactionProductData Product) Brand(CraftedItemInstance item)
{
var lot = GetLot(item);
var maker = lot.Origin switch
{
Attributed attributed => attributed.Faction,
Produced produced => produced.Faction,
_ => default
};
if (!maker.IsSet()) return (null, null);
var product = ItemData.GetAll<FactionProductData>()
.Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))
.OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)
.FirstOrDefault();
return (ItemData.Get(maker), product);
}
public (RarityTier tier, int upgrades) GetTier(CraftedItemInstance item)
{
var quality = GetLot(item).Quality;
var tier = GameplaySettings.Tiers[0];
foreach (var t in GameplaySettings.Tiers)
if (quality + .001f > t.Quality)
tier = t;
int upgrades = (int) ((quality - tier.Quality) / .0499f);
return (tier, upgrades);
}
}