A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
No EOL
5.9 KiB
C#
176 lines
No EOL
5.9 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 MessagePack;
|
|
using Newtonsoft.Json;
|
|
using CultMath;
|
|
using static CultMath.math;
|
|
|
|
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
|
public class ConstantWeaponData : WeaponData
|
|
{
|
|
[Inspectable, JsonProperty("ammoInterval"), Key(17)]
|
|
public float AmmoInterval = 1;
|
|
|
|
public override Behavior CreateInstance(EquippedItem item)
|
|
{
|
|
return new ConstantWeapon(this, item);
|
|
}
|
|
|
|
public override Behavior CreateInstance(ConsumableItemEffect item)
|
|
{
|
|
return new ConstantWeapon(this, item);
|
|
}
|
|
}
|
|
|
|
public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer
|
|
{
|
|
private ConstantWeaponData _data;
|
|
private int _ammo = 1;
|
|
private float _ammoInterval;
|
|
private float _reload;
|
|
private bool _reloading;
|
|
|
|
public override int Ammo
|
|
{
|
|
get => _ammo;
|
|
}
|
|
|
|
public float Progress
|
|
{
|
|
get { return saturate(_reload); }
|
|
}
|
|
|
|
public override float DamagePerSecond => Damage;
|
|
public override float RangeDamagePerSecond(float range)
|
|
{
|
|
return Damage * _data.DamageCurve.Evaluate(saturate(unlerp(MinRange, Range, range)));
|
|
}
|
|
|
|
public event Action OnReloadBegin;
|
|
public event Action OnReloadComplete;
|
|
public event Action OnStartFiring;
|
|
public event Action OnStopFiring;
|
|
|
|
public void ResetEvents()
|
|
{
|
|
OnReloadBegin = null;
|
|
OnReloadComplete = null;
|
|
OnStartFiring = null;
|
|
OnStopFiring = null;
|
|
}
|
|
|
|
public ConstantWeapon(ConstantWeaponData data, EquippedItem item) : base(data, item)
|
|
{
|
|
_data = data;
|
|
}
|
|
|
|
public ConstantWeapon(ConstantWeaponData data, ConsumableItemEffect item) : base(data, item)
|
|
{
|
|
_data = data;
|
|
}
|
|
|
|
// Cut 3 (docs/stats-and-power-cut.md): the request the bus needs before Execute runs -- what continuing to
|
|
// fire this tick would cost, or nothing when not firing or safed. _firing is set externally (Activate/
|
|
// Deactivate) before Entity.Update calls PowerBus.Step, so it is already current when this runs.
|
|
//
|
|
// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Energy is a registered
|
|
// request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every other
|
|
// IPowerConsumer in this cut. Damage (the field Cut 7 curves) is a separate stat base.Execute reads with the
|
|
// real Evaluate.
|
|
public float PowerRequest(float dt) => _firing && StanceAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f;
|
|
|
|
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon.
|
|
public int DefaultPowerTier => PowerTiers.Low;
|
|
|
|
public override bool Execute(float dt)
|
|
{
|
|
base.Execute(dt);
|
|
if (_firing && !StanceAllowsFire)
|
|
{
|
|
// Safed: shooter has a target and hasn't declared hostility toward it.
|
|
_firing = false;
|
|
OnStopFiring?.Invoke();
|
|
return false;
|
|
}
|
|
if (_firing)
|
|
{
|
|
// Cut 7 (docs/stats-and-power-target.md): base.Execute(dt) above already re-read Damage through
|
|
// Evaluate(_data.Damage), so a Damage stat carrying a PowerSupply term already fires this tick for
|
|
// less under a partial grant -- the weapon no longer safes itself off (the "flicking off gear" bug
|
|
// the ruling names) just because the grant fell short of 1. Only true zero supply still stops it.
|
|
if (Item != null && Item.PowerSupply <= 1e-4f)
|
|
{
|
|
_firing = false;
|
|
OnStopFiring?.Invoke();
|
|
return false;
|
|
}
|
|
if (_data.AmmoType.IsSet())
|
|
{
|
|
if (_reloading)
|
|
{
|
|
_reload -= dt / _data.ReloadTime;
|
|
if (_reload < 0)
|
|
{
|
|
_reloading = false;
|
|
OnReloadComplete?.Invoke();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_ammoInterval -= dt / _data.AmmoInterval;
|
|
if (_ammoInterval < 0)
|
|
{
|
|
_ammoInterval = 1;
|
|
if (_data.MagazineSize > 1 && _ammo > 0) _ammo--;
|
|
else
|
|
{
|
|
var cargo = Entity.FindItemInCargo(_data.AmmoType.Key);
|
|
if (cargo != null)
|
|
{
|
|
var item = cargo.ItemsOfType[_data.AmmoType.Key][0];
|
|
if (item is SimpleCommodity simpleCommodity)
|
|
cargo.Remove(simpleCommodity, 1);
|
|
|
|
if(_data.MagazineSize > 1)
|
|
{
|
|
_reloading = true;
|
|
_reload = 1;
|
|
OnReloadBegin?.Invoke();
|
|
|
|
_firing = false;
|
|
OnStopFiring?.Invoke();
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
CauseWearDamage(dt);
|
|
AddHeat(Evaluate(_data.Heat) * dt);
|
|
Entity.VisibilitySources[this] = Evaluate(_data.Visibility);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public override void Activate()
|
|
{
|
|
if(!_firing && !_reloading)
|
|
{
|
|
_firing = true;
|
|
OnStartFiring?.Invoke();
|
|
}
|
|
}
|
|
|
|
public override void Deactivate()
|
|
{
|
|
if (_firing)
|
|
{
|
|
_firing = false;
|
|
OnStopFiring?.Invoke();
|
|
}
|
|
}
|
|
} |