Skip to content

Commit a70cb2b

Browse files
committed
test: cover merchant min-level restrictions
1 parent 2aa0935 commit a70cb2b

4 files changed

Lines changed: 60 additions & 4 deletions

File tree

docs/qa/manual-client-checklist.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,16 @@ Expected result:
324324
- selected-character anti-flagged or transfer-guarded equipment fails closed: no item refresh, no quickslot change, no point change, no carried/equipment mutation, and no persistence change
325325
- equipment whose template-authored `equip_effect` point delta would overflow the bootstrap signed 32-bit point value also fails closed before item, quickslot, point, or persistence mutation
326326

327+
### 4.5.9 Merchant buy/sell template restrictions (`SHOP BUY` / `SHOP SELL2`)
328+
329+
- [ ] Open a known bootstrap merchant window with a disposable QA character
330+
- [ ] Attempt to buy a catalog item whose authored template requires a higher `min_level` than the selected character has
331+
- [ ] Attempt to sell a carried item whose authored template requires a higher `min_level` than the selected character has
332+
333+
Expected result:
334+
- both packet paths fail with the current merchant invalid-position companion and no inventory, item quickslot, gold, or persisted account mutation is visible
335+
- adjacent allowed merchant buy/sell cases still use the template-authored price/sell-credit behavior
336+
327337
---
328338

329339
## 5. Single-client movement

internal/minimal/shared_world_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20198,6 +20198,46 @@ func TestGameSessionFlowShopBuyAndSellRejectSelectedCharacterAntiFlagTemplatesWi
2019820198
assertMerchantStateUnchanged(t, sellRuntime, sellAccounts, sellLogin, sellBuyer, "anti-flag packet shop sell2")
2019920199
}
2020020200

20201+
func TestGameSessionFlowShopBuyAndSellRejectMinLevelTemplatesWithoutMutation(t *testing.T) {
20202+
buyBuyer := merchantBuyerCharacter("MerchantBuyerPacketMinLevel", 0x0104012a, 0x0205012a, 125, []inventory.ItemInstance{{ID: 77, Vnum: 27001, Count: 3, Slot: 5}})
20203+
buyBuyer.Level = 9
20204+
buyRuntime, buyAccounts, buyFlow, buyActorID, buyLogin := setupMerchantBuySession(t, "merchant-min-level-buy", 0x2a2a2a2a, buyBuyer)
20205+
defer closeSessionFlow(t, buyFlow)
20206+
buyRuntime.itemTemplates[27001] = itemcatalog.Template{Vnum: 27001, Name: "Restricted Potion", Stackable: true, MaxCount: 200, ShopBuyPrice: 5, MinLevel: 10}
20207+
20208+
interactWithMerchantForBuy(t, buyFlow, buyActorID)
20209+
buyOut, err := buyFlow.HandleClientFrame(decodeSingleFrame(t, shopproto.EncodeClientBuy(shopproto.ClientBuyPacket{CatalogSlot: 0})))
20210+
if err != nil {
20211+
t.Fatalf("unexpected min-level packet shop buy error: %v", err)
20212+
}
20213+
if len(buyOut) != 1 {
20214+
t.Fatalf("expected min-level packet shop buy to emit 1 invalid-pos frame, got %d", len(buyOut))
20215+
}
20216+
if err := shopproto.DecodeServerInvalidPos(decodeSingleFrame(t, buyOut[0])); err != nil {
20217+
t.Fatalf("decode min-level packet shop buy invalid-pos frame: %v", err)
20218+
}
20219+
assertMerchantStateUnchanged(t, buyRuntime, buyAccounts, buyLogin, buyBuyer, "min-level packet shop buy")
20220+
20221+
sellBuyer := merchantBuyerCharacter("MerchantSellerPacketMinLevel", 0x0104012b, 0x0205012b, 125, []inventory.ItemInstance{{ID: 77, Vnum: 27001, Count: 3, Slot: 5}})
20222+
sellBuyer.Level = 9
20223+
sellRuntime, sellAccounts, sellFlow, sellActorID, sellLogin := setupMerchantBuySession(t, "merchant-min-level-sell", 0x2b2b2b2b, sellBuyer)
20224+
defer closeSessionFlow(t, sellFlow)
20225+
sellRuntime.itemTemplates[27001] = itemcatalog.Template{Vnum: 27001, Name: "Restricted Potion", Stackable: true, MaxCount: 200, ShopBuyPrice: 5, MinLevel: 10}
20226+
20227+
interactWithMerchantForBuy(t, sellFlow, sellActorID)
20228+
sellOut, err := sellFlow.HandleClientFrame(decodeSingleFrame(t, shopproto.EncodeClientSell2(shopproto.ClientSell2Packet{Slot: 5, Count: 2})))
20229+
if err != nil {
20230+
t.Fatalf("unexpected min-level packet shop sell2 error: %v", err)
20231+
}
20232+
if len(sellOut) != 1 {
20233+
t.Fatalf("expected min-level packet shop sell2 to emit 1 invalid-pos frame, got %d", len(sellOut))
20234+
}
20235+
if err := shopproto.DecodeServerInvalidPos(decodeSingleFrame(t, sellOut[0])); err != nil {
20236+
t.Fatalf("decode min-level packet shop sell2 invalid-pos frame: %v", err)
20237+
}
20238+
assertMerchantStateUnchanged(t, sellRuntime, sellAccounts, sellLogin, sellBuyer, "min-level packet shop sell2")
20239+
}
20240+
2020120241
func assertMerchantStateUnchanged(t *testing.T, runtime *gameRuntime, accounts accountstore.Store, login string, want loginticket.Character, context string) {
2020220242
t.Helper()
2020320243
currencySnapshot, ok := runtime.CurrencySnapshot(want.Name)

internal/player/runtime_inventory_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,11 @@ func TestMerchantTemplateMutationsRejectSelectedCharacterAntiFlagTemplates(t *te
743743
character: loginticket.Character{RaceNum: 1},
744744
template: itemcatalog.Template{Vnum: 27001, Name: "Restricted Potion", Stackable: true, MaxCount: 200, AntiFemale: true},
745745
},
746+
{
747+
name: "minimum level",
748+
character: loginticket.Character{Level: 9},
749+
template: itemcatalog.Template{Vnum: 27001, Name: "Restricted Potion", Stackable: true, MaxCount: 200, MinLevel: 10},
750+
},
746751
}
747752

