AccessFact
One layer of one fact about a player: true, false, or nil for not-yet-answered. Facts are never
gated by a release flag and never consult each other -- "does this player own the pass" is the same
question whether or not the game has launched. What a fact means is AccessFeature's business.
Two ways to answer, and the difference is per-player versus game-wide:
-- Per-player: a resolver, run once per player and shared between every consumer.
maid:Add(AccessFact.new("ownsFullAccessPass", {
resolve = function(serviceBag, player)
return serviceBag:GetService(GameProductDataService)
:ObservePlayerOwnership(player, GameConfigAssetTypes.PASS, FULL_ACCESS_GAME_PASS_KEY)
end,
}))
-- Game-wide: one [ValueObject.Mountable] every player reads the same answer from.
maid:Add(AccessFact.new("eventIsRunning", { value = eventRunningValueObject }))
A fact has a lifetime -- it holds a shared observable per player -- so give it to a maid where you make it. Registering it does not take ownership.
Several layers may answer the same fact -- a group rank and an allowlist both saying whether someone is
staff. Each declares an AccessFactPriority, and the highest layer that contributes decides. Return
AccessFact.ABSTAIN to contribute nothing and let a lower layer answer; returning nil is an answer,
and the answer is "unresolved".
GOTCHA: a fact resolves in whichever realm asks it, so a resolver that only works on the server leaves the client permanently unresolved -- and every feature declaring that fact never settles there. Resolve server-side work into a replicated player attribute and have the resolver read that in both realms.
Types
AccessFactContribution
type AccessFactContribution = {value: boolean?}?What a layer said: an AccessFactContributionState plus, for the states that carry one, the boolean it means. Always a table -- there is no nil contribution any more, because a nil could not be told apart from a layer that said nothing and could not survive being put in a map.
metadata is why it said so, in whatever shape the mechanism has: which friend granted access, which
gamepass was owned, which allowlist matched. Opaque to this package -- it is carried, printed and
handed back, never interpreted -- because only the mechanism knows what is worth attributing.
AccessFactResolver
Resolves the fact for one player. Returns anything ValueObject.Mountable accepts, so a test can hand
back a bare true where production hands back an observable -- or AccessFact.ABSTAIN.
AccessFactOptions
interface AccessFactOptions {value: ValueObject.Mountable<boolean?>?--
game-wide
priority: number?--
defaults to AccessFactPriority.DEFAULT
source: string?--
label for readouts, defaults to "default"
serverOverrideBehavior: string?--
see AccessFactServerOverrideBehavior, defaults to allow-only
}
Exactly one of resolve or value. An options table rather than a positional resolver because a fact
has more to say about itself than its answer -- where it sits, what to call it in a readout.
source labels this layer in a debug readout. It only has to be unique among layers of the same fact,
so the default is fine until you add a second layer, at which point registration makes you name it.
Properties
ABSTAIN
AccessFact.ABSTAIN: userdataReturned by a resolver that has nothing to say, so a lower-priority layer answers instead. Sugar for AccessFactContributionState.ABSTAIN.
Distinct from returning nil, which means "I am answering, and my answer is that nobody knows yet".
One is silence and the other is an answer; they behave differently in the merge, and naming both is
what stopped that difference from living in a nil.
Functions
contribution
An answer with attribution attached. Use where "yes" alone is not enough for the UI you will build: "you own this" is less useful than "you own this because of gamepass 12345", and a friend-granted fact is nearly useless without knowing which friend.
resolve = function(_serviceBag, player)
return observeFriendGrant(player):Pipe({
Rx.map(function(friend)
return AccessFact.contribution(friend ~= nil, { grantedByUserId = friend })
end),
})
end
contributionOfState
A contribution in a state directly, for the states no boolean can express.
isContribution
AccessFact.isContribution(value: any) → booleannew
isAccessFact
AccessFact.isAccessFact(value: any) → booleanInit
Stores the ServiceBag resolvers are handed. Called by AccessDataService on registration.
GetFactName
GetPriority
GetSource
GetServerOverrideBehavior
How this fact's server answer combines with a locally-resolved one. See AccessFactServerOverrideBehavior.
ObserveForPlayer
What this layer says about the player, live.
Emits immediately -- unresolved until the resolver answers -- so a consumer always has something to render, and so Rx.combineLatest over several layers starts producing at once rather than waiting on the slowest lookup.
One resolver run is shared between every concurrent subscriber -- the fan-in that stops five surfaces from opening five copies of the same ownership lookup. The share is refcounted, so the resolver is torn down once nobody is listening and re-run for whoever asks next.
RemovePlayer
Drops the cached resolution for a player who is gone. The cache is weak-keyed as well, but GC is not a schedule -- a departed player's ownership lookup should stop being subscribable the moment they leave, not whenever the collector next runs.
GetDebugState
A plain snapshot of this layer, for printing while you reason about a registry.