AccessFeature
One capability a game gates: entering a chapter, buying an egg, opening a demo area. A feature is policy -- it reads facts and decides.
Every judgement lives here: which combination of facts opens a thing, what a release flag means for it, inversions, per-thing context. Facts state what is true; features decide what that is worth. That is why the same fact can grant one feature and deny another.
-- The common case needs no function.
local Chapters = AccessFeature.anyOf("chapters", { "ownsGame", "isEarlyAccessTester" })
-- Anything else declares its non-fact inputs and folds them in.
local EggPurchase = AccessFeature.new("eggPurchase", {
facts = { "ownsGame", "isEarlyAccessTester" },
context = {
assetId = function(eggName) return observeEggAssetId(eggName) end,
hasCollected = function(eggName) return observeCollected(eggName) end,
},
observeCompute = function(observeFacts, eggName, observeContext)
return Rx.combineLatest({
facts = observeFacts,
context = observeContext,
}):Pipe({
Rx.map(EggHuntAccessPolicy.computeEggPurchase),
})
end,
})
context is for inputs that are not facts, because facts are per-player and these are per-thing:
an egg's asset, whether it has been collected. Declaring them rather than closing over them inside
observeCompute is what lets a report print them beside the facts -- "why is this egg refused" is
answered by the context at least as often, and an input nobody can see is an input nobody can debug.
observeCompute is handed the fact observable rather than each fact value, so a feature that needs context of
its own combines it once instead of rebuilding it every time a fact changes. Keep the verdict itself a
pure function the pipe maps through -- that is the part worth unit testing, and it stays testable
without an observable in sight.
facts is declared rather than inferred so an unresolved fact only stalls the features that actually
read it. One dead mechanism should not leave the whole game unresolved.
Types
AccessFeatureCompute
AccessFeatureInput
interface AccessFeatureInput {observeContext: Observable<{[string]: any}>--
the declared non-fact inputs, resolved for this subject
}Everything a compute gets beyond its facts and its subject.
A named bag rather than more positional arguments: facts and subject are what nearly every feature uses, and the rest has already grown three times. Adding to this cannot churn a compute that does not read it.
AccessFeatureContext
Named inputs that are not facts, resolved from the subject.
Functions
new
anyOf
A feature granted by any one of its facts, unresolved while one is unanswered and nothing else has granted, denied only once every answer is in and none of them granted. See AccessStateUtils.fromFacts.
allOf
A feature granted only when every declared fact allows -- the and-of that a flag-gated grant
actually is, like isEarlyAccessTester and testerEarlyAccessEnabled.
Unresolved while any of them is unanswered, refused as soon as one denies. A definite no ends it: no later answer can rescue an and-of, so waiting would be a stall with a known answer behind it.
Facts pushed on later still widen, they do not join the and: allOf(a, b) granted by a push means
(a and b) or pushed. Anything else would make AccessFeature.PushFactAllowsFeature able to take
access away, which is exactly the surprise it promises not to have.
noneOf
A feature granted only when every declared fact is definitely false -- "does not own the game".
Unresolved while any of them is unanswered, which is the whole reason this exists rather than a not
in a compute: inverting the value turns unresolved into true, and on a purchase gate that is offering
to sell somebody something they already have.
Pushed facts widen, on the same terms as AccessFeature.allOf.
alwaysAllowed
A feature nothing can gate. Useful as the always-open half of a pair, and as the thing a game registers while a real rule is still being written.
isAccessFeature
AccessFeature.isAccessFeature(value: any) → booleanGetFeatureName
GetFactNames
The facts this feature reads. A copy, so a caller cannot quietly widen what the feature depends on.
ObserveFactNames
The facts this feature reads, live. Changes when something is pushed onto the feature.
PushFactAllowsFeature
AccessFeature.PushFactAllowsFeature(fact: AccessFact) → () → ()--
Removes the fact from this feature
Adds a fact that also allows this feature, and hands back a function that removes it again.
This is how a feature is extended without editing it. A package ships owns-game reading a purchase;
a game pushes a gamepass and a staff allowlist onto the same feature, and everything already gating on
owns-game picks them up.
maid:GiveTask(ownsGame:PushFactAllowsFeature(gamePassFact))
maid:GiveTask(ownsGame:PushFactAllowsFeature(adminFact))
The fact must be registered with AccessDataService as well -- pushing says this fact grants this feature, registering says here is how to answer it. Pushing an unregistered fact fails loudly when the feature is next read, rather than silently never granting.
Pushing is strictly widening: a pushed fact can grant, never deny. Anything else would make "add a way in" able to take one away, which is exactly the sort of surprise this API should not have.
A push made on the server reaches the client -- AccessDataService replicates what each feature reads, and the client widens by name. So this is safe to call from server-only code without the two realms drifting apart on what the feature means.
PushFactNameAllowsFeature
AccessFeature.PushFactNameAllowsFeature(factName: string) → () → ()--
Removes the fact from this feature
The same widening by name rather than by object, for a realm that has the name but not the fact.
This is what server-to-client replication of a push arrives through: the client learns that a fact grants a feature before -- or without ever -- having a resolver for it, and the value comes over the per-player fact replication. Prefer AccessFeature.PushFactAllowsFeature anywhere the fact is in hand, because passing the object is what makes it obvious the fact has to exist somewhere.
RequiresSubject
Whether asking about this feature without a subject is a meaningless question.
"Can they enter a world" has no answer; "can they enter world 3" does. Anything that walks the whole registry -- the per-player tracker behind AccessPlayerBase.IsFeatureAllowed, the console dump -- leaves these alone rather than evaluating them with nothing, which at best reports a verdict nobody asked for and at worst runs a compute against a nil it was never written for.
GetFeatureInputs
The other features this one reads the verdict of.
Declared rather than reached for inside a compute, because a verdict that came from somewhere the
report cannot name is a verdict nobody can explain. FeatureAccessFact does the other conversion --
feature to fact -- which collapses it to a boolean; this keeps the whole state, so a refusal for
boughtAccessDisabled stays distinguishable from one for notOwned.
Returns loosely typed for the same reason the fact layers are: an array of a BaseObject-derived class does not unify with itself under the old solver, and the cascade reaches every caller.
GetContextNames
The names of this feature's non-fact inputs.
ObserveContext
This feature's non-fact inputs, resolved against a subject, as one table keyed by name.
Facts are per-player, so anything per-thing -- an egg's asset, whether it has been collected -- cannot
be one. Declaring those here instead of closing over them inside observeCompute is what lets a report
print them beside the facts: an input nobody can see is an input nobody can debug, and "why is this egg
refused" is answered by the context as often as by the facts.
Emits an empty table for a feature that declared none, rather than never emitting, so a caller can combine it unconditionally.
GetDebugState
AccessFeature.GetDebugState(self: AccessFeature) → {featureName: string,facts: {string},context: {string}}A plain snapshot of this feature, including anything pushed onto it since it was written.
ObserveCompute
Runs the feature's policy. Called by AccessDataService, which supplies the fact observable.
observeFeatures and player can only come from AccessDataService, which has the registry and knows
who is being asked about. A feature declaring no feature inputs gets an empty map rather than nothing,
so a compute can combine it unconditionally.