Struct ObservedResourcePushScheduler
Decides, tick by tick, whether a character's resources go out to its observers.
public struct ObservedResourcePushScheduler
- Inherited Members
Remarks
The observer resource stream is unreliable and change gated: a value is sent when it changes and then never again. Lose that one packet and every observer holds a stale bar until the next change — which for a creature that has just died, or has just been topped up to full, may be never. This schedules a single confirmation re-send of the last pushed state ConfirmDelayTicks later, provided the state is still what was pushed. One confirmation is pending at most; a new change replaces it rather than stacking.
Not every resource earns the same schedule. Health drives every observer-facing
readout there is; mana and stamina drive none of them — no nameplate, target frame or party
row reads a peer's mana, and the one genuine peer-side consumer is
HasResourceCondition tested against a victim during a predicted hit. Gated as one OR
across whole units, sprint's 5 stamina a second dirtied the gate on essentially every tick
and turned a change-gated channel into a 2.5-5 Hz stream of the whole resource sheet.
So health (and every maximum) is Primary and keeps the fine
schedule, while mana and stamina are Secondary and move only when
they cross a percentage bucket — the coarsest form anything could act on.
Pure state machine with no network dependency so the schedule is unit tested. The owner of this struct decides what "changed" means (whole units, for a bar) and passes in the intervals plus, for the confirmation rule, when its next PERIODIC change is due.
Fields
ConfirmDelayTicks
Ticks after a push before its confirmation re-send. 15 at tick rate 30 is half a second.
public const uint ConfirmDelayTicks = 15
Field Value
ConfirmPending
True while a confirmation re-send is scheduled for ConfirmTick.
public bool ConfirmPending
Field Value
ConfirmTick
Tick the pending confirmation is due on.
public uint ConfirmTick
Field Value
HasPushed
False until the first push, which always happens regardless of change.
public bool HasPushed
Field Value
LastPushed
The state most recently sent.
public CharacterAttributeResourceState LastPushed
Field Value
NextPushTick
Earliest tick a Primary change push may occur on.
public uint NextPushTick
Field Value
NextSecondaryPushTick
Earliest tick a Secondary-only change push may occur on.
public uint NextSecondaryPushTick
Field Value
SecondaryResourceBuckets
Buckets the span between empty and full is divided into for a secondary resource.
public const int SecondaryResourceBuckets = 10
Field Value
Remarks
Ten, so a bucket is a tenth of the bar. Sprint drains stamina at 5 a second, which on a hundred-point bar crosses a boundary every two seconds rather than every tick, and the 4/s recovery ramp crosses one every two and a half. Empty and full are buckets of their own (see SecondaryResourceBucket(float, int)) because "has any at all" and "is capped" are the two facts a consumer actually branches on.
Methods
AllowImmediatePush(uint)
Drops the outstanding rate limit so the next CHANGE is sent on the tick it happens.
public void AllowImmediatePush(uint tick)
Parameters
tickuintThe current server tick; the deadline is moved back to it.
Remarks
NextPushTick is an absolute deadline stamped from the interval in force at the last push. A caller that widens its interval while a character is idle therefore leaves a deadline behind that outlives the reason for it — and the event that matters most (the first hit of a fight) is exactly the one that arrives while it is still pending. This clears the deadline without pushing anything: nothing is sent unless the state has actually changed.
Deliberately does NOT touch LastPushed, HasPushed or the pending confirmation. Clearing those would resend a value the observers already hold and would drop the loss repair for the previous push.
Nor does it touch NextSecondaryPushTick. The event this exists for is a health change; a mana or stamina bucket crossing is not made urgent by combat starting, and letting it through here would spend the packet combat entry is trying to reserve.
ClassifyChange(in CharacterAttributeResourceState, in CharacterAttributeResourceState)
How much two resource states differ, as far as an observer is concerned.
public static ObservedResourcePushScheduler.ChangeKind ClassifyChange(in CharacterAttributeResourceState a, in CharacterAttributeResourceState b)
Parameters
Returns
Remarks
Health is compared at whole units because that is what a health bar renders, and every maximum exactly because it is the denominator that bar is drawn against. Sub-unit regeneration drift in health would otherwise mark every interval dirty and push continuously.
Mana and stamina are compared by percentage bucket. Nothing on any client renders a peer's mana or stamina at all; the only peer-side reader is a resource CONDITION evaluated against a victim, which asks "does it have at least N" — a question a tenth of the bar answers, and which the server re-answers exactly before anything is allowed to happen. Compared at whole units these two alone produced a continuous push stream for any character that was sprinting or recovering.
Evaluate(uint, in CharacterAttributeResourceState, in Schedule)
Evaluates one tick.
public ObservedResourcePushScheduler.Decision Evaluate(uint tick, in CharacterAttributeResourceState state, in ObservedResourcePushScheduler.Schedule schedule)
Parameters
tickuintThe current server tick.
stateCharacterAttributeResourceStateThe current resource state, with fields observers do not use zeroed.
scheduleObservedResourcePushScheduler.ScheduleThe intervals and periodic-change forecast in force this tick.
Returns
- ObservedResourcePushScheduler.Decision
What, if anything, to send. On Push, LastPushed is now
state.
Evaluate(uint, in CharacterAttributeResourceState, uint)
Evaluates one tick against a single interval and no periodic forecast.
public ObservedResourcePushScheduler.Decision Evaluate(uint tick, in CharacterAttributeResourceState state, uint pushInterval)
Parameters
tickuintstateCharacterAttributeResourceStatepushIntervaluint
Returns
Remarks
The shape the schedule had before mana and stamina were split off. Kept because it is the honest signature for a caller with one resource and no pulse, and because the confirmation rule it produces is the unconditional one.
Reset()
Forgets everything, so the next evaluation pushes unconditionally.
public void Reset()
ResolveConfirmTick(uint, in Schedule)
When the confirmation for a push made on pushTick falls due.
public static uint ResolveConfirmTick(uint pushTick, in ObservedResourcePushScheduler.Schedule schedule)
Parameters
pushTickuintThe tick the push being confirmed went out on.
scheduleObservedResourcePushScheduler.ScheduleThe schedule in force, carrying the periodic-change forecast.
Returns
Remarks
A confirmation exists to repair a loss that nothing else will. It repeats the final value of a burst of changes, on the reliable channel, because that one send has nothing behind it to supersede it. A burst that is still running needs no repair: the next push is a few ticks away and carries the same field again.
Regeneration made "a burst that stops" untrue. It pulses once a second — thirty ticks, against a fifteen-tick confirmation delay — so every character below full on any resource sat in a two-message steady state forever: an unreliable push on the pulse, then a reliable confirmation fifteen ticks later repeating a value that the next pulse was about to replace. The confirmation was repairing a value with a successor already scheduled, which is precisely what it is not for.
So a pending periodic change DEFERS the confirmation to one delay past it. Note that "inside the confirmation window" is not the test: the pulse period is TWICE the delay, so the pulse never falls inside the window and a window test would have changed nothing at all. What matters is that a further push is already scheduled, whenever it is due.
Deferral rather than cancellation, because the forecast is a schedule and not a promise: a pulse lands inside a consumption lockout, or on a resource already at its maximum, and changes nothing. Cancelling on such a pulse would throw away the loss repair for the push that preceded it and strand observers on whatever they last received — the stale-corpse failure this whole mechanism exists to remove. Deferred, the confirmation fires the moment the pulses stop moving the values, and never while they are.
The cost is that a burst which ends between two pulses waits for the pulse before it is repaired: one pulse period plus one delay, rather than one delay. At the authored one-second regeneration cadence that is at most 45 ticks against 15. A packet lost at the end of a fight is therefore repaired a second later than it used to be, which is the price of not sending a reliable repeat every single second of every character's life.
ResourcesDifferForObservers(in CharacterAttributeResourceState, in CharacterAttributeResourceState)
True when two resource states differ by enough for an observer to notice.
public static bool ResourcesDifferForObservers(in CharacterAttributeResourceState a, in CharacterAttributeResourceState b)
Parameters
Returns
Remarks
The boolean face of ClassifyChange(in CharacterAttributeResourceState, in CharacterAttributeResourceState), for callers that only need to know whether anything is owed rather than which schedule owes it.
SecondaryResourceBucket(float, int)
Which percentage bucket a secondary resource sits in.
public static int SecondaryResourceBucket(float current, int max)
Parameters
currentfloatThe resource's current value.
maxintThe resource's maximum; zero or less means the resource is absent.
Returns
- int
A bucket index; only equality between two of these is meaningful.
Remarks
Empty is bucket 0 and full is the last bucket, both exactly, with SecondaryResourceBuckets proportional buckets between them. The two ends are singled out because they are the only two values with a meaning of their own: a resource at zero cannot pay for anything, and one at its maximum cannot regenerate. A plain floor would have put 0 and 1 of 100 in the same bucket and 99 and 100 likewise.
TicksUntil(uint, uint)
Ticks from from forward to to, floored at zero.
public static uint TicksUntil(uint from, uint to)
Parameters
Returns
Remarks
A duration, deliberately, not a tick. A caller's periodic schedule is very often
kept in the REPLICATE tick domain — the owning client's own unsynchronised counter — and
comparing that against TimeManager.LocalTick is meaningless. A difference taken
inside one domain is a number of ticks and transfers to any other, so this is how a
replicate-domain pulse is handed to a LocalTick-domain schedule.