748753
for _, tc := range cases {

spec/protocol/npc-shop-transaction-bootstrap.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ When a gated `BUY` request arrives, the runtime must validate all of the followi
134134
- the entry `count` is greater than zero
135135
- the selected character has at least that much gold available
136136
- the selected character has a valid carried-inventory placement for that template/count under `item-stack-bootstrap.md`, including `anti_stack` templates skipping existing-stack merge/fan-out paths
137-
- the resolved template does not carry a selected-character job/sex restriction (`anti_warrior`, `anti_assassin`, `anti_sura`, `anti_shaman`, `anti_male`, or `anti_female`)
137+
- the resolved template does not carry a selected-character job/sex/level restriction (`anti_warrior`, `anti_assassin`, `anti_sura`, `anti_shaman`, `anti_male`, `anti_female`, or `min_level` above the selected character's level)
138138
- persistence/writeback can succeed before the new live state is committed
139139

140140
The first buy contract intentionally remains single-entry and immediate:
@@ -190,7 +190,7 @@ The first buy path must fail closed when any of these are true:
190190
- the catalog/template resolution fails
191191
- the player has insufficient gold
192192
- no valid carried inventory placement exists
193-
- the resolved template carries a selected-character job/sex restriction (`anti_warrior`, `anti_assassin`, `anti_sura`, `anti_shaman`, `anti_male`, or `anti_female`)
193+
- the resolved template carries a selected-character job/sex/level restriction (`anti_warrior`, `anti_assassin`, `anti_sura`, `anti_shaman`, `anti_male`, `anti_female`, or `min_level` above the selected character's level)
194194
- persistence/writeback fails
195195

196196
The first sell/sell2 path must fail closed when any of these are true:
@@ -202,7 +202,7 @@ The first sell/sell2 path must fail closed when any of these are true:
202202
- the requested carried cell has duplicate live item occupancy
203203
- the carried item is marked runtime-locked
204204
- the template is marked `anti_sell`
205-
- the resolved template carries a selected-character job/sex restriction (`anti_warrior`, `anti_assassin`, `anti_sura`, `anti_shaman`, `anti_male`, or `anti_female`)
205+
- the resolved template carries a selected-character job/sex/level restriction (`anti_warrior`, `anti_assassin`, `anti_sura`, `anti_shaman`, `anti_male`, `anti_female`, or `min_level` above the selected character's level)
206206
- the template has no sell price
207207
- persistence/writeback fails
208208

@@ -346,8 +346,9 @@ The first live sell-back contract remains intentionally narrow:
346346
- ordinary sell credit derives from loaded item-template `shop_buy_price` as `floor((shop_buy_price * sold_count) / 5)` minus `floor(3% tax)`
347347
- templates flagged `sell_count_per_gold` follow the legacy count-per-gold branch first: use `floor(sold_count / shop_buy_price)` when `shop_buy_price > 0`, or `sold_count` when it is zero, then apply the same `/5` and 3% tax floor; if the resulting credit is zero, the bootstrap runtime fails closed
348348
- templates flagged `anti_sell` fail closed before credit calculation, return bare self-only `GC::SHOP INVALID_POS` on the packet sell path while a merchant window is active, and leave live plus persisted inventory/currency unchanged
349-
- the player mutation boundary also exposes a template-backed sell helper that rejects `anti_sell` templates, template/item-`vnum` mismatches, invalid template metadata, invalid counts, locked carried items, equipped carried items, and zero-credit pricing before mutating live inventory or gold
349+
- the player mutation boundary also exposes a template-backed sell helper that rejects `anti_sell` templates, template/item-`vnum` mismatches, invalid template metadata, invalid counts, locked carried items, equipped carried items, selected-character job/sex/level restrictions, and zero-credit pricing before mutating live inventory or gold
350350
- successful template-backed sell uses the same template-derived `MerchantSellCredit` calculation as the packet/runtime sell path, then delegates to the existing credit mutation so whole-stack and partial-stack state results stay aligned
351+
- selected-character `min_level` restrictions are checked through the same template-use guard as job/sex anti-flags; packet `SHOP BUY`, packet `SHOP SELL` / `SELL2`, and the template-backed player-runtime sell helper all fail closed before inventory, quickslot, gold, or persistence mutation when the selected character is below the authored minimum level
351352
- the updated selected-character snapshot is persisted before the live shared-world registration is refreshed
352353
- if persistence/writeback fails, the runtime rolls the selected character's live gold and carried inventory back to the pre-sell snapshot, emits no success frames, and leaves the persisted account snapshot unchanged
353354
- whole-stack success emits self-only `ITEM_DEL(slot)`, then zero or more self-only `QUICKSLOT_DEL(position)` frames for item quickslots that referenced the removed carried slot, then self-only `PLAYER_POINT_CHANGE(type = POINT_GOLD, amount = credited_elk, value = new_gold)`

0 commit comments

Comments
 (0)