Add daemon item stat query refs

This commit is contained in:
Meta Vens 2026-06-21 19:59:30 +01:00
commit c89f8ea480
5 changed files with 564 additions and 11 deletions

View file

@ -4076,22 +4076,48 @@ static void RequireInventoryShipSettingsUseEveSurface(string root)
static void RequireInventoryCargoItemDetailsUseEveSurface(string root)
{
var inventoryMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "InventoryMenu.cs");
var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs");
var surfaceDocumentPath = Path.Combine(
root,
"Packages",
"org.gamecult.aetheria.state",
"Runtime",
"AetheriaRuntimePlayerSettingsSurfaceBuilder.cs");
var daemonItemStatQueriesPath = Path.Combine(
root,
"Packages",
"org.gamecult.aetheria.state",
"Runtime",
"AetheriaRuntimeDaemonItemStatQueries.cs");
var cargoItemSurfaceBuilderPath = Path.Combine(
root,
"Packages",
"org.gamecult.aetheria.state",
"Runtime",
"AetheriaRuntimeCargoItemDetailsSurfaceBuilder.cs");
if (!File.Exists(inventoryMenuPath))
var requiredFiles = new[]
{
throw new InvalidOperationException("Cannot verify inventory cargo-item shell; InventoryMenu.cs is missing.");
}
if (!File.Exists(cargoItemSurfaceBuilderPath))
inventoryMenuPath,
actionGameManagerPath,
surfaceDocumentPath,
daemonItemStatQueriesPath,
cargoItemSurfaceBuilderPath
};
var missingFiles = requiredFiles
.Where(path => !File.Exists(path))
.Select(path => Path.GetRelativePath(root, path))
.ToArray();
if (missingFiles.Length > 0)
{
throw new InvalidOperationException("Cannot verify inventory cargo-item shell; AetheriaRuntimeCargoItemDetailsSurfaceBuilder.cs is missing.");
throw new InvalidOperationException(
"Cannot verify inventory cargo-item shell; required files are missing: " +
string.Join(", ", missingFiles));
}
var source = File.ReadAllText(inventoryMenuPath);
var actionGameManager = File.ReadAllText(actionGameManagerPath);
var surfaceDocument = File.ReadAllText(surfaceDocumentPath);
var daemonItemStatQueries = File.ReadAllText(daemonItemStatQueriesPath);
var cargoItemSurfaceBuilder = File.ReadAllText(cargoItemSurfaceBuilderPath);
var requiredSymbols = new[]
{
@ -4123,6 +4149,26 @@ static void RequireInventoryCargoItemDetailsUseEveSurface(string root)
throw new InvalidOperationException("InventoryMenu cargo click path no longer routes item inspection through the Eve surface.");
}
if (!source.Contains("AetheriaRuntimeDaemonItemStatQueries.EvaluatePerformanceStat(", StringComparison.Ordinal) ||
source.Contains("GameManager.ItemManager.GetTier", StringComparison.Ordinal) ||
source.Contains("GameManager.ItemManager.Evaluate", StringComparison.Ordinal) ||
actionGameManager.Contains("ObservedItemStat(", StringComparison.Ordinal) ||
actionGameManager.Contains("ItemManager.Evaluate(stat", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"InventoryMenu must project current item stats through shared daemon item-stat queries instead of direct Unity ItemManager stat authority.");
}
if (!surfaceDocument.Contains("public static class AetheriaRuntimeSurfaceStateRefs", StringComparison.Ordinal) ||
!surfaceDocument.Contains("public const string Value = \"valueRef\"", StringComparison.Ordinal) ||
!daemonItemStatQueries.Contains("public const string StateRefPrefix = \"aetheria.state/items\"", StringComparison.Ordinal) ||
!daemonItemStatQueries.Contains("public static string ItemStatRef(", StringComparison.Ordinal) ||
!daemonItemStatQueries.Contains("public string ValueRef =>", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Eve/CultUI item stat surfaces must expose typed daemon state refs that the UI runtime can resolve.");
}
var forbiddenSymbols = new[]
{
"BuildCargoItemDetailsSurfaceDefinition(",

View file

@ -542,6 +542,11 @@ public class ActionGameManager : MonoBehaviour
return _observedLoadoutTemplates;
}
public (RarityTier tier, int upgrades) ObservedItemTier(CraftedItemInstance item)
{
return ItemManager.GetTier(item);
}
private readonly (float2 direction, string name)[] _directions = {
(new float2(0, 1), "Front"),
(new float2(1, 0), "Right"),

View file

@ -506,7 +506,7 @@ public class InventoryMenu : MonoBehaviour
if (item is EquippableItem equippableItem)
{
var (itemTier, upgrades) = GameManager.ItemManager.GetTier(equippableItem);
var (itemTier, upgrades) = GameManager.ObservedItemTier(equippableItem);
tier = $"{itemTier.Name}{new string('+', upgrades)}";
durability = $"{(int)(equippableItem.Durability / GetMaxDurability(typedItem, equippableItem) * 100)}%";
thermalRange = FormatTemperatureRange(typedItem);
@ -548,7 +548,7 @@ public class InventoryMenu : MonoBehaviour
new AetheriaRuntimeCargoItemMetric(
$"{AetheriaRuntimeCargoItemDetailsSurfaceBuilder.SurfaceId}.behavior.{behavior.Kind}.target",
$"{statReference.Target.SplitCamelCase()}:{statReference.Stat.SplitCamelCase()}",
$"{(modifierType == StatModifierType.Constant ? "+" : "x")}{ActionGameManager.RuntimePlayerSettings.Format(GameManager.ItemManager.Evaluate(modifier, equippableItem))}")
$"{(modifierType == StatModifierType.Constant ? "+" : "x")}{FormatCurrentItemStat(modifier, equippableItem)}")
});
continue;
}
@ -594,7 +594,7 @@ public class InventoryMenu : MonoBehaviour
value = ((int)payloadField.Value.NumberValue).ToString();
break;
case AetheriaRuntimeBehaviorFieldValueKind.PerformanceStat:
value = ActionGameManager.RuntimePlayerSettings.Format(GameManager.ItemManager.Evaluate(AetheriaRuntimeBehaviorValueReader.ReadPerformanceStat(payloadField.Value), equippableItem));
value = FormatCurrentItemStat(payloadField.Value, equippableItem);
break;
default:
return null;
@ -627,7 +627,7 @@ public class InventoryMenu : MonoBehaviour
new AetheriaRuntimeEquippedItemMetric(
$"{AetheriaRuntimeEquippedItemDetailsSurfaceBuilder.SurfaceId}.behavior.{behavior.Kind}.target",
$"{statReference.Target.SplitCamelCase()}:{statReference.Stat.SplitCamelCase()}",
$"{(modifierType == StatModifierType.Constant ? "+" : "x")}{ActionGameManager.RuntimePlayerSettings.Format(GameManager.ItemManager.Evaluate(modifier, equippableItem))}")
$"{(modifierType == StatModifierType.Constant ? "+" : "x")}{FormatCurrentItemStat(modifier, equippableItem)}")
});
continue;
}
@ -673,7 +673,7 @@ public class InventoryMenu : MonoBehaviour
value = ((int)payloadField.Value.NumberValue).ToString();
break;
case AetheriaRuntimeBehaviorFieldValueKind.PerformanceStat:
value = ActionGameManager.RuntimePlayerSettings.Format(GameManager.ItemManager.Evaluate(AetheriaRuntimeBehaviorValueReader.ReadPerformanceStat(payloadField.Value), equippableItem));
value = FormatCurrentItemStat(payloadField.Value, equippableItem);
break;
default:
return null;
@ -687,10 +687,185 @@ public class InventoryMenu : MonoBehaviour
private string BuildEquippedItemTitle(EquippedItem item, AetheriaRuntimeCatalogItem typedItem)
{
var (tier, upgrades) = GameManager.ItemManager.GetTier(item.EquippableItem);
var (tier, upgrades) = GameManager.ObservedItemTier(item.EquippableItem);
return $"{typedItem?.Name ?? "Unknown Item"} ({tier.Name}{new string('+', upgrades)})";
}
private static string FormatCurrentItemStat(PerformanceStat stat, EquippableItem item)
{
return FormatCurrentItemStat(ToRuntimeBehaviorValue(stat), item);
}
private static string FormatCurrentItemStat(AetheriaRuntimeBehaviorValue stat, EquippableItem item)
{
return ActionGameManager.RuntimePlayerSettings.Format(
AetheriaRuntimeDaemonItemStatQueries.EvaluatePerformanceStat(
stat,
ToRuntimeLoadoutItem(item),
item?.Temperature ?? 0));
}
private static AetheriaRuntimeLoadoutItemCommit ToRuntimeLoadoutItem(EquippableItem item)
{
return AetheriaRuntimeDaemonItemStatQueries.ItemCommit(
item?.ItemKey ?? "",
item?.Quality ?? 1,
item?.Durability ?? 1,
enabled: true,
overrideShutdown: item != null && item.OverrideShutdown);
}
private static AetheriaRuntimeBehaviorValue ToRuntimeBehaviorValue(PerformanceStat stat)
{
return new AetheriaRuntimeBehaviorValue(
"performance-stat",
"",
0,
false,
"",
"",
new[]
{
Number(stat?.Min ?? 0),
Number(stat?.Max ?? 0),
Number(stat?.HeatExponentMultiplier ?? 0),
Number(stat?.DurabilityExponentMultiplier ?? 0),
Number(stat?.QualityExponent ?? 0),
ToRuntimeStatRecipeValue(stat?.Recipe)
},
Array.Empty<AetheriaRuntimeBehaviorMapEntry>());
}
private static AetheriaRuntimeBehaviorValue ToRuntimeStatRecipeValue(StatRecipe recipe)
{
if (recipe == null)
return Number(0);
return new AetheriaRuntimeBehaviorValue(
"stat-recipe",
"",
0,
false,
"",
"",
new[]
{
Number(recipe.BaseValue),
new AetheriaRuntimeBehaviorValue(
"stat-recipe-modifiers",
"",
0,
false,
"",
"",
(recipe.Modifiers ?? Array.Empty<StatRecipeModifier>())
.Where(modifier => modifier != null)
.Select(ToRuntimeStatRecipeModifierValue)
.ToArray(),
Array.Empty<AetheriaRuntimeBehaviorMapEntry>())
},
Array.Empty<AetheriaRuntimeBehaviorMapEntry>());
}
private static AetheriaRuntimeBehaviorValue ToRuntimeStatRecipeModifierValue(StatRecipeModifier modifier)
{
return new AetheriaRuntimeBehaviorValue(
"stat-recipe-modifier",
"",
0,
false,
"",
"",
new[]
{
Text(ConditionToken(modifier.Condition)),
Text(OperationToken(modifier.Operation)),
Number(modifier.Amount),
Number(0),
Bool(modifier.Enabled)
},
Array.Empty<AetheriaRuntimeBehaviorMapEntry>());
}
private static string ConditionToken(StatConditionMask condition)
{
switch (condition)
{
case StatConditionMask.Quality:
return AetheriaRuntimeStatRecipeConditions.Quality;
case StatConditionMask.Durability:
return AetheriaRuntimeStatRecipeConditions.Durability;
case StatConditionMask.Heat:
return AetheriaRuntimeStatRecipeConditions.Heat;
case StatConditionMask.Charge:
return AetheriaRuntimeStatRecipeConditions.Charge;
case StatConditionMask.Ammo:
return AetheriaRuntimeStatRecipeConditions.Ammo;
case StatConditionMask.Range:
return AetheriaRuntimeStatRecipeConditions.Range;
case StatConditionMask.Integrity:
return AetheriaRuntimeStatRecipeConditions.Integrity;
case StatConditionMask.PilotSkill:
return AetheriaRuntimeStatRecipeConditions.PilotSkill;
case StatConditionMask.Environment:
return AetheriaRuntimeStatRecipeConditions.Environment;
default:
return "";
}
}
private static string OperationToken(StatModifierOperation operation)
{
switch (operation)
{
case StatModifierOperation.Multiply:
return AetheriaRuntimeStatRecipeOperations.Multiply;
case StatModifierOperation.Override:
return AetheriaRuntimeStatRecipeOperations.Override;
default:
return AetheriaRuntimeStatRecipeOperations.Add;
}
}
private static AetheriaRuntimeBehaviorValue Number(double value)
{
return new AetheriaRuntimeBehaviorValue(
"number",
"",
value,
false,
"",
"",
Array.Empty<AetheriaRuntimeBehaviorValue>(),
Array.Empty<AetheriaRuntimeBehaviorMapEntry>());
}
private static AetheriaRuntimeBehaviorValue Text(string value)
{
return new AetheriaRuntimeBehaviorValue(
"string",
value ?? "",
0,
false,
"",
"",
Array.Empty<AetheriaRuntimeBehaviorValue>(),
Array.Empty<AetheriaRuntimeBehaviorMapEntry>());
}
private static AetheriaRuntimeBehaviorValue Bool(bool value)
{
return new AetheriaRuntimeBehaviorValue(
"bool",
"",
0,
value,
"",
"",
Array.Empty<AetheriaRuntimeBehaviorValue>(),
Array.Empty<AetheriaRuntimeBehaviorMapEntry>());
}
private static bool IsWeaponGroupAssigned(EquippedItem item, int groupIndex)
{
return item?.Entity?.WeaponGroups != null &&

View file

@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace GameCult.Aetheria.State.Verse
{
public readonly struct AetheriaRuntimeDaemonItemStatValue
{
public AetheriaRuntimeDaemonItemStatValue(
string itemKey,
string behaviorKind,
int behaviorGroup,
int fieldKey,
string label,
double value)
{
ItemKey = itemKey ?? "";
BehaviorKind = behaviorKind ?? "";
BehaviorGroup = behaviorGroup;
FieldKey = fieldKey;
Label = label ?? "";
Value = value;
}
public string ItemKey { get; }
public string BehaviorKind { get; }
public int BehaviorGroup { get; }
public int FieldKey { get; }
public string Label { get; }
public double Value { get; }
public string ValueRef => AetheriaRuntimeDaemonItemStatQueries.ItemStatRef(
ItemKey,
BehaviorKind,
BehaviorGroup,
FieldKey);
}
public static class AetheriaRuntimeDaemonItemStatQueries
{
public const string StateRefPrefix = "aetheria.state/items";
public static string ItemStatRef(
string itemKey,
string behaviorKind,
int behaviorGroup,
int fieldKey)
{
return $"{StateRefPrefix}/{Token(itemKey)}/behaviors/{Token(behaviorKind)}/{behaviorGroup}/stats/{fieldKey}";
}
public static AetheriaRuntimeStatRecipePreviewState ConditionsFor(
AetheriaRuntimeLoadoutItemCommit? item,
double heat = 0,
double charge = 1,
double ammo = 1,
double range = 1,
double integrity = 1,
double pilotSkill = 0,
double environment = 0)
{
return new AetheriaRuntimeStatRecipePreviewState(
quality: Clamp01(item?.Quality ?? 1),
durability: Clamp01(item?.Durability ?? 1),
heat: Clamp01(heat),
charge: Clamp01(charge),
ammo: Clamp01(ammo),
range: Clamp01(range),
integrity: Clamp01(integrity),
pilotSkill: Clamp01(pilotSkill),
environment: Clamp01(environment));
}
public static double EvaluatePerformanceStat(
AetheriaRuntimeBehaviorValue? value,
AetheriaRuntimeLoadoutItemCommit? item,
double heat = 0)
{
return EvaluatePerformanceStat(
ReadPerformanceStat(value),
ConditionsFor(item, heat));
}
public static double EvaluatePerformanceStat(
AetheriaRuntimePerformanceStat? stat,
AetheriaRuntimeStatRecipePreviewState conditions)
{
if (stat == null)
return 0;
conditions ??= AetheriaRuntimeStatRecipePreviewState.Default;
if (stat.Recipe != null)
return EvaluateRecipe(stat.Recipe, conditions);
var heat = stat.HeatExponentMultiplier == 0
? 1
: Math.Pow(Clamp01(conditions.Heat), stat.HeatExponentMultiplier);
var durability = stat.DurabilityExponentMultiplier == 0
? 1
: Math.Pow(Clamp01(conditions.Durability), stat.DurabilityExponentMultiplier);
var quality = stat.QualityExponent == 0
? 1
: Math.Pow(Clamp01(conditions.Quality), stat.QualityExponent);
return Lerp(stat.Min, stat.Max, durability * quality * heat);
}
public static IReadOnlyList<AetheriaRuntimeDaemonItemStatValue> QueryPerformanceStats(
AetheriaRuntimeCatalogItem? catalogItem,
AetheriaRuntimeLoadoutItemCommit? item,
double heat = 0)
{
if (catalogItem?.BehaviorPayloads == null || catalogItem.BehaviorPayloads.Count == 0)
return Array.Empty<AetheriaRuntimeDaemonItemStatValue>();
var conditions = ConditionsFor(item, heat);
var stats = new List<AetheriaRuntimeDaemonItemStatValue>();
foreach (var behavior in catalogItem.BehaviorPayloads)
{
var metadata = AetheriaRuntimeBehaviorMetadataCatalog.Get(behavior.Kind);
foreach (var field in behavior.Fields ?? Array.Empty<AetheriaRuntimeBehaviorField>())
{
var fieldMetadata = metadata?.DisplayFields.FirstOrDefault(candidate => candidate.Key == field.Key);
if (fieldMetadata?.ValueKind != AetheriaRuntimeBehaviorFieldValueKind.PerformanceStat)
continue;
stats.Add(new AetheriaRuntimeDaemonItemStatValue(
item?.ItemKey ?? catalogItem.ItemKey,
behavior.Kind,
behavior.Group,
field.Key,
fieldMetadata.Name,
EvaluatePerformanceStat(ReadPerformanceStat(field.Value), conditions)));
}
}
return stats.Count == 0 ? Array.Empty<AetheriaRuntimeDaemonItemStatValue>() : stats;
}
public static AetheriaRuntimeLoadoutItemCommit ItemCommit(
string itemKey,
double quality,
double durability,
int quantity = 1,
bool enabled = true,
bool overrideShutdown = false)
{
return new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = itemKey ?? "",
Quality = quality,
Durability = durability,
Quantity = quantity,
Enabled = enabled,
OverrideShutdown = overrideShutdown
};
}
private static AetheriaRuntimePerformanceStat ReadPerformanceStat(AetheriaRuntimeBehaviorValue? value)
{
return new AetheriaRuntimePerformanceStat(
ChildNumber(value, 0),
ChildNumber(value, 1),
ChildNumber(value, 2),
ChildNumber(value, 3),
ChildNumber(value, 4),
ReadStatRecipe(ChildValue(value, 5)));
}
private static AetheriaRuntimeStatRecipe? ReadStatRecipe(AetheriaRuntimeBehaviorValue? value)
{
if (value == null || value.Children.Count == 0)
return null;
var modifiers = ChildValue(value, 1)?.Children
.Select(ReadStatRecipeModifier)
.Where(modifier => modifier != null)
.Cast<AetheriaRuntimeStatRecipeModifier>()
.ToArray() ?? Array.Empty<AetheriaRuntimeStatRecipeModifier>();
return new AetheriaRuntimeStatRecipe(ChildNumber(value, 0), modifiers);
}
private static AetheriaRuntimeStatRecipeModifier? ReadStatRecipeModifier(AetheriaRuntimeBehaviorValue? value)
{
if (value == null)
return null;
return new AetheriaRuntimeStatRecipeModifier(
ChildString(value, 0),
ChildString(value, 1),
ChildNumber(value, 2),
ReadCurveKeys(ChildValue(value, 3)),
value.Children.Count <= 4 || ChildValue(value, 4)?.BoolValue == true);
}
private static IReadOnlyList<AetheriaRuntimeCurveKey> ReadCurveKeys(AetheriaRuntimeBehaviorValue? value)
{
if (value?.Children == null || value.Children.Count == 0)
return Array.Empty<AetheriaRuntimeCurveKey>();
return value.Children
.Where(key => key.Children.Count >= 4)
.Select(key => new AetheriaRuntimeCurveKey(
ChildNumber(key, 0),
ChildNumber(key, 1),
ChildNumber(key, 2),
ChildNumber(key, 3)))
.ToArray();
}
private static double EvaluateRecipe(
AetheriaRuntimeStatRecipe recipe,
AetheriaRuntimeStatRecipePreviewState conditions)
{
var value = recipe.BaseValue;
foreach (var modifier in recipe.Modifiers ?? Array.Empty<AetheriaRuntimeStatRecipeModifier>())
{
if (modifier == null || !modifier.Enabled || string.IsNullOrWhiteSpace(modifier.Condition))
continue;
var sample = SampleCurve(modifier.CurveKeys, conditions.GetConditionValue(modifier.Condition));
switch (modifier.Operation ?? "")
{
case AetheriaRuntimeStatRecipeOperations.Multiply:
value *= 1 + ((modifier.Amount - 1) * sample);
break;
case AetheriaRuntimeStatRecipeOperations.Override:
value = Lerp(value, modifier.Amount, sample);
break;
default:
value += modifier.Amount * sample;
break;
}
}
return value;
}
private static double SampleCurve(IReadOnlyList<AetheriaRuntimeCurveKey>? keys, double value)
{
if (keys == null || keys.Count == 0)
return Clamp01(value);
var ordered = keys.OrderBy(key => key.Time).ToArray();
if (value <= ordered[0].Time)
return Clamp01(ordered[0].Value);
for (var index = 1; index < ordered.Length; index++)
{
var next = ordered[index];
var previous = ordered[index - 1];
if (value > next.Time)
continue;
var span = next.Time - previous.Time;
var t = span <= double.Epsilon ? 1 : Clamp01((value - previous.Time) / span);
return Clamp01(Lerp(previous.Value, next.Value, t));
}
return Clamp01(ordered[ordered.Length - 1].Value);
}
private static double ChildNumber(AetheriaRuntimeBehaviorValue? value, int index)
{
return ChildValue(value, index)?.NumberValue ?? 0;
}
private static string ChildString(AetheriaRuntimeBehaviorValue? value, int index)
{
return ChildValue(value, index)?.StringValue ?? "";
}
private static AetheriaRuntimeBehaviorValue? ChildValue(AetheriaRuntimeBehaviorValue? value, int index)
{
return value != null && value.Children.Count > index ? value.Children[index] : null;
}
private static double Clamp01(double value)
{
if (value < 0)
return 0;
return value > 1 ? 1 : value;
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static string Token(string value)
{
return string.IsNullOrWhiteSpace(value)
? "_"
: value.Trim().Replace("/", "%2F");
}
}
}

View file

@ -81,6 +81,34 @@ namespace GameCult.Aetheria.State.Verse
public IReadOnlyList<AetheriaRuntimeSurfaceComponent> Children { get; }
}
public static class AetheriaRuntimeSurfaceStateRefs
{
public const string Source = "stateRef";
public const string Value = "valueRef";
public const string Label = "labelRef";
public const string Format = "stateFormat";
public static (string Key, string Value) SourceRef(string reference)
{
return (Source, reference ?? "");
}
public static (string Key, string Value) ValueRef(string reference)
{
return (Value, reference ?? "");
}
public static (string Key, string Value) LabelRef(string reference)
{
return (Label, reference ?? "");
}
public static (string Key, string Value) FormatRef(string format)
{
return (Format, format ?? "");
}
}
public sealed class AetheriaRuntimeSurfaceStyleToken
{
public AetheriaRuntimeSurfaceStyleToken(string name, string value)