Skip to main content

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 = {valueboolean?}?

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

type AccessFactResolver = () → ValueObject.Mountable<boolean?>

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 {
resolveAccessFactResolver?--

per-player

valueValueObject.Mountable<boolean?>?--

game-wide

prioritynumber?--

defaults to AccessFactPriority.DEFAULT

sourcestring?--

label for readouts, defaults to "default"

serverOverrideBehaviorstring?--

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: userdata

Returned 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

AccessFact.contribution(
valueboolean?,
metadataany?
) → AccessFactContribution

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

AccessFact.contributionOfState(
statestring,
metadataany?
) → AccessFactContribution

A contribution in a state directly, for the states no boolean can express.

isContribution

AccessFact.isContribution(valueany) → boolean

new

AccessFact.new(
factNamestring,
) → AccessFact

isAccessFact

AccessFact.isAccessFact(valueany) → boolean

Init

AccessFact.Init(
selfAccessFact,
serviceBagServiceBag
) → ()

Stores the ServiceBag resolvers are handed. Called by AccessDataService on registration.

GetFactName

AccessFact.GetFactName(selfAccessFact) → string

GetPriority

AccessFact.GetPriority(selfAccessFact) → number

GetSource

AccessFact.GetSource(selfAccessFact) → string

GetServerOverrideBehavior

AccessFact.GetServerOverrideBehavior(selfAccessFact) → string

How this fact's server answer combines with a locally-resolved one. See AccessFactServerOverrideBehavior.

ObserveForPlayer

AccessFact.ObserveForPlayer(
selfAccessFact,
playerPlayer
) → Observable<AccessFactContribution>

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

AccessFact.RemovePlayer(
selfAccessFact,
playerPlayer
) → ()

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

AccessFact.GetDebugState(selfAccessFact) → {
factNamestring,
prioritynumber,
sourcestring
}

A plain snapshot of this layer, for printing while you reason about a registry.

Show raw api
{
    "functions": [
        {
            "name": "contribution",
            "desc": "An answer with attribution attached. Use where \"yes\" alone is not enough for the UI you will build:\n\"you own this\" is less useful than \"you own this because of gamepass 12345\", and a friend-granted\nfact is nearly useless without knowing *which* friend.\n\n```lua\nresolve = function(_serviceBag, player)\n\treturn observeFriendGrant(player):Pipe({\n\t\tRx.map(function(friend)\n\t\t\treturn AccessFact.contribution(friend ~= nil, { grantedByUserId = friend })\n\t\tend),\n\t})\nend\n```",
            "params": [
                {
                    "name": "value",
                    "desc": "",
                    "lua_type": "boolean?"
                },
                {
                    "name": "metadata",
                    "desc": "",
                    "lua_type": "any?"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFactContribution"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 75,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "contributionOfState",
            "desc": "A contribution in a state directly, for the states no boolean can express.",
            "params": [
                {
                    "name": "state",
                    "desc": "",
                    "lua_type": "string"
                },
                {
                    "name": "metadata",
                    "desc": "",
                    "lua_type": "any?"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFactContribution"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 90,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "isContribution",
            "desc": "",
            "params": [
                {
                    "name": "value",
                    "desc": "",
                    "lua_type": "any"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "boolean"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 104,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "new",
            "desc": "",
            "params": [
                {
                    "name": "factName",
                    "desc": "",
                    "lua_type": "string"
                },
                {
                    "name": "options",
                    "desc": "",
                    "lua_type": "AccessFactOptions"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFact"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 195,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "isAccessFact",
            "desc": "",
            "params": [
                {
                    "name": "value",
                    "desc": "",
                    "lua_type": "any"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "boolean"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 237,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "Init",
            "desc": "Stores the [ServiceBag] resolvers are handed. Called by [AccessDataService] on registration.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                },
                {
                    "name": "serviceBag",
                    "desc": "",
                    "lua_type": "ServiceBag"
                }
            ],
            "returns": [],
            "function_type": "static",
            "source": {
                "line": 246,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "GetFactName",
            "desc": "",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "string"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 255,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "GetPriority",
            "desc": "",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "number"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 262,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "GetSource",
            "desc": "",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "string"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 269,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "GetServerOverrideBehavior",
            "desc": "How this fact's server answer combines with a locally-resolved one. See\n[AccessFactServerOverrideBehavior].",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "string"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 279,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "ObserveForPlayer",
            "desc": "What this layer says about the player, live.\n\nEmits immediately -- unresolved until the resolver answers -- so a consumer always has something to\nrender, and so [Rx.combineLatest] over several layers starts producing at once rather than waiting on\nthe slowest lookup.\n\nOne resolver run is shared between every concurrent subscriber -- the fan-in that stops five surfaces\nfrom opening five copies of the same ownership lookup. The share is refcounted, so the resolver is torn\ndown once nobody is listening and re-run for whoever asks next.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                },
                {
                    "name": "player",
                    "desc": "",
                    "lua_type": "Player"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "Observable<AccessFactContribution>"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 297,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "RemovePlayer",
            "desc": "Drops the cached resolution for a player who is gone. The cache is weak-keyed as well, but GC is not a\nschedule -- a departed player's ownership lookup should stop being subscribable the moment they leave,\nnot whenever the collector next runs.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                },
                {
                    "name": "player",
                    "desc": "",
                    "lua_type": "Player"
                }
            ],
            "returns": [],
            "function_type": "static",
            "source": {
                "line": 338,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "GetDebugState",
            "desc": "A plain snapshot of this layer, for printing while you reason about a registry.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFact"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "{ factName: string, priority: number, source: string }"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 347,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        }
    ],
    "properties": [
        {
            "name": "ABSTAIN",
            "desc": "Returned by a resolver that has nothing to say, so a lower-priority layer answers instead. Sugar for\n[AccessFactContributionState].ABSTAIN.\n\nDistinct from returning `nil`, which means \"I am answering, and my answer is that nobody knows yet\".\nOne is silence and the other is an answer; they behave differently in the merge, and naming both is\nwhat stopped that difference from living in a nil.",
            "lua_type": "userdata",
            "source": {
                "line": 119,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        }
    ],
    "types": [
        {
            "name": "AccessFactContribution",
            "desc": "What a layer said: an [AccessFactContributionState] plus, for the states that carry one, the boolean\nit means. Always a table -- there is no nil contribution any more, because a nil could not be told\napart from a layer that said nothing and could not survive being put in a map.\n\n`metadata` is why it said so, in whatever shape the mechanism has: which friend granted access, which\ngamepass was owned, which allowlist matched. Opaque to this package -- it is carried, printed and\nhanded back, never interpreted -- because only the mechanism knows what is worth attributing.",
            "lua_type": "{ value: boolean? }?",
            "source": {
                "line": 133,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "AccessFactResolver",
            "desc": "Resolves the fact for one player. Returns anything [ValueObject.Mountable] accepts, so a test can hand\nback a bare `true` where production hands back an observable -- or [AccessFact.ABSTAIN].",
            "lua_type": "(ServiceBag, Player) -> ValueObject.Mountable<boolean?>",
            "source": {
                "line": 146,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        },
        {
            "name": "AccessFactOptions",
            "desc": "Exactly one of `resolve` or `value`. An options table rather than a positional resolver because a fact\nhas more to say about itself than its answer -- where it sits, what to call it in a readout.\n\n`source` labels this layer in a debug readout. It only has to be unique among layers of the same fact,\nso the default is fine until you add a second layer, at which point registration makes you name it.",
            "fields": [
                {
                    "name": "resolve",
                    "lua_type": "AccessFactResolver?",
                    "desc": "per-player"
                },
                {
                    "name": "value",
                    "lua_type": "ValueObject.Mountable<boolean?>?",
                    "desc": "game-wide"
                },
                {
                    "name": "priority",
                    "lua_type": "number?",
                    "desc": "defaults to AccessFactPriority.DEFAULT"
                },
                {
                    "name": "source",
                    "lua_type": "string?",
                    "desc": "label for readouts, defaults to \"default\""
                },
                {
                    "name": "serverOverrideBehavior",
                    "lua_type": "string?",
                    "desc": "see AccessFactServerOverrideBehavior, defaults to allow-only"
                }
            ],
            "source": {
                "line": 163,
                "path": "src/access/src/Shared/AccessFact.lua"
            }
        }
    ],
    "name": "AccessFact",
    "desc": "One layer of one fact about a player: `true`, `false`, or `nil` for not-yet-answered. Facts are never\ngated by a release flag and never consult each other -- \"does this player own the pass\" is the same\nquestion whether or not the game has launched. What a fact *means* is [AccessFeature]'s business.\n\nTwo ways to answer, and the difference is per-player versus game-wide:\n\n```lua\n-- Per-player: a resolver, run once per player and shared between every consumer.\nmaid:Add(AccessFact.new(\"ownsFullAccessPass\", {\n\tresolve = function(serviceBag, player)\n\t\treturn serviceBag:GetService(GameProductDataService)\n\t\t\t:ObservePlayerOwnership(player, GameConfigAssetTypes.PASS, FULL_ACCESS_GAME_PASS_KEY)\n\tend,\n}))\n\n-- Game-wide: one [ValueObject.Mountable] every player reads the same answer from.\nmaid:Add(AccessFact.new(\"eventIsRunning\", { value = eventRunningValueObject }))\n```\n\nA fact has a lifetime -- it holds a shared observable per player -- so give it to a maid where you make\nit. Registering it does not take ownership.\n\nSeveral layers may answer the same fact -- a group rank and an allowlist both saying whether someone is\nstaff. Each declares an [AccessFactPriority], and the highest layer that *contributes* decides. Return\n[AccessFact.ABSTAIN] to contribute nothing and let a lower layer answer; returning `nil` is an answer,\nand the answer is \"unresolved\".\n\nGOTCHA: a fact resolves in whichever realm asks it, so a resolver that only works on the server leaves\nthe client permanently unresolved -- and every feature declaring that fact never settles there. Resolve\nserver-side work into a replicated player attribute and have the resolver read *that* in both realms.",
    "source": {
        "line": 36,
        "path": "src/access/src/Shared/AccessFact.lua"
    }
}