@quenty/gameconfig 12.53.0 → 12.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +6 -4
- package/src/Server/GameConfigService.lua +8 -4
- package/src/Server/Manifest/NevermoreManifestConfigProvider.lua +80 -0
- package/src/Server/Manifest/NevermoreManifestConfigProvider.spec.lua +95 -0
- package/src/Shared/Config/Asset/GameConfigAssetBase.lua +14 -0
- package/src/Shared/Config/Asset/GameConfigAssetConstants.lua +5 -0
- package/src/Shared/Config/Asset/GameConfigAssetUtils.lua +14 -1
- package/src/Shared/Config/AssetTypes/GameConfigAssetTypeUtils.lua +1 -0
- package/src/Shared/Config/AssetTypes/GameConfigAssetTypes.lua +11 -0
- package/src/Shared/Config/Picker/GameConfigPicker.lua +16 -3
- package/src/Shared/Config/Picker/GameConfigPicker.spec.lua +170 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [12.55.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/gameconfig@12.54.0...@quenty/gameconfig@12.55.0) (2026-07-21)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @quenty/gameconfig
|
|
9
|
+
|
|
10
|
+
# [12.54.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/gameconfig@12.53.0...@quenty/gameconfig@12.54.0) (2026-07-20)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- Strict typing on tests ([efbc458](https://github.com/Quenty/NevermoreEngine/commit/efbc45882fac95755ea4ff4e007c5f71bedebf6f))
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
- **gameconfig:** add asset selection priority for key clashes ([ae7d765](https://github.com/Quenty/NevermoreEngine/commit/ae7d7651f6a3270e8e0ee9fc592b4e6bc2fc5a51))
|
|
19
|
+
- **gameconfig:** auto-register manifest places via NevermoreManifestConfigProvider ([db58cfc](https://github.com/Quenty/NevermoreEngine/commit/db58cfc62b9164f316895c4252942bd292a02f89))
|
|
20
|
+
- **gameproductservice:** add paid-access game ownership tracking ([581a595](https://github.com/Quenty/NevermoreEngine/commit/581a5950d433ea4d6dfbcc33e88f254f9f13e14c))
|
|
21
|
+
|
|
6
22
|
# [12.53.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/gameconfig@12.52.0...@quenty/gameconfig@12.53.0) (2026-07-18)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @quenty/gameconfig
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/gameconfig",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.55.0",
|
|
4
4
|
"description": "Configuration service to specify Roblox badges, products, and other specific assets.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -42,8 +42,9 @@
|
|
|
42
42
|
"@quenty/instanceutils": "13.32.0",
|
|
43
43
|
"@quenty/loader": "10.11.0",
|
|
44
44
|
"@quenty/maid": "3.10.0",
|
|
45
|
-
"@quenty/marketplaceutils": "11.
|
|
45
|
+
"@quenty/marketplaceutils": "11.24.0",
|
|
46
46
|
"@quenty/nevermore-test-runner": "1.4.0",
|
|
47
|
+
"@quenty/nevermoreclimanifest": "1.5.0",
|
|
47
48
|
"@quenty/observablecollection": "12.41.0",
|
|
48
49
|
"@quenty/playerutils": "8.35.0",
|
|
49
50
|
"@quenty/preferredparentutils": "4.4.2",
|
|
@@ -57,7 +58,8 @@
|
|
|
57
58
|
"@quenty/statestack": "14.34.0",
|
|
58
59
|
"@quenty/string": "3.3.7",
|
|
59
60
|
"@quenty/table": "3.9.2",
|
|
60
|
-
"@quenty/valueobject": "13.33.0"
|
|
61
|
+
"@quenty/valueobject": "13.33.0",
|
|
62
|
+
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
61
63
|
},
|
|
62
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "8bd905a5c7e7f1fd4e178b54beeadef94a615d1d"
|
|
63
65
|
}
|
|
@@ -103,9 +103,10 @@ end
|
|
|
103
103
|
Adds a new place with the key configured to the `assetKey`
|
|
104
104
|
@param assetKey string -- Key name to use for the place
|
|
105
105
|
@param placeId number -- Cloud id
|
|
106
|
+
@param priority number? -- Selection priority; higher wins on a key clash
|
|
106
107
|
]=]
|
|
107
|
-
function GameConfigService:AddPlace(assetKey: string, placeId: number)
|
|
108
|
-
self:AddTypedAsset(GameConfigAssetTypes.PLACE, assetKey, placeId)
|
|
108
|
+
function GameConfigService:AddPlace(assetKey: string, placeId: number, priority: number?)
|
|
109
|
+
return self:AddTypedAsset(GameConfigAssetTypes.PLACE, assetKey, placeId, priority)
|
|
109
110
|
end
|
|
110
111
|
|
|
111
112
|
--[=[
|
|
@@ -141,19 +142,22 @@ end
|
|
|
141
142
|
@param assetType GameConfigAssetType
|
|
142
143
|
@param assetKey string -- Key name to use for the bundle
|
|
143
144
|
@param assetId number -- Cloud id
|
|
145
|
+
@param priority number? -- Selection priority; higher wins on a key clash
|
|
144
146
|
]=]
|
|
145
147
|
function GameConfigService:AddTypedAsset(
|
|
146
148
|
assetType: GameConfigAssetTypes.GameConfigAssetType,
|
|
147
149
|
assetKey: string,
|
|
148
|
-
assetId: number
|
|
150
|
+
assetId: number,
|
|
151
|
+
priority: number?
|
|
149
152
|
)
|
|
150
153
|
assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType")
|
|
151
154
|
assert(type(assetKey) == "string", "Bad assetKey")
|
|
152
155
|
assert(type(assetId) == "number", "Bad assetId")
|
|
156
|
+
assert(priority == nil or type(priority) == "number", "Bad priority")
|
|
153
157
|
|
|
154
158
|
local gameConfig = self:_getOrCreateDefaultGameConfig()
|
|
155
159
|
|
|
156
|
-
local asset = GameConfigAssetUtils.create(self._binders.GameConfigAsset, assetType, assetKey, assetId)
|
|
160
|
+
local asset = GameConfigAssetUtils.create(self._binders.GameConfigAsset, assetType, assetKey, assetId, priority)
|
|
157
161
|
asset.Parent = GameConfigUtils.getOrCreateAssetFolder(gameConfig, assetType)
|
|
158
162
|
|
|
159
163
|
return asset
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Registers the deploy target's places -- read from the nevermore CLI manifest
|
|
4
|
+
baked into the running place -- as PLACE assets in [GameConfigService], each
|
|
5
|
+
at a high priority so it wins over a hand-authored place sharing the same key
|
|
6
|
+
(see [GameConfigPicker.FindFirstActiveAssetOfKey]).
|
|
7
|
+
|
|
8
|
+
Add it to a server [ServiceBag] after [GameConfigService]:
|
|
9
|
+
|
|
10
|
+
```lua
|
|
11
|
+
serviceBag:GetService(require("GameConfigService"))
|
|
12
|
+
serviceBag:GetService(require("NevermoreManifestConfigProvider"))
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
In Studio or an undeployed build the manifest place table is empty, so nothing
|
|
16
|
+
is registered and hand-authored config resolves exactly as before.
|
|
17
|
+
|
|
18
|
+
@server
|
|
19
|
+
@class NevermoreManifestConfigProvider
|
|
20
|
+
]=]
|
|
21
|
+
|
|
22
|
+
local require = require(script.Parent.loader).load(script)
|
|
23
|
+
|
|
24
|
+
local GameConfigService = require("GameConfigService")
|
|
25
|
+
local Maid = require("Maid")
|
|
26
|
+
local NevermoreCLIManifestUtils = require("NevermoreCLIManifestUtils")
|
|
27
|
+
local ServiceBag = require("ServiceBag")
|
|
28
|
+
|
|
29
|
+
-- Well above the default priority (0) so a manifest place always beats a
|
|
30
|
+
-- hand-authored one that shares its key.
|
|
31
|
+
local MANIFEST_PLACE_PRIORITY = 1000
|
|
32
|
+
|
|
33
|
+
local NevermoreManifestConfigProvider = {}
|
|
34
|
+
NevermoreManifestConfigProvider.ServiceName = "NevermoreManifestConfigProvider"
|
|
35
|
+
|
|
36
|
+
export type NevermoreManifestConfigProvider = typeof(setmetatable(
|
|
37
|
+
{} :: {
|
|
38
|
+
_serviceBag: ServiceBag.ServiceBag,
|
|
39
|
+
_gameConfigService: GameConfigService.GameConfigService,
|
|
40
|
+
_maid: Maid.Maid,
|
|
41
|
+
},
|
|
42
|
+
{} :: typeof({ __index = NevermoreManifestConfigProvider })
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
function NevermoreManifestConfigProvider.Init(self: NevermoreManifestConfigProvider, serviceBag: ServiceBag.ServiceBag)
|
|
46
|
+
assert(not (self :: any)._serviceBag, "Already initialized")
|
|
47
|
+
self._serviceBag = assert(serviceBag, "No serviceBag")
|
|
48
|
+
self._maid = Maid.new()
|
|
49
|
+
|
|
50
|
+
self._gameConfigService = self._serviceBag:GetService(GameConfigService :: any)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
function NevermoreManifestConfigProvider.Start(self: NevermoreManifestConfigProvider)
|
|
54
|
+
self:_applyPlaces(NevermoreCLIManifestUtils.getPlaces())
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
--[=[
|
|
58
|
+
Registers each named place as a high-priority PLACE asset. Nameless entries
|
|
59
|
+
(single-place targets) are skipped -- there is no config key to bind them to.
|
|
60
|
+
|
|
61
|
+
@param places { NevermoreCLIManifestUtils.ManifestPlace }
|
|
62
|
+
@private
|
|
63
|
+
]=]
|
|
64
|
+
function NevermoreManifestConfigProvider._applyPlaces(
|
|
65
|
+
self: NevermoreManifestConfigProvider,
|
|
66
|
+
places: { NevermoreCLIManifestUtils.ManifestPlace }
|
|
67
|
+
)
|
|
68
|
+
for _, place in places do
|
|
69
|
+
if place.name then
|
|
70
|
+
local asset = self._gameConfigService:AddPlace(place.name, place.placeId, MANIFEST_PLACE_PRIORITY)
|
|
71
|
+
self._maid:GiveTask(asset)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
function NevermoreManifestConfigProvider.Destroy(self: NevermoreManifestConfigProvider)
|
|
77
|
+
self._maid:DoCleaning()
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return NevermoreManifestConfigProvider
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Coverage for NevermoreManifestConfigProvider: it registers named manifest
|
|
4
|
+
places as high-priority PLACE assets (so they win over a hand-authored place
|
|
5
|
+
sharing the key) and skips nameless single-place-target entries.
|
|
6
|
+
|
|
7
|
+
Registration is driven through the private _applyPlaces so the test controls
|
|
8
|
+
the place table directly, rather than depending on a real deploy stamp on the
|
|
9
|
+
manifest module. Each test uses distinct keys/ids; assets bind through global
|
|
10
|
+
CollectionService tags under a shared parent, so they persist across tests in
|
|
11
|
+
a run.
|
|
12
|
+
|
|
13
|
+
@class NevermoreManifestConfigProvider.spec.lua
|
|
14
|
+
]]
|
|
15
|
+
local require = require(script.Parent.loader).load(script)
|
|
16
|
+
|
|
17
|
+
local GameConfigAssetTypes = require("GameConfigAssetTypes")
|
|
18
|
+
local GameConfigService = require("GameConfigService")
|
|
19
|
+
local Jest = require("Jest")
|
|
20
|
+
local NevermoreManifestConfigProvider = require("NevermoreManifestConfigProvider")
|
|
21
|
+
local ServiceBag = require("ServiceBag")
|
|
22
|
+
|
|
23
|
+
local describe = Jest.Globals.describe
|
|
24
|
+
local expect = Jest.Globals.expect
|
|
25
|
+
local it = Jest.Globals.it
|
|
26
|
+
|
|
27
|
+
local PLACE: GameConfigAssetTypes.GameConfigAssetType = GameConfigAssetTypes.PLACE
|
|
28
|
+
|
|
29
|
+
local function newProvider()
|
|
30
|
+
local serviceBag = ServiceBag.new()
|
|
31
|
+
local gameConfigService = (serviceBag:GetService(GameConfigService) :: any) :: GameConfigService.GameConfigService
|
|
32
|
+
local provider = (
|
|
33
|
+
serviceBag:GetService(NevermoreManifestConfigProvider) :: any
|
|
34
|
+
) :: NevermoreManifestConfigProvider.NevermoreManifestConfigProvider
|
|
35
|
+
serviceBag:Init()
|
|
36
|
+
serviceBag:Start()
|
|
37
|
+
return serviceBag, gameConfigService, provider, gameConfigService:GetConfigPicker()
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe("NevermoreManifestConfigProvider", function()
|
|
41
|
+
it("registers a named place as a resolvable PLACE asset", function()
|
|
42
|
+
local serviceBag, _, provider, picker = newProvider()
|
|
43
|
+
|
|
44
|
+
provider:_applyPlaces({
|
|
45
|
+
{ name = "provChapterA", placeId = 555, universeId = 1 },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
local asset = picker:FindFirstActiveAssetOfKey(PLACE, "provChapterA")
|
|
49
|
+
expect(asset).never.toBeNil()
|
|
50
|
+
assert(asset, "no asset")
|
|
51
|
+
expect(asset:GetAssetId()).toEqual(555)
|
|
52
|
+
|
|
53
|
+
serviceBag:Destroy()
|
|
54
|
+
end)
|
|
55
|
+
|
|
56
|
+
it("wins over a hand-authored place sharing the key", function()
|
|
57
|
+
local serviceBag, gameConfigService, provider, picker = newProvider()
|
|
58
|
+
|
|
59
|
+
gameConfigService:AddPlace("provChapterB", 111) -- hand-authored, priority 0
|
|
60
|
+
provider:_applyPlaces({
|
|
61
|
+
{ name = "provChapterB", placeId = 999, universeId = 1 },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
local asset = picker:FindFirstActiveAssetOfKey(PLACE, "provChapterB")
|
|
65
|
+
assert(asset, "no asset")
|
|
66
|
+
expect(asset:GetAssetId()).toEqual(999)
|
|
67
|
+
expect(asset:GetPriority() > 0).toEqual(true)
|
|
68
|
+
|
|
69
|
+
serviceBag:Destroy()
|
|
70
|
+
end)
|
|
71
|
+
|
|
72
|
+
it("skips nameless (single-place-target) entries", function()
|
|
73
|
+
local serviceBag, _, provider, picker = newProvider()
|
|
74
|
+
|
|
75
|
+
expect(function()
|
|
76
|
+
provider:_applyPlaces({
|
|
77
|
+
{ placeId = 777, universeId = 1 },
|
|
78
|
+
})
|
|
79
|
+
end).never.toThrow()
|
|
80
|
+
|
|
81
|
+
-- Nameless places have no config key to bind to, so nothing is registered.
|
|
82
|
+
expect(picker:FindFirstActiveAssetOfId(PLACE, 777)).toBeNil()
|
|
83
|
+
|
|
84
|
+
serviceBag:Destroy()
|
|
85
|
+
end)
|
|
86
|
+
|
|
87
|
+
it("registers nothing for an empty place table", function()
|
|
88
|
+
local serviceBag, _, provider, picker = newProvider()
|
|
89
|
+
|
|
90
|
+
provider:_applyPlaces({})
|
|
91
|
+
expect(picker:FindFirstActiveAssetOfKey(PLACE, "provNeverAdded")).toBeNil()
|
|
92
|
+
|
|
93
|
+
serviceBag:Destroy()
|
|
94
|
+
end)
|
|
95
|
+
end)
|
|
@@ -126,6 +126,20 @@ function GameConfigAssetBase:GetAssetType(): string?
|
|
|
126
126
|
return self._obj:GetAttribute(GameConfigAssetConstants.ASSET_TYPE_ATTRIBUTE)
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
+
--[=[
|
|
130
|
+
Gets the asset's selection priority. Higher wins when several active assets
|
|
131
|
+
share a type and key. Assets that never set it read back as
|
|
132
|
+
[GameConfigAssetConstants.DEFAULT_PRIORITY].
|
|
133
|
+
@return number
|
|
134
|
+
]=]
|
|
135
|
+
function GameConfigAssetBase:GetPriority(): number
|
|
136
|
+
local priority = self._obj:GetAttribute(GameConfigAssetConstants.PRIORITY_ATTRIBUTE)
|
|
137
|
+
if type(priority) == "number" then
|
|
138
|
+
return priority
|
|
139
|
+
end
|
|
140
|
+
return GameConfigAssetConstants.DEFAULT_PRIORITY
|
|
141
|
+
end
|
|
142
|
+
|
|
129
143
|
--[=[
|
|
130
144
|
Observes the asset type
|
|
131
145
|
@return Observable<string?>
|
|
@@ -10,4 +10,9 @@ local Table = require("Table")
|
|
|
10
10
|
return Table.readonly({
|
|
11
11
|
ASSET_TYPE_ATTRIBUTE = "AssetType",
|
|
12
12
|
ASSET_ID_ATTRIBUTE = "AssetId",
|
|
13
|
+
-- Higher wins when several active assets share a type and key. Absent means
|
|
14
|
+
-- the default, DEFAULT_PRIORITY -- so hand-authored assets (which never set
|
|
15
|
+
-- it) are beaten by a higher-priority one registered in code.
|
|
16
|
+
PRIORITY_ATTRIBUTE = "Priority",
|
|
17
|
+
DEFAULT_PRIORITY = 0,
|
|
13
18
|
})
|
|
@@ -18,17 +18,25 @@ local GameConfigAssetUtils = {}
|
|
|
18
18
|
|
|
19
19
|
--[=[
|
|
20
20
|
Creates a new game configuration
|
|
21
|
+
|
|
22
|
+
`priority` is only stamped when given; an asset without it reads back as
|
|
23
|
+
[GameConfigAssetConstants.DEFAULT_PRIORITY]. Pass a higher value to win over
|
|
24
|
+
hand-authored assets that share this type and key (see
|
|
25
|
+
[GameConfigPicker.FindFirstActiveAssetOfKey]).
|
|
26
|
+
|
|
21
27
|
@param binder Binder<GameConfigAssetBase>
|
|
22
28
|
@param assetType GameConfigAssetType
|
|
23
29
|
@param assetKey string
|
|
24
30
|
@param assetId number
|
|
31
|
+
@param priority number?
|
|
25
32
|
@return Instance
|
|
26
33
|
]=]
|
|
27
34
|
function GameConfigAssetUtils.create(
|
|
28
35
|
binder: Binder.Binder<any>, -- GameConfigAssetBase.GameConfigAssetBase (require cycle: GameConfigAssetBase requires this module)
|
|
29
36
|
assetType: GameConfigAssetTypes.GameConfigAssetType,
|
|
30
37
|
assetKey: string,
|
|
31
|
-
assetId: number
|
|
38
|
+
assetId: number,
|
|
39
|
+
priority: number?
|
|
32
40
|
): Folder
|
|
33
41
|
local asset = Instance.new("Folder")
|
|
34
42
|
asset.Name = assetKey
|
|
@@ -37,6 +45,9 @@ function GameConfigAssetUtils.create(
|
|
|
37
45
|
|
|
38
46
|
AttributeUtils.initAttribute(asset, GameConfigAssetConstants.ASSET_TYPE_ATTRIBUTE, assetType)
|
|
39
47
|
AttributeUtils.initAttribute(asset, GameConfigAssetConstants.ASSET_ID_ATTRIBUTE, assetId)
|
|
48
|
+
if priority ~= nil then
|
|
49
|
+
AttributeUtils.initAttribute(asset, GameConfigAssetConstants.PRIORITY_ATTRIBUTE, priority)
|
|
50
|
+
end
|
|
40
51
|
|
|
41
52
|
return asset
|
|
42
53
|
end
|
|
@@ -69,6 +80,8 @@ function GameConfigAssetUtils.promiseCloudDataForAssetType(
|
|
|
69
80
|
return marketplaceServiceCache:PromiseProductInfo(assetId, Enum.InfoType.GamePass)
|
|
70
81
|
elseif assetType == GameConfigAssetTypes.PLACE then
|
|
71
82
|
return marketplaceServiceCache:PromiseProductInfo(assetId, Enum.InfoType.Asset)
|
|
83
|
+
elseif assetType == GameConfigAssetTypes.GAME then
|
|
84
|
+
return marketplaceServiceCache:PromiseProductInfo(assetId, Enum.InfoType.Asset)
|
|
72
85
|
elseif assetType == GameConfigAssetTypes.ASSET then
|
|
73
86
|
return marketplaceServiceCache:PromiseProductInfo(assetId, Enum.InfoType.Asset)
|
|
74
87
|
elseif assetType == GameConfigAssetTypes.BUNDLE then
|
|
@@ -14,6 +14,7 @@ local pluralMap: { [GameConfigAssetTypes.GameConfigAssetType]: string } = {
|
|
|
14
14
|
[GameConfigAssetTypes.PRODUCT] = "products",
|
|
15
15
|
[GameConfigAssetTypes.PASS] = "passes",
|
|
16
16
|
[GameConfigAssetTypes.PLACE] = "places",
|
|
17
|
+
[GameConfigAssetTypes.GAME] = "games",
|
|
17
18
|
[GameConfigAssetTypes.ASSET] = "assets",
|
|
18
19
|
[GameConfigAssetTypes.BUNDLE] = "bundles",
|
|
19
20
|
[GameConfigAssetTypes.SUBSCRIPTION] = "subscriptions",
|
|
@@ -14,6 +14,7 @@ export type GameConfigAssetType =
|
|
|
14
14
|
| "asset"
|
|
15
15
|
| "bundle"
|
|
16
16
|
| "place"
|
|
17
|
+
| "game"
|
|
17
18
|
| "subscription"
|
|
18
19
|
| "membership"
|
|
19
20
|
|
|
@@ -63,6 +64,16 @@ return SimpleEnum.new({
|
|
|
63
64
|
]=]
|
|
64
65
|
PLACE = "place" :: "place",
|
|
65
66
|
|
|
67
|
+
--[=[
|
|
68
|
+
Specifies the asset is a game (universe). Ownership is checked against paid-access
|
|
69
|
+
via [MarketplaceService.PlayerOwnsAssetAsync]. Games cannot be prompted for purchase
|
|
70
|
+
in-experience, so prompting a game asset type throws.
|
|
71
|
+
|
|
72
|
+
@prop GAME string
|
|
73
|
+
@within GameConfigAssetTypes
|
|
74
|
+
]=]
|
|
75
|
+
GAME = "game" :: "game",
|
|
76
|
+
|
|
66
77
|
--[=[
|
|
67
78
|
Specifies the asset is of type subscription
|
|
68
79
|
@prop SUBSCRIPTION string
|
|
@@ -266,7 +266,13 @@ function GameConfigPicker.PromisePriceInRobux(
|
|
|
266
266
|
end
|
|
267
267
|
|
|
268
268
|
--[=[
|
|
269
|
-
Find the
|
|
269
|
+
Find the active asset of a given type and key.
|
|
270
|
+
|
|
271
|
+
When several active assets share the type and key, the one with the highest
|
|
272
|
+
[GameConfigAssetBase.GetPriority] wins; equal priorities keep the first
|
|
273
|
+
iterated (so a lone hand-authored asset resolves exactly as before, and a
|
|
274
|
+
higher-priority asset registered in code -- e.g. from the deploy manifest --
|
|
275
|
+
overrides it). Returns nil when no active asset matches.
|
|
270
276
|
|
|
271
277
|
@param assetType
|
|
272
278
|
@param assetKey
|
|
@@ -280,13 +286,20 @@ function GameConfigPicker.FindFirstActiveAssetOfKey(
|
|
|
280
286
|
assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType")
|
|
281
287
|
assert(type(assetKey) == "string", "Bad assetKey")
|
|
282
288
|
|
|
289
|
+
local best: GameConfigAssetBase? = nil
|
|
290
|
+
local bestPriority: number? = nil
|
|
291
|
+
|
|
283
292
|
for _, gameConfig in self:GetActiveConfigs() do
|
|
284
293
|
for _, gameConfigAsset in gameConfig:GetAssetsOfTypeAndKey(assetType, assetKey) do
|
|
285
|
-
|
|
294
|
+
local priority = gameConfigAsset:GetPriority()
|
|
295
|
+
if bestPriority == nil or priority > bestPriority then
|
|
296
|
+
best = gameConfigAsset
|
|
297
|
+
bestPriority = priority
|
|
298
|
+
end
|
|
286
299
|
end
|
|
287
300
|
end
|
|
288
301
|
|
|
289
|
-
return
|
|
302
|
+
return best
|
|
290
303
|
end
|
|
291
304
|
|
|
292
305
|
--[=[
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Coverage for GameConfigPicker priority selection: when several active assets
|
|
4
|
+
share a type and key (e.g. a hand-authored place and one registered from the
|
|
5
|
+
deploy manifest), the highest-priority one wins, and equal priorities keep
|
|
6
|
+
the first iterated -- so a lone hand-authored asset resolves exactly as it did
|
|
7
|
+
before priority existed.
|
|
8
|
+
|
|
9
|
+
Each test uses a distinct asset key. Configs are bound through global
|
|
10
|
+
CollectionService tags under a shared parent, so assets registered by one
|
|
11
|
+
ServiceBag remain visible to the next within a run; distinct keys keep lookups
|
|
12
|
+
isolated regardless.
|
|
13
|
+
|
|
14
|
+
@class GameConfigPicker.spec.lua
|
|
15
|
+
]]
|
|
16
|
+
local require = require(script.Parent.loader).load(script)
|
|
17
|
+
|
|
18
|
+
local GameConfigAssetTypes = require("GameConfigAssetTypes")
|
|
19
|
+
local GameConfigAssetUtils = require("GameConfigAssetUtils")
|
|
20
|
+
local GameConfigBindersServer = require("GameConfigBindersServer")
|
|
21
|
+
local GameConfigService = require("GameConfigService")
|
|
22
|
+
local GameConfigUtils = require("GameConfigUtils")
|
|
23
|
+
local Jest = require("Jest")
|
|
24
|
+
local ServiceBag = require("ServiceBag")
|
|
25
|
+
|
|
26
|
+
local describe = Jest.Globals.describe
|
|
27
|
+
local expect = Jest.Globals.expect
|
|
28
|
+
local it = Jest.Globals.it
|
|
29
|
+
|
|
30
|
+
local PLACE: GameConfigAssetTypes.GameConfigAssetType = GameConfigAssetTypes.PLACE
|
|
31
|
+
|
|
32
|
+
local function newPicker()
|
|
33
|
+
local serviceBag = ServiceBag.new()
|
|
34
|
+
local gameConfigService = (serviceBag:GetService(GameConfigService) :: any) :: GameConfigService.GameConfigService
|
|
35
|
+
serviceBag:Init()
|
|
36
|
+
serviceBag:Start()
|
|
37
|
+
return serviceBag, gameConfigService, gameConfigService:GetConfigPicker()
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe("GameConfigPicker.FindFirstActiveAssetOfKey priority", function()
|
|
41
|
+
it("resolves a single added place", function()
|
|
42
|
+
local serviceBag, gameConfigService, picker = newPicker()
|
|
43
|
+
|
|
44
|
+
gameConfigService:AddPlace("specResolve", 111)
|
|
45
|
+
local asset = picker:FindFirstActiveAssetOfKey(PLACE, "specResolve")
|
|
46
|
+
expect(asset).never.toBeNil()
|
|
47
|
+
assert(asset, "No asset")
|
|
48
|
+
expect(asset:GetAssetId()).toEqual(111)
|
|
49
|
+
|
|
50
|
+
serviceBag:Destroy()
|
|
51
|
+
end)
|
|
52
|
+
|
|
53
|
+
it("defaults an asset without a priority to DEFAULT_PRIORITY", function()
|
|
54
|
+
local serviceBag, gameConfigService, picker = newPicker()
|
|
55
|
+
|
|
56
|
+
gameConfigService:AddPlace("specDefaultPriority", 111)
|
|
57
|
+
local asset = picker:FindFirstActiveAssetOfKey(PLACE, "specDefaultPriority")
|
|
58
|
+
assert(asset, "No asset")
|
|
59
|
+
expect(asset:GetPriority()).toEqual(0)
|
|
60
|
+
|
|
61
|
+
serviceBag:Destroy()
|
|
62
|
+
end)
|
|
63
|
+
|
|
64
|
+
it("prefers the higher-priority asset added after a default one", function()
|
|
65
|
+
local serviceBag, gameConfigService, picker = newPicker()
|
|
66
|
+
|
|
67
|
+
gameConfigService:AddPlace("specClashAfter", 111) -- hand-authored (priority 0)
|
|
68
|
+
gameConfigService:AddPlace("specClashAfter", 222, 100) -- manifest (priority 100)
|
|
69
|
+
|
|
70
|
+
local asset = picker:FindFirstActiveAssetOfKey(PLACE, "specClashAfter")
|
|
71
|
+
assert(asset, "No asset")
|
|
72
|
+
expect(asset:GetAssetId()).toEqual(222)
|
|
73
|
+
|
|
74
|
+
serviceBag:Destroy()
|
|
75
|
+
end)
|
|
76
|
+
|
|
77
|
+
it("prefers the higher-priority asset even when added first", function()
|
|
78
|
+
local serviceBag, gameConfigService, picker = newPicker()
|
|
79
|
+
|
|
80
|
+
gameConfigService:AddPlace("specClashFirst", 222, 100) -- manifest first
|
|
81
|
+
gameConfigService:AddPlace("specClashFirst", 111) -- hand-authored second
|
|
82
|
+
|
|
83
|
+
local asset = picker:FindFirstActiveAssetOfKey(PLACE, "specClashFirst")
|
|
84
|
+
assert(asset, "No asset")
|
|
85
|
+
expect(asset:GetAssetId()).toEqual(222)
|
|
86
|
+
|
|
87
|
+
serviceBag:Destroy()
|
|
88
|
+
end)
|
|
89
|
+
|
|
90
|
+
it("resolves deterministically to a matching asset when priorities tie", function()
|
|
91
|
+
local serviceBag, gameConfigService, picker = newPicker()
|
|
92
|
+
|
|
93
|
+
gameConfigService:AddPlace("specTie", 111)
|
|
94
|
+
gameConfigService:AddPlace("specTie", 222)
|
|
95
|
+
|
|
96
|
+
-- Equal priority keeps the config's own iteration order (unchanged from
|
|
97
|
+
-- before priority existed), so the exact winner is implementation-defined.
|
|
98
|
+
-- The real contract is only that it is stable and matches -- a higher
|
|
99
|
+
-- priority is the deterministic tie-breaker, covered above.
|
|
100
|
+
local firstAsset = picker:FindFirstActiveAssetOfKey(PLACE, "specTie")
|
|
101
|
+
local secondAsset = picker:FindFirstActiveAssetOfKey(PLACE, "specTie")
|
|
102
|
+
assert(firstAsset, "No firstAsset")
|
|
103
|
+
assert(secondAsset, "No secondAsset")
|
|
104
|
+
local first = firstAsset:GetAssetId()
|
|
105
|
+
local second = secondAsset:GetAssetId()
|
|
106
|
+
expect(first).toEqual(second)
|
|
107
|
+
expect(first == 111 or first == 222).toEqual(true)
|
|
108
|
+
|
|
109
|
+
serviceBag:Destroy()
|
|
110
|
+
end)
|
|
111
|
+
|
|
112
|
+
it("returns nil for a key with no active asset", function()
|
|
113
|
+
local serviceBag, _, picker = newPicker()
|
|
114
|
+
|
|
115
|
+
expect(picker:FindFirstActiveAssetOfKey(PLACE, "specDefinitelyMissing")).toBeNil()
|
|
116
|
+
|
|
117
|
+
serviceBag:Destroy()
|
|
118
|
+
end)
|
|
119
|
+
end)
|
|
120
|
+
|
|
121
|
+
describe("GameConfigPicker gameId gate dominates priority", function()
|
|
122
|
+
-- Builds a config whose GameId is NOT the running place's, containing one
|
|
123
|
+
-- PLACE asset. Such a config is never active, so its assets must never be
|
|
124
|
+
-- returned -- no matter how high their priority. Returns the config folder so
|
|
125
|
+
-- the caller can destroy it.
|
|
126
|
+
local function addInactiveConfigPlace(
|
|
127
|
+
serviceBag: any,
|
|
128
|
+
gameConfigService: any,
|
|
129
|
+
assetKey: string,
|
|
130
|
+
placeId: number,
|
|
131
|
+
priority: number
|
|
132
|
+
): Instance
|
|
133
|
+
local binders = serviceBag:GetService(GameConfigBindersServer)
|
|
134
|
+
local config = GameConfigUtils.create(binders.GameConfig, game.GameId + 1)
|
|
135
|
+
config.Parent = gameConfigService:GetPreferredParent()
|
|
136
|
+
|
|
137
|
+
local asset = GameConfigAssetUtils.create(binders.GameConfigAsset, PLACE, assetKey, placeId, priority)
|
|
138
|
+
asset.Parent = GameConfigUtils.getOrCreateAssetFolder(config, PLACE)
|
|
139
|
+
|
|
140
|
+
return config
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it("excludes a high-priority asset whose config gameId does not match", function()
|
|
144
|
+
local serviceBag, gameConfigService, picker = newPicker()
|
|
145
|
+
|
|
146
|
+
local inactive = addInactiveConfigPlace(serviceBag, gameConfigService, "specGateOnly", 888, 5000)
|
|
147
|
+
|
|
148
|
+
expect(picker:FindFirstActiveAssetOfKey(PLACE, "specGateOnly")).toBeNil()
|
|
149
|
+
|
|
150
|
+
inactive:Destroy()
|
|
151
|
+
serviceBag:Destroy()
|
|
152
|
+
end)
|
|
153
|
+
|
|
154
|
+
it("keeps a lower-priority active asset over a higher-priority wrong-gameId one", function()
|
|
155
|
+
local serviceBag, gameConfigService, picker = newPicker()
|
|
156
|
+
|
|
157
|
+
-- Wrong-gameId asset with a far higher priority than the active one.
|
|
158
|
+
local inactive = addInactiveConfigPlace(serviceBag, gameConfigService, "specGateBeatsPriority", 888, 5000)
|
|
159
|
+
-- Correct-gameId asset at the default priority.
|
|
160
|
+
gameConfigService:AddPlace("specGateBeatsPriority", 111)
|
|
161
|
+
|
|
162
|
+
local resolved = picker:FindFirstActiveAssetOfKey(PLACE, "specGateBeatsPriority")
|
|
163
|
+
expect(resolved).never.toBeNil()
|
|
164
|
+
assert(resolved, "No resolved asset")
|
|
165
|
+
expect(resolved:GetAssetId()).toEqual(111)
|
|
166
|
+
|
|
167
|
+
inactive:Destroy()
|
|
168
|
+
serviceBag:Destroy()
|
|
169
|
+
end)
|
|
170
|
+
end)
|