Carry PublicWeenieDesc material type into the live object model so examination titles use the DAT-authored material prefix. Preserve retail AddItemInfo empty appends and embedded armor separator, restoring the deliberate blank rows between appraisal sections. Co-authored-by: Codex <codex@openai.com>
489 lines
21 KiB
C#
489 lines
21 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
|
||
namespace AcDream.Core.Items;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Scaffold for R6 — items + inventory data model.
|
||
// Full research: docs/research/deepdives/r06-items-inventory.md
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// AC's <c>ItemType</c> is a 32-bit flags enum — a single dat weenie can
|
||
/// assert multiple type bits. From <c>ACE.Entity.Enum.ItemType</c>
|
||
/// cross-checked against the decompile paperdoll tooltip dispatcher.
|
||
/// Full bit list in the research doc §1.
|
||
/// </summary>
|
||
[Flags]
|
||
public enum ItemType : uint
|
||
{
|
||
None = 0,
|
||
MeleeWeapon = 0x00000001,
|
||
Armor = 0x00000002,
|
||
Clothing = 0x00000004,
|
||
Jewelry = 0x00000008,
|
||
Creature = 0x00000010,
|
||
Food = 0x00000020,
|
||
Money = 0x00000040,
|
||
Misc = 0x00000080,
|
||
MissileWeapon = 0x00000100,
|
||
Container = 0x00000200,
|
||
Useless = 0x00000400,
|
||
Gem = 0x00000800,
|
||
SpellComponents = 0x00001000,
|
||
Writable = 0x00002000,
|
||
Key = 0x00004000,
|
||
Caster = 0x00008000,
|
||
Portal = 0x00010000,
|
||
Lockable = 0x00020000,
|
||
PromissoryNote = 0x00040000,
|
||
ManaStone = 0x00080000,
|
||
Service = 0x00100000,
|
||
MagicWieldable = 0x00200000,
|
||
CraftCookingBase = 0x00400000,
|
||
CraftAlchemyBase = 0x00800000,
|
||
CraftFletchingBase = 0x01000000,
|
||
CraftAlchemyIntermediate= 0x02000000,
|
||
CraftCookingIntermediate= 0x04000000,
|
||
CraftFletchingIntermediate = 0x08000000,
|
||
LifeStone = 0x10000000,
|
||
TinkeringTool = 0x20000000,
|
||
TinkeringMaterial = 0x40000000,
|
||
Gameboard = 0x80000000u,
|
||
Vestements = Armor | Clothing,
|
||
Weapon = MeleeWeapon | MissileWeapon | Caster,
|
||
WeaponOrCaster = Weapon,
|
||
Item = Weapon | Armor | Clothing | Jewelry | Container,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Equipment slot bitmask — the verbatim retail <c>INVENTORY_LOC</c> enum
|
||
/// (docs/research/named-retail/acclient.h:3193; identical to ACE's EquipMask).
|
||
/// The wire (ValidLocations / CurrentWieldedLocation / WieldObject EquipLoc) delivers
|
||
/// these exact bits. Pinned by EquipMaskTests — do NOT renumber.
|
||
/// (The header's <c>CLOTHING_LOC</c> composite also sets bit 31, 0x80000000, which is
|
||
/// not a named INVENTORY_LOC primitive and has no member here; <c>ALL_LOC</c> tops out at bit 30.)
|
||
/// </summary>
|
||
[Flags]
|
||
public enum EquipMask : uint
|
||
{
|
||
None = 0x00000000,
|
||
HeadWear = 0x00000001,
|
||
ChestWear = 0x00000002,
|
||
AbdomenWear = 0x00000004,
|
||
UpperArmWear = 0x00000008,
|
||
LowerArmWear = 0x00000010,
|
||
HandWear = 0x00000020,
|
||
UpperLegWear = 0x00000040,
|
||
LowerLegWear = 0x00000080,
|
||
FootWear = 0x00000100,
|
||
ChestArmor = 0x00000200,
|
||
AbdomenArmor = 0x00000400,
|
||
UpperArmArmor = 0x00000800,
|
||
LowerArmArmor = 0x00001000,
|
||
UpperLegArmor = 0x00002000,
|
||
LowerLegArmor = 0x00004000,
|
||
NeckWear = 0x00008000,
|
||
WristWearLeft = 0x00010000,
|
||
WristWearRight = 0x00020000,
|
||
FingerWearLeft = 0x00040000,
|
||
FingerWearRight = 0x00080000,
|
||
MeleeWeapon = 0x00100000,
|
||
Shield = 0x00200000,
|
||
MissileWeapon = 0x00400000,
|
||
MissileAmmo = 0x00800000,
|
||
Held = 0x01000000,
|
||
TwoHanded = 0x02000000,
|
||
TrinketOne = 0x04000000,
|
||
Cloak = 0x08000000,
|
||
SigilOne = 0x10000000,
|
||
SigilTwo = 0x20000000,
|
||
SigilThree = 0x40000000,
|
||
}
|
||
|
||
/// <summary>
|
||
/// AC's property model is split across 7 typed tables. See r06 §3 for
|
||
/// the full property enumeration. This struct is a thin wrapper; real
|
||
/// bundles come over the wire in <c>ObjectDesc</c> / <c>IdentifyResponse</c>.
|
||
/// </summary>
|
||
public sealed class PropertyBundle
|
||
{
|
||
public Dictionary<uint, int> Ints { get; } = new();
|
||
public Dictionary<uint, long> Int64s { get; } = new();
|
||
public Dictionary<uint, bool> Bools { get; } = new();
|
||
public Dictionary<uint, double> Floats { get; } = new();
|
||
public Dictionary<uint, string> Strings { get; } = new();
|
||
public Dictionary<uint, uint> DataIds { get; } = new();
|
||
public Dictionary<uint, uint> InstanceIds { get; } = new();
|
||
|
||
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
|
||
public long GetInt64 (uint k, long def = 0) => Int64s.TryGetValue(k, out var v) ? v : def;
|
||
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
|
||
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
|
||
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
|
||
|
||
public PropertyBundle Clone()
|
||
{
|
||
var copy = new PropertyBundle();
|
||
foreach (var kv in Ints) copy.Ints[kv.Key] = kv.Value;
|
||
foreach (var kv in Int64s) copy.Int64s[kv.Key] = kv.Value;
|
||
foreach (var kv in Bools) copy.Bools[kv.Key] = kv.Value;
|
||
foreach (var kv in Floats) copy.Floats[kv.Key] = kv.Value;
|
||
foreach (var kv in Strings) copy.Strings[kv.Key] = kv.Value;
|
||
foreach (var kv in DataIds) copy.DataIds[kv.Key] = kv.Value;
|
||
foreach (var kv in InstanceIds) copy.InstanceIds[kv.Key] = kv.Value;
|
||
return copy;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-object live state (the data side of every server object — items and creatures alike).
|
||
/// Retail <c>ACCWeenieObject</c>.
|
||
/// </summary>
|
||
public sealed class ClientObject
|
||
{
|
||
public uint ObjectId { get; init; }
|
||
public uint WeenieClassId { get; set; } // "blueprint"
|
||
public string Name { get; set; } = "";
|
||
/// <summary>Retail <c>PublicWeenieDesc._plural_name</c>; empty falls back to singular.</summary>
|
||
public string PluralName { get; set; } = "";
|
||
public ItemType Type { get; set; }
|
||
public EquipMask ValidLocations { get; set; }
|
||
public EquipMask CurrentlyEquippedLocation { get; set; }
|
||
public uint IconId { get; set; } // 0x06xxxxxx
|
||
public uint IconUnderlayId{ get; set; } // "magic" underlay
|
||
public uint IconOverlayId { get; set; } // "enchanted" overlay
|
||
/// <summary>
|
||
/// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183).
|
||
/// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000).
|
||
/// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE);
|
||
/// appraise never carries it. 0 = no effect.
|
||
/// </summary>
|
||
public uint Effects { get; set; }
|
||
public int StackSize { get; set; } = 1;
|
||
public int StackSizeMax { get; set; } = 1;
|
||
public int Burden { get; set; } // per-stack total
|
||
public int Value { get; set; } // pyreals
|
||
public uint ContainerId { get; set; } // parent container ObjectId, or 0
|
||
public int ContainerSlot { get; set; } = -1;
|
||
/// <summary>
|
||
/// Retail <c>ContentProfile.m_uContainerProperties</c> hint from
|
||
/// PlayerDescription/ViewContents: 0 = loose item list, nonzero = container list.
|
||
/// Kept separate from <see cref="Type"/> because CreateObject owns real item data.
|
||
/// </summary>
|
||
public uint ContainerTypeHint { get; set; }
|
||
public bool Attuned { get; set; }
|
||
public bool Bonded { get; set; }
|
||
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
|
||
public int ItemsCapacity { get; set; } // main-pack slots (containers)
|
||
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
|
||
/// <summary>Retail <c>PublicWeenieDesc._hook_item_types</c>.</summary>
|
||
public uint HookItemTypes { get; set; }
|
||
/// <summary>Retail <c>PublicWeenieDesc._hook_type</c>.</summary>
|
||
public uint HookType { get; set; }
|
||
/// <summary>
|
||
/// Port of <c>ACCWeenieObject::IsHook @ 0x0058C660</c>.
|
||
/// A hook must publish both a hook type and an accepted item-type mask.
|
||
/// </summary>
|
||
public bool IsHook => HookType != 0u && HookItemTypes != 0u;
|
||
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
|
||
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
|
||
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
|
||
/// <summary>Retail <c>PublicWeenieDesc._bitfield</c>; null until CreateObject supplies it.</summary>
|
||
public uint? PublicWeenieBitfield { get; set; }
|
||
/// <summary>
|
||
/// Retail <c>PublicWeenieDesc._pet_owner</c>; zero means this creature is not a pet.
|
||
/// CreateObject second-header flag <c>PWD2_Packed_PetOwner (0x8)</c> supplies it.
|
||
/// </summary>
|
||
public uint PetOwnerId { get; set; }
|
||
/// <summary>Retail <c>PublicWeenieDesc._combatUse</c>; null when its header flag was absent.</summary>
|
||
public byte? CombatUse { get; set; }
|
||
/// <summary>Retail <c>PublicWeenieDesc._ammoType</c>; zero is <c>AMMO_NONE</c>.</summary>
|
||
public ushort? AmmoType { get; set; }
|
||
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
|
||
public uint? SpellId { get; set; }
|
||
/// <summary>
|
||
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
|
||
/// shared item-cooldown group whose player enchantment id is
|
||
/// <c>CooldownId + 0x8000</c>.
|
||
/// </summary>
|
||
public uint? CooldownId { get; set; }
|
||
/// <summary>
|
||
/// Retail <c>PublicWeenieDesc._cooldown_duration</c>, in seconds. This is
|
||
/// the denominator used by <c>UIElement_UIItem::UpdateCooldownDisplay</c>.
|
||
/// </summary>
|
||
public double? CooldownDuration { get; set; }
|
||
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
|
||
public int TradeState { get; set; }
|
||
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
|
||
public bool IsComponentPack { get; set; }
|
||
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
|
||
public byte? RadarBlipColor { get; set; }
|
||
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
|
||
public byte? RadarBehavior { get; set; }
|
||
public int Structure { get; set; } // charges/uses remaining
|
||
public int MaxStructure { get; set; }
|
||
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
|
||
/// <summary>
|
||
/// Retail <c>PublicWeenieDesc._material_type</c>. The appropriate-name
|
||
/// path resolves this through the DAT material enum and prefixes it to
|
||
/// the base object name.
|
||
/// </summary>
|
||
public uint? MaterialType { get; set; }
|
||
public PropertyBundle Properties { get; } = new();
|
||
|
||
/// <summary>
|
||
/// Ports the singular/plural selection inside
|
||
/// <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0</c>.
|
||
/// Stacked objects prefer <see cref="PluralName"/>; when the wire omitted it,
|
||
/// retail appends <c>s</c>, or <c>es</c> when the singular already ends in
|
||
/// lowercase s. DAT-backed material decoration belongs to the presentation
|
||
/// resolver because Core intentionally has no content-reader dependency.
|
||
/// </summary>
|
||
public string GetAppropriateName()
|
||
{
|
||
if (StackSize <= 1) return Name;
|
||
if (!string.IsNullOrEmpty(PluralName)) return PluralName;
|
||
if (string.IsNullOrEmpty(Name)) return Name;
|
||
return Name[^1] == 's' ? Name + "es" : Name + "s";
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The wire-delivered patch from a <c>CreateObject</c> (0xF745). Nullable fields
|
||
/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert
|
||
/// (ClientObjectTable.Ingest) leaves the existing value untouched
|
||
/// for those, matching retail's <c>SetWeenieDesc</c> (patches only present fields).
|
||
/// Non-nullable id/effect fields use 0 = "not sent". Effects is assigned
|
||
/// unconditionally (0 clears) — the D.5.2 icon contract. Quantity fields are
|
||
/// int? (ACE PropertyInt convention); id/mask fields are uint?.
|
||
/// </summary>
|
||
public readonly record struct WeenieData(
|
||
uint Guid,
|
||
string? Name,
|
||
ItemType? Type,
|
||
uint WeenieClassId,
|
||
uint IconId,
|
||
uint IconOverlayId,
|
||
uint IconUnderlayId,
|
||
uint Effects,
|
||
int? Value,
|
||
int? StackSize,
|
||
int? StackSizeMax,
|
||
int? Burden,
|
||
uint? ContainerId,
|
||
uint? WielderId,
|
||
uint? ValidLocations,
|
||
uint? CurrentWieldedLocation,
|
||
uint? Priority,
|
||
int? ItemsCapacity,
|
||
int? ContainersCapacity,
|
||
int? Structure,
|
||
int? MaxStructure,
|
||
float? Workmanship,
|
||
uint? Useability = null,
|
||
uint? TargetType = null,
|
||
byte? RadarBlipColor = null,
|
||
byte? RadarBehavior = null,
|
||
uint? PublicWeenieBitfield = null,
|
||
byte? CombatUse = null,
|
||
string? PluralName = null,
|
||
uint? PetOwnerId = null,
|
||
ushort? AmmoType = null,
|
||
uint? SpellId = null,
|
||
uint? CooldownId = null,
|
||
double? CooldownDuration = null,
|
||
uint? HookItemTypes = null,
|
||
uint? HookType = null,
|
||
uint? MaterialType = null);
|
||
|
||
/// <summary>
|
||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||
/// Low 16 bits describe where the source may be used from; high 16 bits
|
||
/// describe where the acquired target may be.
|
||
/// </summary>
|
||
public static class ItemUseability
|
||
{
|
||
public const uint Undef = 0x0u;
|
||
public const uint No = 0x1u;
|
||
public const uint Self = 0x2u;
|
||
public const uint Wielded = 0x4u;
|
||
public const uint Contained = 0x8u;
|
||
public const uint Viewed = 0x10u;
|
||
public const uint Remote = 0x20u;
|
||
public const uint NeverWalk = 0x40u;
|
||
public const uint ObjSelf = 0x80u;
|
||
|
||
public const uint SourceMask = 0x0000FFFFu;
|
||
public const uint TargetMask = 0xFFFF0000u;
|
||
|
||
public static bool IsTargeted(uint useability)
|
||
=> (useability & TargetMask) != 0;
|
||
|
||
public static bool AllowsSelfTarget(uint useability)
|
||
=> ((useability >> 16) & Self) != 0;
|
||
|
||
public static bool AllowsObjectSelfTarget(uint useability)
|
||
=> ((useability >> 16) & ObjSelf) != 0;
|
||
|
||
public static uint SourceFlags(uint useability)
|
||
=> useability & SourceMask;
|
||
|
||
/// <summary>Retail <c>ItemUses::GetLeastLimitedSourceUse</c>.</summary>
|
||
public static uint LeastLimitedSourceUse(uint useability)
|
||
{
|
||
uint s = SourceFlags(useability);
|
||
if ((s & Remote) != 0) return Remote;
|
||
if ((s & Viewed) != 0) return Viewed;
|
||
if ((s & Contained) != 0) return Contained;
|
||
if ((s & Wielded) != 0) return Wielded;
|
||
if ((s & Self) != 0) return Self;
|
||
return Undef;
|
||
}
|
||
|
||
public static uint TargetFlags(uint useability)
|
||
=> (useability & TargetMask) >> 16;
|
||
|
||
/// <summary>
|
||
/// Retail <c>ItemUses::IsUseable @ 0x004FCCC0</c>. The implementation
|
||
/// complements the full bitfield and returns its low bit, so only
|
||
/// <c>USEABLE_NO</c> disables use. In particular, the default
|
||
/// <c>USEABLE_UNDEF</c> value zero is usable.
|
||
/// </summary>
|
||
public static bool IsUseable(uint useability)
|
||
=> (useability & No) == 0;
|
||
|
||
/// <summary>
|
||
/// Retail <c>ItemUses::GetLeastLimitedTargetUse</c> (0x004fcd50): the most
|
||
/// permissive target-use bit present, priority Remote > Viewed >
|
||
/// Contained > Wielded > Self > ObjSelf. The target-compat gate
|
||
/// (<c>ItemHolder::IsTargetCompatibleWithTargetingObject</c> 0x00588070)
|
||
/// only enforces a location constraint when the BEST available bit is
|
||
/// Contained or Wielded.
|
||
/// </summary>
|
||
public static uint LeastLimitedTargetUse(uint useability)
|
||
{
|
||
uint t = TargetFlags(useability);
|
||
if ((t & Remote) != 0) return Remote;
|
||
if ((t & Viewed) != 0) return Viewed;
|
||
if ((t & Contained) != 0) return Contained;
|
||
if ((t & Wielded) != 0) return Wielded;
|
||
if ((t & Self) != 0) return Self;
|
||
return t & ObjSelf;
|
||
}
|
||
|
||
public static bool IsDirectUseable(uint useability)
|
||
=> !IsTargeted(useability) && IsUseable(useability);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
|
||
/// → side packs; a side pack cannot hold another side pack (r06 §7).
|
||
/// </summary>
|
||
public sealed class Container
|
||
{
|
||
public uint ObjectId { get; init; }
|
||
public int Capacity { get; set; } = 102; // main inv default
|
||
public int SideCapacity { get; set; } = 0; // 0 for side-pack
|
||
public int BurdenLimit { get; set; }
|
||
public List<ClientObject> Items { get; } = new();
|
||
public List<Container> SidePacks { get; } = new(); // empty for side-pack
|
||
public bool IsSidePack => SideCapacity == 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Burden math — r06 §6. <c>maxBurden = 150 × Strength + Strength × bonusBurden</c>;
|
||
/// carry limit is <c>3 × maxBurden</c> before you can't pick up at all.
|
||
/// </summary>
|
||
public static class BurdenMath
|
||
{
|
||
public const int BurdenPerStrength = 150;
|
||
|
||
/// <summary>Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e).</summary>
|
||
public const int AugBurdenPerRank = 30;
|
||
/// <summary>Max augmentation bonus-burden contribution per Strength (retail clamp 0x96).</summary>
|
||
public const int AugBurdenCap = 150;
|
||
|
||
/// <summary>
|
||
/// Retail <c>EncumbranceSystem::EncumbranceCapacity(strength, aug)</c>
|
||
/// (named-retail decomp 256393, <c>0x004fcc00</c>):
|
||
/// <c>strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength</c>.
|
||
/// </summary>
|
||
public static int EncumbranceCapacity(int strength, int aug)
|
||
{
|
||
if (strength <= 0) return 0;
|
||
int bonus = aug * AugBurdenPerRank;
|
||
if (bonus < 0) bonus = 0; // decomp: eax_2 < 0 -> base only
|
||
if (bonus > AugBurdenCap) bonus = AugBurdenCap;
|
||
return strength * BurdenPerStrength + bonus * strength;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>EncumbranceSystem::Load(capacity, burden)</c> (decomp 256413,
|
||
/// <c>0x004fcc40</c>): the encumbrance ratio <c>burden / capacity</c>
|
||
/// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to
|
||
/// <c>return burden</c>; the <c>cap <= 0</c> guard + single divide is unambiguous.
|
||
/// Returns 0 when capacity <= 0 (no-data).
|
||
/// </summary>
|
||
public static float LoadRatio(int capacity, int burden)
|
||
=> capacity <= 0 ? 0f : (float)burden / capacity;
|
||
|
||
/// <summary>
|
||
/// Retail <c>EncumbranceSystem::LoadMod @ 0x004FCC70</c>: full
|
||
/// effectiveness through 100% load, a linear falloff to zero at 200%,
|
||
/// then zero above it.
|
||
/// </summary>
|
||
public static float LoadModifier(float load)
|
||
=> load <= 1f ? 1f : load < 2f ? 2f - load : 0f;
|
||
|
||
/// <summary>
|
||
/// Percentage printed by <c>gmCharacterInfoUI::UpdateLoad @ 0x004B8A20</c>.
|
||
/// Retail truncates <c>LoadMod * 10</c> before converting the ten steps to
|
||
/// a percentage.
|
||
/// </summary>
|
||
public static int LoadPenaltyPercent(float load)
|
||
=> (10 - (int)(LoadModifier(load) * 10f)) * 10;
|
||
|
||
/// <summary>
|
||
/// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
|
||
/// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at
|
||
/// 100% capacity, full at 300%.
|
||
/// </summary>
|
||
public static float LoadToFill(float load)
|
||
{
|
||
float fill = load / 3f;
|
||
if (fill < 0f) return 0f;
|
||
return fill > 1f ? 1f : fill;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>gmBackpackUI::SetLoadLevel</c> percent text (decomp 176542-176576): the
|
||
/// percent is computed from the CLAMPED fill — <c>arg2 = load/3</c> is clamped to [0,1]
|
||
/// (the 176544-176563 block sets <c>arg2 = 1.0</c> when fill >= 1) BEFORE
|
||
/// <c>floor(arg2 * 300)</c>. So the number SATURATES at 300% (it does NOT read 400% at
|
||
/// 4x capacity). Equivalent to <c>floor(LoadToFill(load) * 300)</c>.
|
||
/// </summary>
|
||
public static int LoadToPercent(float load)
|
||
=> (int)System.MathF.Floor(LoadToFill(load) * 300f);
|
||
|
||
public static int ComputeMax(int strength, int bonusBurden)
|
||
=> BurdenPerStrength * strength + strength * bonusBurden;
|
||
|
||
public static int ComputeCarryLimit(int strength, int bonusBurden)
|
||
=> 3 * ComputeMax(strength, bonusBurden);
|
||
|
||
/// <summary>
|
||
/// Retail's "encumbered" multiplier interpolates between 1.0 at
|
||
/// zero burden and a low value at max. See r06 §6 for the curve.
|
||
/// </summary>
|
||
public static float ComputeEncumbranceMod(int currentBurden, int maxBurden)
|
||
{
|
||
if (maxBurden <= 0) return 1f;
|
||
float ratio = (float)currentBurden / maxBurden;
|
||
// Roughly 1.0 until 50%, then linear decay to ~0.7 at 100%, 0.1 at 300%.
|
||
if (ratio <= 0.5f) return 1f;
|
||
if (ratio <= 1.0f) return 1f - (ratio - 0.5f) * 0.6f; // 1.0 → 0.7
|
||
if (ratio <= 3.0f) return 0.7f - (ratio - 1.0f) * 0.3f; // 0.7 → 0.1
|
||
return 0.1f;
|
||
}
|
||
}
|