@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 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.53.0",
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.23.0",
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": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
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 first asset of a given key
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
- return gameConfigAsset
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 nil
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)