Table of Contents

Class BaseAction

Namespace
FishMMO.Shared.Core
Assembly
FishMMO.Shared.dll

Abstract base class for all ECA actions. Serialized inline via [SerializeReference] on Trigger assets. Derive from this class and add [Serializable] to create concrete actions.

[Serializable]
public abstract class BaseAction : IAction
Inheritance
BaseAction
Implements
Derived
Inherited Members

Fields

StopChainOnFailure

When true and this action implements IAbortableAction, returning false from TryExecute(ICharacter, EventData) aborts the remainder of the current action list (e.g. stop applying damage after a resource consume fails). Has no effect on actions that do not implement IAbortableAction.

[Tooltip("If this action implements IAbortableAction, abort the rest of the action chain when TryExecute returns false.")]
public bool StopChainOnFailure

Field Value

bool

TargetSelector

Optional selector that picks one or more targets for this action. When set, the action runs once per selected target. When unset, the action runs once against the current event data (reading TargetCharacter or falling back to the initiator).

[Tooltip("Optional selector for this action. When unset the action runs once against the current event target.")]
[SerializeReference]
public TargetSelector TargetSelector

Field Value

TargetSelector

Properties

IsPresentation

True when this action's ENTIRE effect is something rendered — particles, sound, floating text, camera shake — and it changes no state that any other peer can observe.

public virtual bool IsPresentation { get; }

Property Value

bool

Remarks

What reads it. TriggerExecution's selector fan-out. A spatial selector correctly declines to resolve on a third-party OBSERVER (TargetSelector.ResolvesTargetsLocally), and the fan-out runs an action once per selected target — so an empty selection ran the whole action list zero times and an authored OnHit event with a Chain, Area, Line or Cone selector produced NOTHING on any screen but the caster's. Observers saw damage numbers pop over three characters with no arc, impact or sound linking them. This flag is how the fan-out knows which of those actions it may still run once, against the event's own scope, on a peer that is not allowed to select for itself.

The bar is deliberately high, and it is not "harmless". Say true only when the action would be correct to run on a peer that has resolved no targets and has no authority for the event. Anything that moves a resource, installs a buff, draws a PREDICTED number, spends a hit count, grants an item or touches persistence must stay false — those belong to the peer that resolved the hit, and an observer running them is the failure the selector gate exists to prevent. A type list in the fan-out was the alternative and is worse: it puts the classification somewhere the action's author never looks.

Exactly one action answers true today (PlayFXAction), which is also the only action in the project that gates on IsClientPeer(ICharacter, EventData) — the two questions are close relatives: this one says "I am only a picture", that one says "and only a screen needs it".

Methods

Execute(ICharacter, EventData)

Executes the action. Must be implemented by derived classes.

public abstract void Execute(ICharacter initiator, EventData eventData)

Parameters

initiator ICharacter

The character initiating the action.

eventData EventData

Event data for the action.

GetTooltipContribution()

Returns a short, designer-facing tooltip line describing this action's effect, or null when the action has nothing to contribute. Override on actions that produce a player-visible outcome (damage, heal, buff apply, knockback, etc.) so ability tooltips can list effects without designer authoring text twice.

public virtual string GetTooltipContribution()

Returns

string

Remarks

The base implementation returns null. Aggregators (BaseAbilityTemplate) skip null/whitespace contributions, so most actions need not override.

IsClientPeer(ICharacter, EventData)

True when this peer has a screen — i.e. it is a client rather than the dedicated server.

protected static bool IsClientPeer(ICharacter initiator, EventData eventData)

Parameters

initiator ICharacter

The character the action is running for.

eventData EventData

The event being executed, or null.

Returns

bool

True when the local peer is a client.

Remarks

For actions whose entire effect is presentational: particles, sound, floating text, camera shake. Those must run on every client that can see the event and on NONE of the server, which has nothing to render and would spend the allocation for no viewer.

The mirror of IsServer(ICharacter), and not a substitute for it. This answers "should I draw something", never "may I change state". An action that mutates anything must still gate on EcaAuthority; gating a state change on this instead would let every client change state and the server change none.

There is no host mode in this project, so a peer is a client or a server and never both. A character with no networked identity — a scene-authored trigger, an edit-mode test — answers true, matching the "allow when undecidable" stance the authority gate takes.

IsReplayTick(EventData)

True when this execution is a prediction REPLAY rather than a first run.

protected static bool IsReplayTick(EventData eventData)

Parameters

eventData EventData

The event being executed, or null.

Returns

bool

True when the event carries a replayed tick.

Remarks

A reconcile replays every tick between the corrected one and now, so anything an action does that is visible or one-shot — a floating number, a VFX instance, a sound — fires once per replayed tick unless it is suppressed here. That is the visual spam PlayFXAction already guards against; this is the same test with one definition instead of an open-coded copy per action.

It is the SECOND line, and today it is the one holding nothing. Every production dispatch refuses to build an ECA payload at all on a replayed tick — ResolveTargetAndSpawn returns before it spawns, BuffController tests its own isReplayingTick, the activation triggers test state.ContainsReplayed(), and the hit, tick and destroy dispatches run off TimeManager.OnTick, which is not replayed — so IsReplay is false at every one of them and this returns false in production. That is the correct end state rather than a hole: skipping the whole dispatch is strictly better than skipping one action inside it, because ECA actions are not idempotent and a replayed dispatch would also re-roll RNG and re-enter selectors. This guard is for the dispatch that CANNOT gate itself, and such a site declares itself by handing TickEventData the ReplicateState it is running under rather than by remembering a bare bool — see IsReplay.

Never use this as an authority gate. The server's own ability dispatches carry replicate ticks too, so gating state changes on it suppresses the server and the effect happens nowhere — the exact mistake EcaAuthority exists to prevent. This answers "have I already done this once", not "am I allowed to do this".

It reads IsReplay and not IsReplicateTick. The two were the same test until the domain flag was found to be true for dispatches that cannot be replayed — a spawn chain and a self-target chain are both skipped outright on a replayed tick, and both carry a replicate-domain tick on every peer, so this returned true for them and false for everything else. Reading the domain flag here suppressed every self-buff and self-heal impact effect in the game, on all peers, for good.

IsServer(ICharacter)

True when this action is running on a peer that is actually hosting a server.

protected static bool IsServer(ICharacter initiator)

Parameters

initiator ICharacter

The character the action is running for.

Returns

bool

True when the local peer's server is initialized.

Remarks

The runtime replacement for #if UNITY_SERVER, which every server-only action body used to be wrapped in. UNITY_SERVER is a build-target define, so it is undefined in the editor — and the scene server runs from the editor. Every one of those bodies therefore compiled to nothing there, which meant a bindstone bound nobody, a merchant opened no shop, and a teleporter moved no one, in the configuration the project is developed in. It is the same compile-time gate already found and removed from NPC.OnStartServer and from scene object registration.

Asks the initiator's own FishNet.Object.NetworkObject rather than a global singleton, so a client-hosted process answers for the peer the character belongs to.

SendToOwner<T>(ICharacter, T)

Sends a broadcast to the one player an action is acting for.

protected static void SendToOwner<T>(ICharacter character, T message) where T : struct, IBroadcast

Parameters

character ICharacter

The character whose owning connection should receive it.

message T

The broadcast to send.

Type Parameters

T

The broadcast type.

Remarks

Not initiator.NetworkObject.Broadcast(...), which is what these actions used to call. That sends to the observers of the initiator's NetworkObject — every client that can see the player, not the player. One person opening a merchant therefore opened the shop on every screen within observer range, because the client handlers have no reason to filter a message they were sent directly.

Anything that opens a window, offers a quest, or reports a personal result belongs here. Genuine world state — a switch throwing, a door opening — should go to the observers of the object that changed, which is a different set again.

TryResolveTarget(EventData, out ICharacter)

Strict target resolution: returns the explicit TargetCharacter only. Does not fall back to the initiator — outward-effecting actions (damage, dispel, interrupt, knockback) should use this so a misconfigured selector can't silently make a caster attack itself.

protected static bool TryResolveTarget(EventData eventData, out ICharacter target)

Parameters

eventData EventData

The action's event data (may be null).

target ICharacter

Resolved target character, or null when none is present.

Returns

bool

True when an explicit target was resolved.

TryResolveTargetOrInitiator(ICharacter, EventData, out ICharacter)

Forgiving target resolution: prefers TargetCharacter, then falls back to the initiator. Use for self-effecting actions (resource costs, self-buffs, cooldown starts) where "no target" naturally means "act on self". Outward-effecting actions should use TryResolveTarget(EventData, out ICharacter) instead so a missing target produces a no-op rather than a self-hit.

protected static bool TryResolveTargetOrInitiator(ICharacter initiator, EventData eventData, out ICharacter target)

Parameters

initiator ICharacter

The action's initiator.

eventData EventData

The action's event data (may be null).

target ICharacter

Resolved character, or null when neither is available.

Returns

bool

True when a non-null target was resolved.