From 3522993280168540b0c360fecce1b608440f6bf1 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 15:55:58 -0400 Subject: [PATCH 01/56] docs: plan inventory item system design --- docs/plan-inventory-items.md | 187 +++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/plan-inventory-items.md diff --git a/docs/plan-inventory-items.md b/docs/plan-inventory-items.md new file mode 100644 index 00000000..f9a10cd2 --- /dev/null +++ b/docs/plan-inventory-items.md @@ -0,0 +1,187 @@ +# Plan: inventory and item NFTs (roadmap §4) + +Working plan for the feature described in +[`plan-future-features-roadmap.md`](./plan-future-features-roadmap.md) §4. That section is +the design rationale; this file is the execution order. Steps are small on purpose: each one +ends at a command that passes, so the branch is revertible at any point. + +Branch: `feat/inventory-item-nfts`. + +## Scope + +| Decision | Choice | +|---|---| +| Chains | EVM only. Solana deferred, per §4's EVM-first recommendation. | +| Categories | Consumable, collectible/material, equipment with combat stats. No cosmetics. | +| Acquisition | Battle-reward drops plus an owner-gated admin grant. | +| Clients | Web only. Hooks live in `@shared/core` so mobile can adopt them later. | + +Phases 1 to 3 ship a working inventory on their own. Phase 4 is the one that touches signed +protocol objects, and it is sequenced last for that reason. + +## Design decisions this plan assumes + +1. **Equipping escrows the ERC-1155 token into `ItemCore`.** §4 requires equip state to be + verifiable from chain state at a recorded `sourceVersion`. Escrow makes the equip mapping + itself the ownership proof, and structurally prevents one sword buffing five pets. +2. **The battle snapshot carries resolved modifiers and the item type id.** Resolved + modifiers are what §4 mandates, so unequipping cannot change a committed fight. The type + id rides along so a third party can cross-check the numbers against the published catalog + rather than taking them on faith. +3. **`Ruleset` gains `itemCatalogHash`**, a single 32-byte field over the canonical encoding + of every combat-affecting `ItemDefinition`, sorted by id. A rebalance then changes + `rulesetHash`, which is what invalidates outstanding defence authorizations by design. +4. **Modifiers are non-negative flat bonuses in v1**, applied after `extract` and before the + skill modifiers, with the sum clamped to 65535 rather than wrapped. Excluding negative + modifiers removes any underflow question against `toUint16`'s wrap semantics. +5. **Drops derive from the battle's existing drand seed** (`keccak(seed, battleId, "DROP")`), + so a drop replays from the receipt like every other outcome. No second randomness system. + +## Environment notes + +- `contracts/ethereum` pins `@openzeppelin/contracts-upgradeable` at **4.7.3** while + `@openzeppelin/contracts` is ^5.4.0. `ItemCore` therefore uses the 4.x initializer style + (`__ERC1155_init`), not v5's `_update` hook. +- Migrations run with `prisma migrate deploy`, never `dev`. Every `CREATE TABLE` ends with + `ENABLE ROW LEVEL SECURITY` and no `FORCE`. +- New files under `contracts/ethereum`, `services/indexer-go`, `protocol` and `verifier` are + MIT. Everything else is PolyForm Noncommercial 1.0.0. + +--- + +## Phase 1: chain layer + +- [ ] **1.1 `ItemCore.sol` core.** ERC-1155 behind UUPS with `OwnableUpgradeable`, matching + `PetCore.sol`'s header, `VERSION` constant, event set and `authorizeCaller` pattern. + Item type ids are the ERC-1155 token ids; balances are quantities. Surface for this + step: `initialize`, `mintTo`, `burnFrom`, caller authorization. + Verify: `pnpm --prefix contracts/ethereum compile`. +- [ ] **1.2 Equip and unequip.** `equip(petId, slot, itemType)` transfers one unit into the + contract and records it; `unequip(petId, slot)` returns it. `equipmentOf(petId)` view + for the indexer. Ownership of the pet is checked against `PetCore`. + Verify: `pnpm --prefix contracts/ethereum compile`. +- [ ] **1.3 `test/ItemCore.test.ts`.** Mint, burn, equip escrow, unequip return, double-equip + rejection, unauthorized caller rejection, equip by a non-owner of the pet. + Verify: `pnpm --prefix contracts/ethereum hh test test/ItemCore.test.ts`. +- [ ] **1.4 Deployment wiring.** Add `ItemCore` impl plus `ERC1967Proxy` to + `ignition/modules/CryptoPetsV2Live.ts`, authorize the backend minter, and return it so + `scripts/deploy.ts` writes the address out. + Verify: `pnpm --prefix contracts/ethereum deploy:visualize`. +- [ ] **1.5 Subgraph.** `ItemBalance` and `PetEquipment` entities in `subgraph/schema.graphql`, + an `ItemCore` ABI under `subgraph/abis/`, a data source in `subgraph.template.yaml`, and + handlers in a new `subgraph/src/item.ts` next to `pet.ts`. + Verify: subgraph codegen and build from `contracts/ethereum/subgraph`. +- [ ] **1.6 `indexer-go` ingest.** `ItemUpdate` and `EquipmentUpdate` in `internal/indexer`, + paged queries in `internal/evm/client.go` and `indexer.go` behind their own watermarks, + row conversion in `mapping.go`. + Verify: `go vet ./... && go test ./internal/evm`. +- [ ] **1.7 `indexer-go` writes.** Version-guarded batch upserts in `internal/store/writer.go` + and `pg.go`, same `(chain, id)` plus monotonic `lastVersion` shape `pet_roster` uses, so + a lower-versioned write is discarded rather than applied. + Verify: `go test ./internal/store`. + +## Phase 2: backend inventory domain + +- [ ] **2.1 Prisma models and migration.** `ItemDefinition` (backend-managed catalog), + `ItemRoster` and `PetEquipment` (indexer-owned, `lastVersion`-guarded), `ItemEntitlement` + (backend-owned drops awaiting claim). One migration, RLS on every new table, copying the + comment block from `20260806100000_add_chat_threads/migration.sql`. + Verify: `pnpm --filter backend build`. +- [ ] **2.2 Catalog seed.** A checked-in JSON catalog plus a loader, so item content is data + rather than code. Covers all three shipping categories. + Verify: seed script runs against a local database. +- [ ] **2.3 Read surface.** `backend/src/features/inventory/` laid out like `features/chat` + (`*.controller.ts`, `*.service.ts`, `*.schema.ts`, `index.ts`), routes in + `backend/src/routes/inventory.ts` behind `verifyToken` with the read/write rate-limit + split `routes/chat.ts` uses. + Verify: `pnpm --filter backend test`. +- [ ] **2.4 GraphQL reads.** `inventory(chain, owner)` and `itemCatalog` in + `backend/src/graphql/{schema,resolvers}.ts`. The caller's own inventory takes its owner + from the session, never from an argument. + Verify: `pnpm --filter backend test`. +- [ ] **2.5 Write surface.** `useItem` (apply the consumable effect to `pet_battle_progress`, + then `ItemCore.burnFrom`), `equipItem` and `unequipItem` (send the chain tx and return + pending; the indexed row is the truth), `claimEntitlement`, and an owner-gated + `grantItem` admin route. + Verify: `pnpm --filter backend test`. +- [ ] **2.6 Battle drops.** The battle worker writes `ItemEntitlement` rows in the same + transaction as the receipt, the way `battle_history` is written today, so a drop cannot + exist without its receipt or the reverse. + Verify: `pnpm --filter backend test`. + +## Phase 3: web UI + +- [ ] **3.1 Shared hooks.** `shared/src/hooks/inventory/` (`useInventory`, `useItemCatalog`, + `useUseItem`, `useEquipItem`), following the GraphQL-string plus `useApiClient` plus + TanStack Query shape in `shared/src/hooks/leaderboard/useLeaderboard.ts`. + Verify: `pnpm --filter @shared/core test`. +- [ ] **3.2 `InventoryAdapter`.** A new chain-blind interface and `useInventoryAdapter` in + `shared/src/hooks/adapters/`. `ChainAdapter` is not extended: `AGENTS.md` forbids it and + §4 names this case. Reuse the pattern, not the interface. + Verify: `pnpm --filter @shared/core lint`. +- [ ] **3.3 Inventory page.** `frontend/src/pages/inventory/index.tsx` plus router and sidebar + entries. Rarity styling reuses `shared/src/utils/pets/cosmetics.ts` verbatim, so pets and + items share one rarity vocabulary. + Verify: `pnpm --filter frontend lint:check && pnpm --filter frontend test`. +- [ ] **3.4 Equip panel.** A panel under + `frontend/src/components/pet/interactions/panels/`. One action over one pet with no + intermediate states, so it composes shared hooks directly and keeps form state local + (the `rename`/`train` shape), not a controller hook. + Verify: `pnpm --filter frontend test`. + +## Phase 4: equipment affects combat + +The phase §4 gates behind a design review. It changes signed protocol objects. + +- [ ] **4.1 Snapshot schema v2.** `PetSnapshot.equipment: EquipEntry[]` + (`{slot, itemType, hp, atk, def, int, mdef}`). `SCHEMA_VERSIONS.snapshot` 1 to 2, with 1 + kept in `SUPPORTED_VERSIONS` so historical receipts keep verifying. The domain tag is + unchanged; the version inside the header is the mechanism for a layout change. +- [ ] **4.2 Version-aware snapshot encoder.** `encodeBattleSnapshot` currently always writes + the current version, so re-encoding a stored v1 snapshot at v2 would break its hash. The + snapshot carries its own `schemaVersion` and the encoder emits the v1 layout for v1. + This is the most breakage-prone edit in the plan. + Verify: `pnpm --filter @cryptopets/protocol test`. +- [ ] **4.3 Ruleset v2.** `Ruleset.itemCatalogHash`, `SCHEMA_VERSIONS.ruleset` 1 to 2, + `RULESET_KEYS` / `serializeRuleset` / `parseRulesetBundle`'s unknown-key rejection all + updated, and the catalog itself published in the bundle so replay works offline. + Verify: `pnpm --filter @cryptopets/protocol test`. +- [ ] **4.4 Engine modifiers.** `simulate()` takes per-pet modifiers, applied after `extract` + and before skills, clamped at 65535. `ENGINE_VERSION` 1 to 2. + Verify: `pnpm --filter @cryptopets/protocol test`, with + `contracts/test-vectors/battle.json` passing **unchanged**: with no equipment the deltas + are zero and the engine stays bit-identical, which is the compatibility test. +- [ ] **4.5 New golden vectors.** Equipment cases in a new + `contracts/test-vectors/equipment.json`. `battle.json` and `xp.json` are not edited. + Verify: `pnpm --filter @cryptopets/protocol test`. +- [ ] **4.6 Go port, same commit.** `combat.PetInputs` gains the modifier, `Simulate`, + `SimulateWithLog` and `Verify` thread it through, applied at the identical point, plus + `internal/combat/equipment_golden_test.go`. `AGENTS.md` makes this a MUST: §F's circuit + breaker only has value because the two ports were written to disagree if either drifts. + Verify: `go test ./internal/combat`. +- [ ] **4.7 Backend wiring.** `snapshot.builder.ts` resolves equipment from the indexed + `pet_equipment` rows and the catalog. `SOURCE_DEFAULT_RULESET` stops being a pure + constant, so `accept.service.ts` (`ensureRulesetPublished`) and `reads.service.ts` build + the ruleset from the live catalog. + Verify: `pnpm --filter backend test`. +- [ ] **4.8 Verifier.** `checks/combatReplay.ts` and `ruleset.ts` handle both snapshot + versions. `verifier/fixtures/corpus.json` keeps its v1 receipts (they must still pass) + and gains a v2 geared receipt, regenerated via `verifier/scripts/gen-corpus.ts`. + Verify: `pnpm --filter @cryptopets/verifier test`. + +### Consequence to accept before starting Phase 4 + +Bumping `ENGINE_VERSION` and adding `itemCatalogHash` changes `rulesetHash` for every battle, +not only geared ones. Every outstanding `DefenseAuthorization` is invalidated and every +defender has to re-consent. `protocol/src/consent/types.ts` documents that as the intended +cost of a rules change, but it is user-visible and should ship deliberately. + +Solana's frozen ports (`game/battle_sim.rs`, `game/xp.rs`) are not touched in any step above. + +## End-to-end check + +Run `pnpm eth:node`, `pnpm --prefix contracts/ethereum deploy`, `pnpm dev:idx` against a +seeded catalog, then `pnpm dev:be` and `pnpm dev:fe`. Grant an item through the admin route, +confirm it appears in `/inventory` once the indexer sees it, equip it, run a battle, and check +the receipt's snapshot carries the resolved modifiers and replays clean through the verifier +against the local corpus. From ffa66d4b49a9b9a524dfe1b7529f6bf6d9f2cfbf Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:06:55 -0400 Subject: [PATCH 02/56] feat(inventory): add ItemCore, the ERC-1155 item contract --- bash.exe.stackdump | 12 +- contracts/ethereum/src/ItemCore.sol | 261 +++++++++++++++++++++ contracts/ethereum/test/ItemCore.test.ts | 279 +++++++++++++++++++++++ 3 files changed, 546 insertions(+), 6 deletions(-) create mode 100644 contracts/ethereum/src/ItemCore.sol create mode 100644 contracts/ethereum/test/ItemCore.test.ts diff --git a/bash.exe.stackdump b/bash.exe.stackdump index e15c61ae..4feb3613 100644 --- a/bash.exe.stackdump +++ b/bash.exe.stackdump @@ -1,9 +1,9 @@ Stack trace: Frame Function Args -000FFFFA3C0 00210062B0E (00210297158, 00210275E3E, 000FFFFA3C0, 000FFFF92C0) -000FFFFA3C0 0021004846A (00000000000, 00000000000, 00000000000, 00000000004) -000FFFFA3C0 002100484A2 (00210297209, 000FFFFA278, 000FFFFA3C0, 00000000000) -000FFFFA3C0 002100D2FFE (00000000000, 00000000000, 00000000000, 00000000000) -000FFFFA3C0 002100D3125 (000FFFFA3D0, 00000000000, 00000000000, 00000000000) -001004F84B7 002100D46E5 (000FFFFA3D0, 00000000000, 00000000000, 00000000000) +000FFFFA380 00210062B0E (00210297158, 00210275E3E, 000FFFFA380, 000FFFF9280) +000FFFFA380 0021004846A (00000000000, 00000000000, 00000000000, 00000000004) +000FFFFA380 002100484A2 (00210297209, 000FFFFA238, 000FFFFA380, 00000000000) +000FFFFA380 002100D2FFE (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFA380 002100D3125 (000FFFFA390, 00000000000, 00000000000, 00000000000) +001004F84B7 002100D46E5 (000FFFFA390, 00000000000, 00000000000, 00000000000) End of stack trace diff --git a/contracts/ethereum/src/ItemCore.sol b/contracts/ethereum/src/ItemCore.sol new file mode 100644 index 00000000..ca4686f7 --- /dev/null +++ b/contracts/ethereum/src/ItemCore.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/utils/ERC1155HolderUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/** + * @title ItemCore + * @dev UUPS-upgradeable ERC-1155 holding every inventory item (roadmap §4). + * + * Semi-fungible on purpose: a pet is a one-of-one ERC-721 in PetCore, but many + * players own the same XP potion, so the token id here is the *item type* and the + * balance is how many of it a wallet holds. That is what keeps a catalog of 20 items + * and a catalog of 2,000 the same cost on chain: only the numeric type id is stored + * here, while names, art and effects live in the backend catalog. + * + * What is deliberately NOT here: item effects. An XP potion's grant, a sword's stat + * modifier, the rarity tier, all of it is backend-managed content, versioned by the + * battle protocol's `itemCatalogHash` rather than by this contract. Putting effects on + * chain would make every rebalance a transaction, and §4's open decision came down on + * the side of the existing GameConfig pattern: balance knobs stay owner-tunable off the + * asset contract. + * + * Storage layout is append-only, with new variables taking a slot off `__gap` rather + * than being appended after it. + */ +contract ItemCore is ERC1155Upgradeable, ERC1155HolderUpgradeable, UUPSUpgradeable, OwnableUpgradeable { + string public constant VERSION = "1.0.0"; + + event ItemsMinted(address indexed to, uint256 indexed itemType, uint256 quantity); + event ItemsBurned(address indexed from, uint256 indexed itemType, uint256 quantity); + event ItemEquipped(uint256 indexed petId, uint8 indexed slot, uint256 indexed itemType, address owner); + event ItemUnequipped(uint256 indexed petId, uint8 indexed slot, uint256 indexed itemType, address owner); + event ItemSlotRegistered(uint256 indexed itemType, uint8 slot); + event ItemSlotCleared(uint256 indexed itemType); + event CallerAuthorized(address indexed caller); + event CallerRevoked(address indexed caller); + event ItemUriUpdated(string uri); + + /// @dev Used by uri() until an owner calls setUri. ERC-1155 clients substitute the + /// lowercase hex item type for `{id}` themselves; the contract never does. + string public constant DEFAULT_ITEM_URI = "https://api.cryptopets.io/items/{id}.json"; + + /// @dev Equip slots. Three gear slots and no cosmetic one: cosmetics are out of the v1 + /// catalog, and a slot nothing can go in is a layout decision made for a feature + /// whose shape is undecided. Adding a fourth later costs nothing here, since the + /// slot is a mapping key rather than a struct field. + uint8 public constant SLOT_WEAPON = 0; + uint8 public constant SLOT_ARMOR = 1; + uint8 public constant SLOT_TRINKET = 2; + uint8 public constant SLOT_COUNT = 3; + + mapping(address => bool) public authorizedCallers; + + /// @dev PetCore proxy, read when equipping to check who owns the pet being geared. + /// Set at initialize so the deployment wiring never has to change; see equip(). + address public petCore; + + /// @dev Which slot an item type may occupy, stored as slot + 1 so that the zero value + /// means "not equippable" rather than "weapon". Read `slotOf` instead of this. + /// + /// The one piece of catalog data that has to be on chain: without it the contract + /// cannot tell a sword from an XP potion, and escrowing a consumable into a weapon + /// slot would lock it where nothing will ever read it. Effects stay off chain. + mapping(uint256 => uint8) private _itemSlotPlusOne; + + /// @dev petId => slot => equipped item type, 0 for an empty slot. Item type 0 is + /// therefore not equippable, which `registerItemSlot` enforces. + mapping(uint256 => mapping(uint8 => uint256)) private _equipped; + + // Reserve 46 slots: 4 declared above + 46 gap = 50 for ItemCore's scope. + uint256[46] private __gap; + + // ─── modifiers ──────────────────────────────────────────────────────────── + + /// @dev Same shape as PetCore's: the owner, or a contract/wallet the owner has + /// authorized. In practice the authorized caller is the backend's item wallet. + /// This is a real trust grant, not a formality: an authorized caller can burn any + /// wallet's items without that wallet's approval, which is what lets the backend + /// settle a consumable in one call after the player has already authenticated to + /// it. Nothing here constrains that caller; the constraint is who the owner + /// authorizes. + modifier onlyAuthorized() { + require(msg.sender == owner() || authorizedCallers[msg.sender], "Not authorized"); + _; + } + + // ─── constructor / initializer ──────────────────────────────────────────── + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); // implementation must never be initialized directly + } + + function initialize(address petCore_, address initialOwner) public initializer { + __ERC1155_init(DEFAULT_ITEM_URI); + __ERC1155Holder_init(); + __UUPSUpgradeable_init(); + __Ownable_init(); + _transferOwnership(initialOwner); + petCore = petCore_; + } + + function _authorizeUpgrade(address) internal override onlyOwner {} + + /// @dev Both bases declare it: ERC-1155 for the token interface, the holder for the + /// receiver interface. This contract is genuinely both, so neither is dropped. + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC1155Upgradeable, ERC1155ReceiverUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + // ─── owner configuration ────────────────────────────────────────────────── + + /// @notice Point item metadata at a service. The `{id}` placeholder is substituted by + /// the client, per ERC-1155, so the value must contain it to be useful. Not + /// validated here: a wrong value is visible in the next uri() read and fixed by + /// calling this again. + function setUri(string calldata newUri) external onlyOwner { + _setURI(newUri); + emit ItemUriUpdated(newUri); + } + + /// @notice Repoint at a newly deployed PetCore. Only affects future equip calls, since + /// equipment is keyed by pet id and nothing here stores a resolved owner. + function setPetCore(address petCore_) external onlyOwner { + require(petCore_ != address(0), "Zero address"); + petCore = petCore_; + } + + // ─── caller authorization ───────────────────────────────────────────────── + + function authorizeCaller(address caller) external onlyOwner { + authorizedCallers[caller] = true; + emit CallerAuthorized(caller); + } + + function revokeCaller(address caller) external onlyOwner { + authorizedCallers[caller] = false; + emit CallerRevoked(caller); + } + + // ─── equip slot registry ────────────────────────────────────────────────── + + /// @notice Declare that `itemType` is equipment for `slot`. + /// @dev Owner-gated rather than authorized-caller-gated: this is catalog shape, not + /// gameplay, and it is the one item property the contract itself enforces. + /// Re-registering an item type to a different slot is allowed and does not + /// disturb anything already equipped, which stays where it was put until + /// unequipped. + function registerItemSlot(uint256 itemType, uint8 slot) external onlyOwner { + require(itemType != 0, "Item type 0 reserved"); + require(slot < SLOT_COUNT, "Unknown slot"); + _itemSlotPlusOne[itemType] = slot + 1; + emit ItemSlotRegistered(itemType, slot); + } + + /// @notice Stop treating `itemType` as equipment. Already-equipped copies are not + /// disturbed; they simply cannot be re-equipped after being removed. + function clearItemSlot(uint256 itemType) external onlyOwner { + delete _itemSlotPlusOne[itemType]; + emit ItemSlotCleared(itemType); + } + + /// @notice The slot `itemType` occupies, and whether it is equipment at all. + function slotOf(uint256 itemType) public view returns (bool isEquipment, uint8 slot) { + uint8 stored = _itemSlotPlusOne[itemType]; + return stored == 0 ? (false, 0) : (true, stored - 1); + } + + // ─── equipment ──────────────────────────────────────────────────────────── + + /// @notice Equip one `itemType` onto `petId`, escrowing it in this contract. + /// + /// @dev Escrow, not a transfer lock. The item leaves the player's balance, which + /// costs some wallet-UI visibility, and buys two things worth more. First, the + /// equip mapping is itself the ownership proof, so "was this gear really on this + /// pet at snapshot time" is answered by chain state at a recorded version rather + /// than by a backend row nobody else can check (roadmap §4). Second, one copy of + /// an item cannot buff two pets, without needing a locked-balance invariant that + /// breaks the moment a geared pet changes hands. + /// + /// Gear follows the pet, deliberately: unequip returns it to whoever owns the pet + /// then, not to whoever equipped it. A transfer-locked design would instead + /// strand the item in the old owner's wallet, locked by a pet they no longer own. + function equip(uint256 petId, uint8 slot, uint256 itemType) external { + require(msg.sender == _petOwner(petId), "Not the owner of this pet"); + (bool isEquipment, uint8 itemSlot) = slotOf(itemType); + require(isEquipment, "Item is not equipment"); + require(itemSlot == slot, "Wrong slot for this item"); + require(_equipped[petId][slot] == 0, "Slot already filled"); + + _equipped[petId][slot] = itemType; + // Reverts on an insufficient balance, so holding the item is checked here rather + // than by a separate require that could disagree with the transfer. + _safeTransferFrom(msg.sender, address(this), itemType, 1, ""); + emit ItemEquipped(petId, slot, itemType, msg.sender); + } + + /// @notice Return the item in `petId`'s `slot` to the pet's current owner. + function unequip(uint256 petId, uint8 slot) external { + address petOwner = _petOwner(petId); + require(msg.sender == petOwner, "Not the owner of this pet"); + uint256 itemType = _equipped[petId][slot]; + require(itemType != 0, "Slot is empty"); + + delete _equipped[petId][slot]; + _safeTransferFrom(address(this), petOwner, itemType, 1, ""); + emit ItemUnequipped(petId, slot, itemType, petOwner); + } + + /// @notice Everything equipped on `petId`, indexed by slot. 0 means an empty slot. + /// @dev The read the indexer projects into `pet_equipment` and the battle snapshot + /// resolves modifiers from. + function equipmentOf(uint256 petId) external view returns (uint256[SLOT_COUNT] memory items) { + for (uint8 slot = 0; slot < SLOT_COUNT; slot++) { + items[slot] = _equipped[petId][slot]; + } + } + + /// @notice The item equipped in one slot, or 0. + function equippedItem(uint256 petId, uint8 slot) external view returns (uint256) { + return _equipped[petId][slot]; + } + + function _petOwner(uint256 petId) private view returns (address) { + require(petCore != address(0), "PetCore not set"); + return IERC721Upgradeable(petCore).ownerOf(petId); + } + + // ─── authorized mutators (called by the backend item wallet) ────────────── + + /// @notice Mint `quantity` of `itemType` to `to`. + /// @dev The single acquisition path in v1: an admin grant and a claimed battle drop + /// both land here. Crates and marketplace purchases are later features that + /// would call this the same way. + function mintTo(address to, uint256 itemType, uint256 quantity) external onlyAuthorized { + require(to != address(0), "Zero address"); + require(quantity > 0, "Zero quantity"); + _mint(to, itemType, quantity, ""); + emit ItemsMinted(to, itemType, quantity); + } + + /// @notice Burn `quantity` of `itemType` from `from`. + /// @dev Consumables are burned here after the backend has applied their effect, so + /// the burn is the record that the effect was spent. Reverts on an insufficient + /// balance, which is what keeps a double-spend of one potion from settling + /// twice even if the backend asked for it. + function burnFrom(address from, uint256 itemType, uint256 quantity) external onlyAuthorized { + require(quantity > 0, "Zero quantity"); + _burn(from, itemType, quantity); + emit ItemsBurned(from, itemType, quantity); + } +} diff --git a/contracts/ethereum/test/ItemCore.test.ts b/contracts/ethereum/test/ItemCore.test.ts new file mode 100644 index 00000000..45eb1fb8 --- /dev/null +++ b/contracts/ethereum/test/ItemCore.test.ts @@ -0,0 +1,279 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { network } from "hardhat"; +import { encodeFunctionData } from "viem"; + +/** Asserts a call reverted, and that the revert carried `reason`. */ +async function rejectsWith(promise: Promise, reason: string): Promise { + await assert.rejects(promise, (error: unknown) => { + const text = String(error); + assert.ok(text.includes(reason), `expected a revert containing ${JSON.stringify(reason)}, got:\n${text}`); + return true; + }); +} + +/** + * ItemCore (roadmap §4). + * + * Two things carry the feature's weight and get most of the cases below. Minting and + * burning are the whole acquisition and consumable path, so who may call them matters more + * than what they do. Equipping is escrow, and escrow is only worth its cost if the invariants + * it buys actually hold: one copy of an item cannot buff two pets, and gear follows the pet + * rather than the wallet that equipped it. + */ +describe("ItemCore", async function () { + const { viem } = await network.connect(); + + const SLOT_WEAPON = 0; + const SLOT_ARMOR = 1; + const SLOT_TRINKET = 2; + + const SWORD = 1n; + const PLATE = 2n; + const POTION = 3n; // never registered to a slot: a consumable, not equipment + + async function deploy() { + const [owner, alice, bob, backend] = await viem.getWalletClients(); + + const config = await viem.deployContract("GameConfig", [owner.account.address]); + + const petCoreImpl = await viem.deployContract("PetCore"); + const petCoreProxy = await viem.deployContract("ERC1967Proxy", [ + petCoreImpl.address, + encodeFunctionData({ + abi: petCoreImpl.abi, + functionName: "initialize", + args: [config.address, owner.account.address], + }), + ]); + const petCore = await viem.getContractAt("PetCore", petCoreProxy.address); + + const itemCoreImpl = await viem.deployContract("ItemCore"); + const itemCoreProxy = await viem.deployContract("ERC1967Proxy", [ + itemCoreImpl.address, + encodeFunctionData({ + abi: itemCoreImpl.abi, + functionName: "initialize", + args: [petCore.address, owner.account.address], + }), + ]); + const itemCore = await viem.getContractAt("ItemCore", itemCoreProxy.address); + + // Pets 1 and 2 to alice, pet 3 to bob. createPet only writes the entry; mintTo is + // what gives it an ERC-721 owner, which is the half ItemCore reads. + for (let i = 0; i < 3; i++) { + await petCore.write.createPet([`pet${i + 1}`, 1234567890123456n, 3, 0, 0n, 0n]); + } + await petCore.write.mintTo([alice.account.address, 1n]); + await petCore.write.mintTo([alice.account.address, 2n]); + await petCore.write.mintTo([bob.account.address, 3n]); + + await itemCore.write.authorizeCaller([backend.account.address]); + await itemCore.write.registerItemSlot([SWORD, SLOT_WEAPON]); + await itemCore.write.registerItemSlot([PLATE, SLOT_ARMOR]); + + return { itemCore, petCore, owner, alice, bob, backend }; + } + + /** Mints `quantity` of `itemType` to `to` as the authorized backend wallet. */ + async function grant( + ctx: Awaited>, + to: `0x${string}`, + itemType: bigint, + quantity: bigint, + ) { + await ctx.itemCore.write.mintTo([to, itemType, quantity], { account: ctx.backend.account }); + } + + describe("minting and burning", function () { + it("credits the recipient's balance", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, POTION, 5n); + assert.equal(await ctx.itemCore.read.balanceOf([ctx.alice.account.address, POTION]), 5n); + }); + + it("rejects a mint from an unauthorized caller", async function () { + const ctx = await deploy(); + await rejectsWith( + ctx.itemCore.write.mintTo([ctx.alice.account.address, POTION, 1n], { account: ctx.alice.account }), + "Not authorized", + ); + }); + + it("burns from a holder without needing their approval", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, POTION, 3n); + await ctx.itemCore.write.burnFrom([ctx.alice.account.address, POTION, 2n], { + account: ctx.backend.account, + }); + assert.equal(await ctx.itemCore.read.balanceOf([ctx.alice.account.address, POTION]), 1n); + }); + + it("rejects burning more than the holder has, so one potion cannot settle twice", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, POTION, 1n); + await rejectsWith( + ctx.itemCore.write.burnFrom([ctx.alice.account.address, POTION, 2n], { account: ctx.backend.account }), + "burn amount exceeds balance", + ); + }); + + it("rejects a burn from an unauthorized caller", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, POTION, 1n); + await rejectsWith( + ctx.itemCore.write.burnFrom([ctx.alice.account.address, POTION, 1n], { account: ctx.bob.account }), + "Not authorized", + ); + }); + + it("lets the owner revoke a caller it previously authorized", async function () { + const ctx = await deploy(); + await ctx.itemCore.write.revokeCaller([ctx.backend.account.address]); + await rejectsWith( + ctx.itemCore.write.mintTo([ctx.alice.account.address, POTION, 1n], { account: ctx.backend.account }), + "Not authorized", + ); + }); + }); + + describe("the slot registry", function () { + it("reports whether an item type is equipment, and where it goes", async function () { + const ctx = await deploy(); + assert.deepEqual(await ctx.itemCore.read.slotOf([SWORD]), [true, SLOT_WEAPON]); + assert.deepEqual(await ctx.itemCore.read.slotOf([POTION]), [false, 0]); + }); + + it("refuses item type 0, which is the empty-slot sentinel", async function () { + const ctx = await deploy(); + await rejectsWith(ctx.itemCore.write.registerItemSlot([0n, SLOT_WEAPON]), "Item type 0 reserved"); + }); + + it("refuses a slot the contract does not have", async function () { + const ctx = await deploy(); + await rejectsWith(ctx.itemCore.write.registerItemSlot([SWORD, 3]), "Unknown slot"); + }); + + it("is owner-gated, not authorized-caller-gated", async function () { + const ctx = await deploy(); + await rejectsWith( + ctx.itemCore.write.registerItemSlot([POTION, SLOT_TRINKET], { account: ctx.backend.account }), + "caller is not the owner", + ); + }); + }); + + describe("equipping", function () { + it("escrows the item into the contract and records the slot", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, SWORD, 1n); + + await ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }); + + assert.equal(await ctx.itemCore.read.balanceOf([ctx.alice.account.address, SWORD]), 0n); + assert.equal(await ctx.itemCore.read.balanceOf([ctx.itemCore.address, SWORD]), 1n); + assert.equal(await ctx.itemCore.read.equippedItem([1n, SLOT_WEAPON]), SWORD); + assert.deepEqual(await ctx.itemCore.read.equipmentOf([1n]), [SWORD, 0n, 0n]); + }); + + it("rejects an item that is not equipment", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, POTION, 1n); + await rejectsWith( + ctx.itemCore.write.equip([1n, SLOT_WEAPON, POTION], { account: ctx.alice.account }), + "Item is not equipment", + ); + }); + + it("rejects equipment put in the wrong slot", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, PLATE, 1n); + await rejectsWith( + ctx.itemCore.write.equip([1n, SLOT_WEAPON, PLATE], { account: ctx.alice.account }), + "Wrong slot for this item", + ); + }); + + it("rejects a second item in an occupied slot", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, SWORD, 2n); + await ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }); + await rejectsWith( + ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }), + "Slot already filled", + ); + }); + + it("rejects equipping a pet the caller does not own", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.bob.account.address, SWORD, 1n); + await rejectsWith( + ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.bob.account }), + "Not the owner of this pet", + ); + }); + + it("will not let one copy buff two pets", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, SWORD, 1n); + await ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }); + // Alice owns pet 2 as well, and the sword is now escrowed rather than merely + // flagged, so there is nothing left in her balance to equip onto it. + await rejectsWith( + ctx.itemCore.write.equip([2n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }), + "insufficient balance", + ); + }); + }); + + describe("unequipping", function () { + it("returns the item to the pet's owner", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, SWORD, 1n); + await ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }); + + await ctx.itemCore.write.unequip([1n, SLOT_WEAPON], { account: ctx.alice.account }); + + assert.equal(await ctx.itemCore.read.balanceOf([ctx.alice.account.address, SWORD]), 1n); + assert.equal(await ctx.itemCore.read.equippedItem([1n, SLOT_WEAPON]), 0n); + }); + + it("rejects an empty slot", async function () { + const ctx = await deploy(); + await rejectsWith( + ctx.itemCore.write.unequip([1n, SLOT_ARMOR], { account: ctx.alice.account }), + "Slot is empty", + ); + }); + + it("rejects a caller who does not own the pet", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, SWORD, 1n); + await ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }); + await rejectsWith( + ctx.itemCore.write.unequip([1n, SLOT_WEAPON], { account: ctx.bob.account }), + "Not the owner of this pet", + ); + }); + + it("hands gear to the pet's new owner after a transfer, not to whoever equipped it", async function () { + const ctx = await deploy(); + await grant(ctx, ctx.alice.account.address, SWORD, 1n); + await ctx.itemCore.write.equip([1n, SLOT_WEAPON, SWORD], { account: ctx.alice.account }); + + await ctx.petCore.write.transferFrom([ctx.alice.account.address, ctx.bob.account.address, 1n], { + account: ctx.alice.account, + }); + + await rejectsWith( + ctx.itemCore.write.unequip([1n, SLOT_WEAPON], { account: ctx.alice.account }), + "Not the owner of this pet", + ); + await ctx.itemCore.write.unequip([1n, SLOT_WEAPON], { account: ctx.bob.account }); + + assert.equal(await ctx.itemCore.read.balanceOf([ctx.bob.account.address, SWORD]), 1n); + assert.equal(await ctx.itemCore.read.balanceOf([ctx.alice.account.address, SWORD]), 0n); + }); + }); +}); From 8cf5a4012ecfc6a8341822ad1c401310d02976c4 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:11:30 -0400 Subject: [PATCH 03/56] feat(inventory): deploy ItemCore from the Ignition module --- .../ignition/modules/CryptoPetsV2Live.ts | 25 ++++++++++++++++++- contracts/ethereum/scripts/deploy.ts | 10 +++++++- contracts/ethereum/test/ItemCore.test.ts | 23 +++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts index 8ea16d21..0c45b32b 100644 --- a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts +++ b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts @@ -54,6 +54,23 @@ const CryptoPetsV2LiveModule = buildModule("CryptoPetsV2Live", (m) => { id: "GameLogic", }); + // ── ItemCore proxy (roadmap §4) ─────────────────────────────────────────── + // Behind a proxy like PetCore and GameLogic, because it is a live asset ledger whose + // rules will move: crates, marketplace hooks and further slots are all planned against + // it. Points at the PetCore proxy, not the implementation, since equip reads ownerOf + // and only the proxy holds the pets. + const itemCoreImpl = m.contract("ItemCore", [], { id: "ItemCoreImpl" }); + const itemCoreInit = m.encodeFunctionCall(itemCoreImpl, "initialize", [ + petCoreProxy, + deployer, + ]); + const itemCoreProxy = m.contract( + "ERC1967Proxy", + [itemCoreImpl, itemCoreInit], + { id: "ItemCoreProxy" } + ); + const itemCore = m.contractAt("ItemCore", itemCoreProxy, { id: "ItemCore" }); + // ── backend-battle contracts (§I) ──────────────────────────────────────── // Neither is a proxy, and neither is upgradeable, on purpose: the registry records // history, so being able to rewrite the thing that records it would defeat the point. @@ -76,8 +93,14 @@ const CryptoPetsV2LiveModule = buildModule("CryptoPetsV2Live", (m) => { // A real deployment should rotate this to the backend's own anchor wallet — the key // in BATTLE_ANCHOR_PRIVATE_KEY — and revoke the deployer. m.call(batchRegistry, "setPublisher", [deployer, true]); + // No authorizeCaller for ItemCore here. Unlike the registry's publisher list, its + // onlyAuthorized already accepts owner(), so the deployer can mint from the start and a + // call granting the deployer what it holds anyway would be a no-op. A real deployment + // authorizes the backend's item wallet instead, and the item catalog's slot + // registrations are seeded alongside the backend catalog rather than from here, since + // they are content rather than deployment shape. - return { config, petCore, gameLogic, batchRegistry, rewardDistributor }; + return { config, petCore, gameLogic, itemCore, batchRegistry, rewardDistributor }; }); export default CryptoPetsV2LiveModule; diff --git a/contracts/ethereum/scripts/deploy.ts b/contracts/ethereum/scripts/deploy.ts index 53ba591c..b08cf50a 100644 --- a/contracts/ethereum/scripts/deploy.ts +++ b/contracts/ethereum/scripts/deploy.ts @@ -146,6 +146,7 @@ async function injectContractAddresses(network: NetworkSpec, deploymentId?: stri const petCoreAddress = deployedAddresses['CryptoPetsV2Live#PetCoreProxy'] as string | undefined; const gameLogicAddress = deployedAddresses['CryptoPetsV2Live#GameLogicProxy'] as string | undefined; const gameConfigAddress = deployedAddresses['CryptoPetsV2Live#GameConfig'] as string | undefined; + const itemCoreAddress = deployedAddresses['CryptoPetsV2Live#ItemCoreProxy'] as string | undefined; const batchRegistryAddress = deployedAddresses['CryptoPetsV2Live#BattleBatchRegistry'] as string | undefined; const rewardDistributorAddress = deployedAddresses['CryptoPetsV2Live#SeasonRewardDistributor'] as string | undefined; @@ -157,15 +158,21 @@ async function injectContractAddresses(network: NetworkSpec, deploymentId?: stri console.log(`📝 PetCore: ${petCoreAddress}`); console.log(`📝 GameLogic: ${gameLogicAddress ?? '(not found)'}`); console.log(`📝 GameConfig: ${gameConfigAddress ?? '(not found)'}`); + console.log(`📝 ItemCore: ${itemCoreAddress ?? '(not found)'}`); console.log(`📝 BattleBatchRegistry: ${batchRegistryAddress ?? '(not found)'}`); console.log(`📝 SeasonRewardDistributor: ${rewardDistributorAddress ?? '(not found)'}`); - // These two are read by the backend, not the frontend, so they are printed for the + // These are read by the backend, not the frontend, so they are printed for the // operator to copy rather than written into frontend/.env.local. if (batchRegistryAddress) { console.log(` backend/.env: BATTLE_ANCHOR_REGISTRY_ADDRESS=${batchRegistryAddress}`); } + // ItemCore goes to both: the backend mints and burns through it, and the frontend + // sends equip/unequip itself, so its address is injected below as well. + if (itemCoreAddress) { + console.log(` backend/.env: ITEM_CORE_ADDRESS=${itemCoreAddress}`); + } const frontendEnvLocalPath = join(process.cwd(), '..', '..', 'frontend', '.env.local'); @@ -189,6 +196,7 @@ async function injectContractAddresses(network: NetworkSpec, deploymentId?: stri upsertEnvLine(lines, 'VITE_PETCORE_ADDRESS', petCoreAddress); if (gameLogicAddress) upsertEnvLine(lines, 'VITE_GAMELOGIC_ADDRESS', gameLogicAddress); if (gameConfigAddress) upsertEnvLine(lines, 'VITE_GAMECONFIG_ADDRESS', gameConfigAddress); + if (itemCoreAddress) upsertEnvLine(lines, 'VITE_ITEMCORE_ADDRESS', itemCoreAddress); if (!lines.some((l) => l.startsWith('VITE_API_URL='))) { lines.push('VITE_API_URL=http://localhost:3001'); diff --git a/contracts/ethereum/test/ItemCore.test.ts b/contracts/ethereum/test/ItemCore.test.ts index 45eb1fb8..9a08636d 100644 --- a/contracts/ethereum/test/ItemCore.test.ts +++ b/contracts/ethereum/test/ItemCore.test.ts @@ -86,6 +86,29 @@ describe("ItemCore", async function () { await ctx.itemCore.write.mintTo([to, itemType, quantity], { account: ctx.backend.account }); } + describe("initialization", function () { + it("comes up owned, pointed at PetCore, and serving the default item uri", async function () { + const ctx = await deploy(); + assert.equal( + (await ctx.itemCore.read.petCore()).toLowerCase(), + ctx.petCore.address.toLowerCase(), + ); + assert.equal( + (await ctx.itemCore.read.owner()).toLowerCase(), + ctx.owner.account.address.toLowerCase(), + ); + assert.equal(await ctx.itemCore.read.uri([1n]), "https://api.cryptopets.io/items/{id}.json"); + }); + + it("leaves the implementation itself uninitializable", async function () { + const impl = await viem.deployContract("ItemCore"); + await rejectsWith( + impl.write.initialize([impl.address, impl.address]), + "Initializable: contract is already initialized", + ); + }); + }); + describe("minting and burning", function () { it("credits the recipient's balance", async function () { const ctx = await deploy(); From 9c7afe9fa794963e7c48d0a06245a7326ff10116 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:16:26 -0400 Subject: [PATCH 04/56] feat(inventory): index item balances and equipment in the subgraph --- contracts/ethereum/subgraph/schema.graphql | 43 ++++++++ .../subgraph/scripts/prepare-subgraph.mjs | 18 +++- contracts/ethereum/subgraph/src/item.ts | 98 +++++++++++++++++++ .../ethereum/subgraph/subgraph.template.yaml | 38 ++++++- 4 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 contracts/ethereum/subgraph/src/item.ts diff --git a/contracts/ethereum/subgraph/schema.graphql b/contracts/ethereum/subgraph/schema.graphql index c8868a6b..7f033392 100644 --- a/contracts/ethereum/subgraph/schema.graphql +++ b/contracts/ethereum/subgraph/schema.graphql @@ -57,3 +57,46 @@ type BreedRequest @entity(immutable: false) { petId1: BigInt! petId2: BigInt! } + +# ─── Inventory (roadmap §4) ─────────────────────────────────────────────────── +# Same contract as Pet above: field names mirror what indexer-go selects, and both +# entities carry `updatedAt` because that block timestamp is the per-row version the +# indexer resumes from and the version guard compares. +# +# Neither entity is ever removed. A wallet that spends its last potion, or a slot that is +# emptied, has to produce a row the indexer can *see*, and a deleted entity is invisible to +# a watermark query: the indexer would keep serving the stale balance forever. So zero is +# written as a value rather than as an absence. + +type ItemBalance @entity(immutable: false) { + "`{owner}-{itemType}`, lowercase owner." + id: ID! + + "Holder address. The ItemCore contract itself appears here holding escrowed equipment." + owner: Bytes! + + "ERC-1155 token id, which is the item *type*, not one instance of it." + itemType: BigInt! + + "How many the holder has. Re-read from balanceOf rather than accumulated from deltas." + quantity: BigInt! + + "Block timestamp of the last update." + updatedAt: BigInt! +} + +type PetEquipment @entity(immutable: false) { + "`{petId}-{slot}`." + id: ID! + + petId: BigInt! + + "0 = weapon, 1 = armor, 2 = trinket (ItemCore.SLOT_*)." + slot: Int! + + "Equipped item type, or 0 for an empty slot." + itemType: BigInt! + + "Block timestamp of the last update." + updatedAt: BigInt! +} diff --git a/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs b/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs index 8013fc99..42ef664f 100644 --- a/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs +++ b/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs @@ -10,10 +10,11 @@ * SUBGRAPH_START_BLOCK=12345678 # the v2 deploy block (the indexer reindexes from here) * PETCORE_ADDRESS=0x... # PetCore proxy * GAMELOGIC_ADDRESS=0x... # GameLogic proxy + * ITEMCORE_ADDRESS=0x... # ItemCore proxy * IGNITION_DEPLOYMENT_ID=name # deployment dir to read, when not the default chain- * - * The v2 stack (PetCore + GameLogic) is two UUPS proxies — index the proxy - * addresses, not the implementations. See ignition/modules/CryptoPetsV2Live.ts. + * The v2 stack (PetCore + GameLogic + ItemCore) is three UUPS proxies — index the + * proxy addresses, not the implementations. See ignition/modules/CryptoPetsV2Live.ts. */ import fs from 'fs'; @@ -86,12 +87,13 @@ function loadIgnitionAddress(network, key) { return readJson(deployedPath)[key] ?? null; } -function writeAddressesTs(petCoreAddress, gameLogicAddress) { +function writeAddressesTs(petCoreAddress, gameLogicAddress, itemCoreAddress) { const content = `// Generated by scripts/prepare-subgraph.mjs — do not edit by hand. import { Address } from "@graphprotocol/graph-ts"; export const PETCORE_ADDRESS = Address.fromString("${petCoreAddress.toLowerCase()}"); export const GAMELOGIC_ADDRESS = Address.fromString("${gameLogicAddress.toLowerCase()}"); +export const ITEMCORE_ADDRESS = Address.fromString("${itemCoreAddress.toLowerCase()}"); `; fs.writeFileSync(path.join(SUBGRAPH_DIR, 'src', 'addresses.ts'), content); } @@ -124,14 +126,22 @@ function main() { ZERO ).toLowerCase(); + const itemCoreAddress = ( + process.env.ITEMCORE_ADDRESS ?? + loadIgnitionAddress(network, 'CryptoPetsV2Live#ItemCoreProxy') ?? + ZERO + ).toLowerCase(); + copyAbi('PetCore'); copyAbi('GameLogic'); + copyAbi('ItemCore'); - writeAddressesTs(petCoreAddress, gameLogicAddress); + writeAddressesTs(petCoreAddress, gameLogicAddress, itemCoreAddress); writeSubgraphYaml({ NETWORK: network, PETCORE_ADDRESS: petCoreAddress, GAMELOGIC_ADDRESS: gameLogicAddress, + ITEMCORE_ADDRESS: itemCoreAddress, START_BLOCK: startBlock, }); diff --git a/contracts/ethereum/subgraph/src/item.ts b/contracts/ethereum/subgraph/src/item.ts new file mode 100644 index 00000000..4468d969 --- /dev/null +++ b/contracts/ethereum/subgraph/src/item.ts @@ -0,0 +1,98 @@ +import { Address, BigInt } from "@graphprotocol/graph-ts"; +import { + ItemCore, + ItemEquipped, + ItemUnequipped, + TransferBatch, + TransferSingle, +} from "../generated/ItemCore/ItemCore"; +import { ItemBalance, PetEquipment } from "../generated/schema"; +import { ITEMCORE_ADDRESS } from "./addresses"; + +/** + * Inventory handlers (roadmap §4). + * + * Balances are projected from the ERC-1155 transfer events rather than from ItemCore's own + * ItemsMinted/ItemsBurned. Those two are the readable narration of an acquisition; the + * transfer events are the complete set. Minting, burning, escrowing gear, returning it, and + * a plain wallet-to-wallet send all emit a transfer, and only some of them emit anything + * else, so handling transfers is what makes the projection exhaustive instead of nearly so. + */ + +const ZERO_ADDRESS = Address.zero(); + +/** + * Re-reads one holder's balance of one item type and upserts it. + * + * A read rather than an accumulation, for the same reason `refreshPet` re-reads a pet: an + * entity built by adding and subtracting deltas is only correct if every event that ever + * moved the number was handled, and it drifts silently the first time one is missed. Reading + * balanceOf makes each row a snapshot that a missed event can stale but not corrupt. + */ +function refreshBalance(owner: Address, itemType: BigInt, updatedAt: BigInt): void { + // Mint and burn show up as transfers from and to the zero address. It holds no balance + // and nothing reads it, so writing a row for it would be noise the indexer has to skip. + if (owner.equals(ZERO_ADDRESS)) return; + + const core = ItemCore.bind(ITEMCORE_ADDRESS); + const balance = core.try_balanceOf(owner, itemType); + if (balance.reverted) return; + + const id = owner.toHexString() + "-" + itemType.toString(); + let entity = ItemBalance.load(id); + if (entity == null) { + entity = new ItemBalance(id); + } + + entity.owner = owner; + entity.itemType = itemType; + entity.quantity = balance.value; + entity.updatedAt = updatedAt; + entity.save(); +} + +export function handleTransferSingle(event: TransferSingle): void { + const updatedAt = event.block.timestamp; + refreshBalance(event.params.from, event.params.id, updatedAt); + refreshBalance(event.params.to, event.params.id, updatedAt); +} + +export function handleTransferBatch(event: TransferBatch): void { + const updatedAt = event.block.timestamp; + const ids = event.params.ids; + for (let i = 0; i < ids.length; i++) { + refreshBalance(event.params.from, ids[i], updatedAt); + refreshBalance(event.params.to, ids[i], updatedAt); + } +} + +export function handleItemEquipped(event: ItemEquipped): void { + writeSlot(event.params.petId, event.params.slot, event.params.itemType, event.block.timestamp); +} + +export function handleItemUnequipped(event: ItemUnequipped): void { + // Zero, not a delete. The indexer resumes from `updatedAt`, so an entity that stops + // existing is an entity it never learns about, and it would keep the pet geared forever. + writeSlot(event.params.petId, event.params.slot, BigInt.zero(), event.block.timestamp); +} + +/** + * Upserts one pet's slot. + * + * No owner field, deliberately. The owner is on the Pet entity and changes when the pet is + * transferred, which ItemCore emits nothing for: a copy stored here would be right until the + * first time a geared pet changed hands and wrong silently after that. + */ +function writeSlot(petId: BigInt, slot: i32, itemType: BigInt, updatedAt: BigInt): void { + const id = petId.toString() + "-" + slot.toString(); + let entity = PetEquipment.load(id); + if (entity == null) { + entity = new PetEquipment(id); + } + + entity.petId = petId; + entity.slot = slot; + entity.itemType = itemType; + entity.updatedAt = updatedAt; + entity.save(); +} diff --git a/contracts/ethereum/subgraph/subgraph.template.yaml b/contracts/ethereum/subgraph/subgraph.template.yaml index f192964b..f9eb4937 100644 --- a/contracts/ethereum/subgraph/subgraph.template.yaml +++ b/contracts/ethereum/subgraph/subgraph.template.yaml @@ -1,7 +1,9 @@ # Template — `scripts/prepare-subgraph.mjs` fills {{...}} and writes subgraph.yaml. -# Two v2 data sources: the PetCore proxy (pet lifecycle + marriage) and the -# GameLogic proxy (breed/mint/train). Both share src/mapping.ts and both -# carry the PetCore ABI so handlers can read full pet state via getPet(). +# Three v2 data sources: the PetCore proxy (pet lifecycle + marriage), the +# GameLogic proxy (breed/mint/train), and the ItemCore proxy (inventory, roadmap +# §4). The first two share src/mapping.ts and both carry the PetCore ABI so +# handlers can read full pet state via getPet(); ItemCore has its own +# src/item.ts, since it touches neither Pet nor the pet ABI. specVersion: 1.0.0 schema: file: ./schema.graphql @@ -63,3 +65,33 @@ dataSources: - event: Trained(indexed uint256,uint32,uint32,uint32) handler: handleTrained file: ./src/mapping.ts + - kind: ethereum/contract + name: ItemCore + network: {{NETWORK}} + source: + address: "{{ITEMCORE_ADDRESS}}" + abi: ItemCore + startBlock: {{START_BLOCK}} + mapping: + kind: ethereum/events + apiVersion: 0.0.9 + language: wasm/assemblyscript + entities: + - ItemBalance + - PetEquipment + abis: + - name: ItemCore + file: ./abis/ItemCore.json + eventHandlers: + # Balances come off the ERC-1155 transfers rather than ItemsMinted/ItemsBurned: + # minting, burning, escrowing gear, returning it and a plain wallet-to-wallet send + # all emit a transfer, and only some of them emit anything else. + - event: TransferSingle(indexed address,indexed address,indexed address,uint256,uint256) + handler: handleTransferSingle + - event: TransferBatch(indexed address,indexed address,indexed address,uint256[],uint256[]) + handler: handleTransferBatch + - event: ItemEquipped(indexed uint256,indexed uint8,indexed uint256,address) + handler: handleItemEquipped + - event: ItemUnequipped(indexed uint256,indexed uint8,indexed uint256,address) + handler: handleItemUnequipped + file: ./src/item.ts From ac88a940547cea85ca3707658ee26831d21d8d6a Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:23:30 -0400 Subject: [PATCH 05/56] feat(inventory): index item balances and equipment in indexer-go --- services/indexer-go/internal/evm/client.go | 105 ++++++++- services/indexer-go/internal/evm/indexer.go | 14 +- services/indexer-go/internal/evm/inventory.go | 188 +++++++++++++++ .../indexer-go/internal/evm/inventory_test.go | 221 ++++++++++++++++++ services/indexer-go/internal/evm/mapping.go | 42 ++++ services/indexer-go/internal/indexer/types.go | 49 ++++ 6 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 services/indexer-go/internal/evm/inventory.go create mode 100644 services/indexer-go/internal/evm/inventory_test.go diff --git a/services/indexer-go/internal/evm/client.go b/services/indexer-go/internal/evm/client.go index 31a02064..09db2cde 100644 --- a/services/indexer-go/internal/evm/client.go +++ b/services/indexer-go/internal/evm/client.go @@ -34,6 +34,46 @@ const ( ` ) +// Inventory queries (roadmap §4). Same cursor-plus-watermark shape as the pet +// queries above, and the same rule applies: these selection sets must match the +// subgraph's ItemBalance / PetEquipment entities exactly or the adapter silently +// reads zero values. +const ( + itemBalanceFields = `id owner itemType quantity updatedAt` + + itemBalanceFullQuery = ` + query ItemBalances($first: Int!, $lastId: ID!) { + itemBalances(first: $first, orderBy: id, orderDirection: asc, where: { id_gt: $lastId }) { + ` + itemBalanceFields + ` + } + } +` + itemBalanceIncrementalQuery = ` + query ItemBalancesSince($first: Int!, $lastId: ID!, $since: BigInt!) { + itemBalances(first: $first, orderBy: id, orderDirection: asc, where: { id_gt: $lastId, updatedAt_gt: $since }) { + ` + itemBalanceFields + ` + } + } +` + + petEquipmentFields = `id petId slot itemType updatedAt` + + petEquipmentFullQuery = ` + query PetEquipments($first: Int!, $lastId: ID!) { + petEquipments(first: $first, orderBy: id, orderDirection: asc, where: { id_gt: $lastId }) { + ` + petEquipmentFields + ` + } + } +` + petEquipmentIncrementalQuery = ` + query PetEquipmentsSince($first: Int!, $lastId: ID!, $since: BigInt!) { + petEquipments(first: $first, orderBy: id, orderDirection: asc, where: { id_gt: $lastId, updatedAt_gt: $since }) { + ` + petEquipmentFields + ` + } + } +` +) + // subgraphPet mirrors the subgraph's Pet entity. The Graph encodes Int as a // JSON number and BigInt as a string. type subgraphPet struct { @@ -78,6 +118,26 @@ type subgraphBattle struct { XPLoss uint32 `json:"xpLoss"` } +// subgraphItemBalance mirrors the subgraph's ItemBalance entity. `quantity` is a +// BigInt, so The Graph encodes it as a string. +type subgraphItemBalance struct { + ID string `json:"id"` // "{owner}-{itemType}" + Owner string `json:"owner"` + ItemType string `json:"itemType"` + Quantity string `json:"quantity"` + UpdatedAt string `json:"updatedAt"` +} + +// subgraphPetEquipment mirrors the subgraph's PetEquipment entity. `slot` is an +// Int (a JSON number); the rest are BigInt strings. +type subgraphPetEquipment struct { + ID string `json:"id"` // "{petId}-{slot}" + PetID string `json:"petId"` + Slot uint32 `json:"slot"` + ItemType string `json:"itemType"` + UpdatedAt string `json:"updatedAt"` +} + type client struct { url string pageSize int @@ -140,18 +200,45 @@ func (c *client) fetchPetsPage(ctx context.Context, query string, variables map[ return data.Pets, nil } -// paginate cursor-pages through all matching pets using the given query and -// variable builder, same contract as the TS implementation. -func (c *client) paginate( +func (c *client) fetchItemBalancesPage(ctx context.Context, query string, variables map[string]any) ([]subgraphItemBalance, error) { + var data struct { + ItemBalances []subgraphItemBalance `json:"itemBalances"` + } + if err := c.query(ctx, query, variables, &data); err != nil { + return nil, err + } + return data.ItemBalances, nil +} + +func (c *client) fetchPetEquipmentPage(ctx context.Context, query string, variables map[string]any) ([]subgraphPetEquipment, error) { + var data struct { + PetEquipments []subgraphPetEquipment `json:"petEquipments"` + } + if err := c.query(ctx, query, variables, &data); err != nil { + return nil, err + } + return data.PetEquipments, nil +} + +// paginate cursor-pages through all matching rows of one entity, same contract +// as the TS implementation. +// +// Generic over the row type, and therefore a function rather than a method: Go +// does not allow type parameters on methods. The alternative was a near-identical +// copy of this loop per entity, where a fix to the cursor or the short-page +// termination would have to be made in three places to hold. +func paginate[T any]( ctx context.Context, - query string, + pageSize int, buildVars func(lastID string) map[string]any, -) ([]subgraphPet, error) { + idOf func(T) string, + fetch func(ctx context.Context, variables map[string]any) ([]T, error), +) ([]T, error) { lastID := "" - var all []subgraphPet + var all []T for { - page, err := c.fetchPetsPage(ctx, query, buildVars(lastID)) + page, err := fetch(ctx, buildVars(lastID)) if err != nil { return nil, err } @@ -159,8 +246,8 @@ func (c *client) paginate( break } all = append(all, page...) - lastID = page[len(page)-1].ID - if len(page) < c.pageSize { + lastID = idOf(page[len(page)-1]) + if len(page) < pageSize { break } } diff --git a/services/indexer-go/internal/evm/indexer.go b/services/indexer-go/internal/evm/indexer.go index f6b0490d..03bf27b1 100644 --- a/services/indexer-go/internal/evm/indexer.go +++ b/services/indexer-go/internal/evm/indexer.go @@ -43,6 +43,12 @@ type Indexer struct { // scan failed it stays 0, so the first successful sync recovers by // sweeping everything (updatedAt_gt: 0). watermark uint64 + // itemWatermark and equipmentWatermark are the inventory equivalents + // (roadmap §4), owned by ScanInventory/RunInventory. Kept apart from each + // other, and from the roster's, because a shared cursor advanced by a busy + // stream skips the quiet one's unread rows permanently — see inventory.go. + itemWatermark uint64 + equipmentWatermark uint64 // battleWatermark is the highest Battle.foughtAt emitted. Starting at 0 // means the first sync sweeps the whole battle history into the pipeline — // intentional: it backfills battle_history with chain truth, and @@ -78,8 +84,10 @@ func (ix *Indexer) Chain() string { return ix.chain } // Scan full-syncs every pet ordered by id and primes the watermark. func (ix *Indexer) Scan(ctx context.Context, roster chan<- indexer.RosterUpdate) (int, error) { - pets, err := ix.client.paginate(ctx, fullSyncQuery, func(lastID string) map[string]any { + pets, err := paginate(ctx, ix.client.pageSize, func(lastID string) map[string]any { return map[string]any{"first": ix.client.pageSize, "lastId": lastID} + }, func(p subgraphPet) string { return p.ID }, func(ctx context.Context, vars map[string]any) ([]subgraphPet, error) { + return ix.client.fetchPetsPage(ctx, fullSyncQuery, vars) }) if err != nil { return 0, err @@ -94,8 +102,10 @@ func (ix *Indexer) Scan(ctx context.Context, roster chan<- indexer.RosterUpdate) // nothing and cost one HTTP request. func (ix *Indexer) sync(ctx context.Context, roster chan<- indexer.RosterUpdate) (int, error) { since := strconv.FormatUint(ix.watermark, 10) - pets, err := ix.client.paginate(ctx, incrementalQuery, func(lastID string) map[string]any { + pets, err := paginate(ctx, ix.client.pageSize, func(lastID string) map[string]any { return map[string]any{"first": ix.client.pageSize, "lastId": lastID, "since": since} + }, func(p subgraphPet) string { return p.ID }, func(ctx context.Context, vars map[string]any) ([]subgraphPet, error) { + return ix.client.fetchPetsPage(ctx, incrementalQuery, vars) }) if err != nil { return 0, err diff --git a/services/indexer-go/internal/evm/inventory.go b/services/indexer-go/internal/evm/inventory.go new file mode 100644 index 00000000..c5293ca9 --- /dev/null +++ b/services/indexer-go/internal/evm/inventory.go @@ -0,0 +1,188 @@ +package evm + +import ( + "context" + "log/slog" + "strconv" + "time" + + "github.com/radcrew/do-not-stop/services/indexer-go/internal/indexer" + "github.com/radcrew/do-not-stop/services/indexer-go/internal/metrics" +) + +// The inventory half of the EVM adapter (roadmap §4): item balances and pet +// equipment, both pulled from the same subgraph as the roster and on the same +// watermark-polling shape. +// +// Two watermarks rather than one. The entities are written by different events +// and move at very different rates, and a shared watermark would mean a busy +// balance stream dragging the equipment cursor past equip rows that had not been +// read yet, which the incremental filter then hides forever by construction. +// That is the same trap the roster's own doc comment describes, so it is kept +// out rather than rediscovered. +// +// This loop is deliberately separate from Run: a failing inventory query cannot +// stall roster sync, which is the read matchmaking and every pet surface depend +// on. See indexer.InventoryIndexer. + +// ScanInventory full-syncs every balance and equip slot and primes both watermarks. +func (ix *Indexer) ScanInventory( + ctx context.Context, + items chan<- indexer.ItemUpdate, + equipment chan<- indexer.EquipmentUpdate, +) (int, error) { + balances, err := paginate(ctx, ix.client.pageSize, func(lastID string) map[string]any { + return map[string]any{"first": ix.client.pageSize, "lastId": lastID} + }, func(r subgraphItemBalance) string { return r.ID }, func(ctx context.Context, vars map[string]any) ([]subgraphItemBalance, error) { + return ix.client.fetchItemBalancesPage(ctx, itemBalanceFullQuery, vars) + }) + if err != nil { + return 0, err + } + + slots, err := paginate(ctx, ix.client.pageSize, func(lastID string) map[string]any { + return map[string]any{"first": ix.client.pageSize, "lastId": lastID} + }, func(r subgraphPetEquipment) string { return r.ID }, func(ctx context.Context, vars map[string]any) ([]subgraphPetEquipment, error) { + return ix.client.fetchPetEquipmentPage(ctx, petEquipmentFullQuery, vars) + }) + if err != nil { + return 0, err + } + + // Stamped on the round trip rather than on the rows: an empty inventory is + // still proof the subgraph answered. + metrics.SetLastPoll(ix.chain, time.Now().Unix()) + return ix.emitInventory(ctx, items, equipment, balances, slots) +} + +// syncInventory fetches only what changed since each watermark. +func (ix *Indexer) syncInventory( + ctx context.Context, + items chan<- indexer.ItemUpdate, + equipment chan<- indexer.EquipmentUpdate, +) (int, error) { + sinceItems := strconv.FormatUint(ix.itemWatermark, 10) + balances, err := paginate(ctx, ix.client.pageSize, func(lastID string) map[string]any { + return map[string]any{"first": ix.client.pageSize, "lastId": lastID, "since": sinceItems} + }, func(r subgraphItemBalance) string { return r.ID }, func(ctx context.Context, vars map[string]any) ([]subgraphItemBalance, error) { + return ix.client.fetchItemBalancesPage(ctx, itemBalanceIncrementalQuery, vars) + }) + if err != nil { + return 0, err + } + + sinceSlots := strconv.FormatUint(ix.equipmentWatermark, 10) + slots, err := paginate(ctx, ix.client.pageSize, func(lastID string) map[string]any { + return map[string]any{"first": ix.client.pageSize, "lastId": lastID, "since": sinceSlots} + }, func(r subgraphPetEquipment) string { return r.ID }, func(ctx context.Context, vars map[string]any) ([]subgraphPetEquipment, error) { + return ix.client.fetchPetEquipmentPage(ctx, petEquipmentIncrementalQuery, vars) + }) + if err != nil { + return 0, err + } + + metrics.SetLastPoll(ix.chain, time.Now().Unix()) + return ix.emitInventory(ctx, items, equipment, balances, slots) +} + +// RunInventory scans once to prime the watermarks, then polls incrementally with +// the same periodic full re-read the roster loop uses as a safety net. +func (ix *Indexer) RunInventory( + ctx context.Context, + items chan<- indexer.ItemUpdate, + equipment chan<- indexer.EquipmentUpdate, +) error { + if scanned, err := ix.ScanInventory(ctx, items, equipment); err != nil { + if ctx.Err() != nil { + return nil + } + slog.Error("evm initial inventory scan failed; first sync will sweep everything", "err", err) + } else { + slog.Info("evm inventory scan complete", "scanned", scanned) + } + + ticker := time.NewTicker(ix.poll) + defer ticker.Stop() + + var reconcileC <-chan time.Time + if ix.reconcile > 0 { + reconcileTicker := time.NewTicker(ix.reconcile) + defer reconcileTicker.Stop() + reconcileC = reconcileTicker.C + } + + report := func(label string, count int, err error) bool { + switch { + case err != nil && ctx.Err() != nil: + return false + case err != nil: + slog.Error(label+" failed", "err", err) + case count > 0: + slog.Info(label, "count", count, + "itemWatermark", ix.itemWatermark, "equipmentWatermark", ix.equipmentWatermark) + } + return true + } + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if synced, err := ix.syncInventory(ctx, items, equipment); !report("evm inventory sync", synced, err) { + return nil + } + case <-reconcileC: + if scanned, err := ix.ScanInventory(ctx, items, equipment); !report("evm inventory reconcile scan", scanned, err) { + return nil + } + } + } +} + +// emitInventory converts and sends both row sets, advancing each watermark only +// after every row of its batch is handed off, so a send aborted by shutdown is +// re-fetched next time instead of lost. +func (ix *Indexer) emitInventory( + ctx context.Context, + items chan<- indexer.ItemUpdate, + equipment chan<- indexer.EquipmentUpdate, + balances []subgraphItemBalance, + slots []subgraphPetEquipment, +) (int, error) { + maxItem := ix.itemWatermark + for _, row := range balances { + update, err := ix.toItemUpdate(row) + if err != nil { + return 0, err + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case items <- update: + } + if update.Version > maxItem { + maxItem = update.Version + } + } + ix.itemWatermark = maxItem + + maxSlot := ix.equipmentWatermark + for _, row := range slots { + update, err := ix.toEquipmentUpdate(row) + if err != nil { + return 0, err + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case equipment <- update: + } + if update.Version > maxSlot { + maxSlot = update.Version + } + } + ix.equipmentWatermark = maxSlot + + return len(balances) + len(slots), nil +} diff --git a/services/indexer-go/internal/evm/inventory_test.go b/services/indexer-go/internal/evm/inventory_test.go new file mode 100644 index 00000000..cf1148a2 --- /dev/null +++ b/services/indexer-go/internal/evm/inventory_test.go @@ -0,0 +1,221 @@ +package evm + +import ( + "context" + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/radcrew/do-not-stop/services/indexer-go/internal/indexer" +) + +// fakeInventorySubgraph serves the two inventory entities with the same query +// semantics the real endpoint has (id_gt cursor, updatedAt_gt filter, first cap, +// id ordering). Separate from fakeSubgraph so the roster tests keep exercising +// exactly the handler they were written against. +type fakeInventorySubgraph struct { + balances []subgraphItemBalance + slots []subgraphPetEquipment + requests atomic.Int32 +} + +func (f *fakeInventorySubgraph) handler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f.requests.Add(1) + + var req struct { + Query string `json:"query"` + Variables struct { + First int `json:"first"` + LastID string `json:"lastId"` + Since string `json:"since"` + } `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("fake subgraph: bad request body: %v", err) + } + + incremental := strings.Contains(req.Query, "updatedAt_gt") + var since uint64 + if incremental { + since, _ = strconv.ParseUint(req.Variables.Since, 10, 64) + } + keep := func(id, updatedAt string) bool { + if id <= req.Variables.LastID { + return false + } + at, _ := strconv.ParseUint(updatedAt, 10, 64) + return !incremental || at > since + } + + if strings.Contains(req.Query, "itemBalances(") { + var matched []subgraphItemBalance + for _, b := range f.balances { + if keep(b.ID, b.UpdatedAt) { + matched = append(matched, b) + } + } + sort.Slice(matched, func(i, j int) bool { return matched[i].ID < matched[j].ID }) + if len(matched) > req.Variables.First { + matched = matched[:req.Variables.First] + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"itemBalances": matched}}) + return + } + + var matched []subgraphPetEquipment + for _, s := range f.slots { + if keep(s.ID, s.UpdatedAt) { + matched = append(matched, s) + } + } + sort.Slice(matched, func(i, j int) bool { return matched[i].ID < matched[j].ID }) + if len(matched) > req.Variables.First { + matched = matched[:req.Variables.First] + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"petEquipments": matched}}) + } +} + +func balance(owner, itemType, quantity, updatedAt string) subgraphItemBalance { + return subgraphItemBalance{ + ID: owner + "-" + itemType, Owner: owner, ItemType: itemType, + Quantity: quantity, UpdatedAt: updatedAt, + } +} + +func slot(petID string, slotIdx uint32, itemType, updatedAt string) subgraphPetEquipment { + return subgraphPetEquipment{ + ID: petID + "-" + strconv.FormatUint(uint64(slotIdx), 10), PetID: petID, + Slot: slotIdx, ItemType: itemType, UpdatedAt: updatedAt, + } +} + +func collectInventory(n int) (chan indexer.ItemUpdate, chan indexer.EquipmentUpdate, func() ([]indexer.ItemUpdate, []indexer.EquipmentUpdate)) { + items := make(chan indexer.ItemUpdate, n) + equipment := make(chan indexer.EquipmentUpdate, n) + return items, equipment, func() ([]indexer.ItemUpdate, []indexer.EquipmentUpdate) { + close(items) + close(equipment) + var gotItems []indexer.ItemUpdate + for u := range items { + gotItems = append(gotItems, u) + } + var gotSlots []indexer.EquipmentUpdate + for u := range equipment { + gotSlots = append(gotSlots, u) + } + return gotItems, gotSlots + } +} + +func TestScanInventoryPaginatesAndPrimesBothWatermarks(t *testing.T) { + fake := &fakeInventorySubgraph{ + balances: []subgraphItemBalance{ + balance("0xABCDEF", "1", "3", "100"), + balance("0xBEEF", "1", "7", "300"), + balance("0xCAFE", "2", "1", "200"), + }, + slots: []subgraphPetEquipment{ + slot("1", 0, "1", "150"), + slot("2", 1, "0", "50"), // an emptied slot arrives as a value, not a deletion + }, + } + + ix := newTestIndexer(t, fake.handler(t), 2) // page size 2 forces a cursor walk + items, equipment, drain := collectInventory(10) + + scanned, err := ix.ScanInventory(context.Background(), items, equipment) + if err != nil { + t.Fatalf("ScanInventory: %v", err) + } + if scanned != 5 { + t.Errorf("scanned = %d, want 5", scanned) + } + if ix.itemWatermark != 300 { + t.Errorf("itemWatermark = %d, want 300", ix.itemWatermark) + } + if ix.equipmentWatermark != 150 { + t.Errorf("equipmentWatermark = %d, want 150", ix.equipmentWatermark) + } + + gotItems, gotSlots := drain() + if len(gotItems) != 3 || len(gotSlots) != 2 { + t.Fatalf("emitted %d items and %d slots, want 3 and 2", len(gotItems), len(gotSlots)) + } + if gotItems[0].Owner != "0xabcdef" { + t.Errorf("owner not lowercased: %q", gotItems[0].Owner) + } + if gotItems[0].Chain != "evm" || gotItems[0].ItemType != "1" || + gotItems[0].Quantity != 3 || gotItems[0].Version != 100 { + t.Errorf("unexpected item mapping: %+v", gotItems[0]) + } + if gotSlots[0].PetID != "1" || gotSlots[0].Slot != 0 || + gotSlots[0].ItemType != "1" || gotSlots[0].Version != 150 { + t.Errorf("unexpected equipment mapping: %+v", gotSlots[0]) + } + if gotSlots[1].ItemType != "0" { + t.Errorf("an emptied slot should map to item type 0, got %q", gotSlots[1].ItemType) + } +} + +// The reason the two watermarks are separate: a busy balance stream must not +// advance the equipment cursor past rows nobody has read. The incremental query +// filters on updatedAt_gt, so anything a shared cursor skipped would be invisible +// to every later sync, permanently. +func TestInventoryWatermarksAdvanceIndependently(t *testing.T) { + fake := &fakeInventorySubgraph{ + balances: []subgraphItemBalance{balance("0xabcdef", "1", "3", "300")}, + slots: []subgraphPetEquipment{slot("1", 0, "1", "100")}, + } + + ix := newTestIndexer(t, fake.handler(t), 10) + items, equipment, drain := collectInventory(10) + if _, err := ix.ScanInventory(context.Background(), items, equipment); err != nil { + t.Fatalf("ScanInventory: %v", err) + } + drain() + + // An equip at 150 is behind the item watermark of 300 but ahead of the + // equipment watermark of 100, so only a separate cursor can still see it. + fake.slots = append(fake.slots, slot("2", 0, "5", "150")) + items2, equipment2, drain2 := collectInventory(10) + if _, err := ix.syncInventory(context.Background(), items2, equipment2); err != nil { + t.Fatalf("syncInventory: %v", err) + } + + gotItems, gotSlots := drain2() + if len(gotItems) != 0 { + t.Errorf("expected no item updates past the watermark, got %d", len(gotItems)) + } + if len(gotSlots) != 1 || gotSlots[0].PetID != "2" { + t.Fatalf("expected the pet-2 equip, got %+v", gotSlots) + } + if ix.equipmentWatermark != 150 { + t.Errorf("equipmentWatermark = %d, want 150", ix.equipmentWatermark) + } + if ix.itemWatermark != 300 { + t.Errorf("itemWatermark = %d, want 300 (unmoved)", ix.itemWatermark) + } +} + +// A quantity wider than 64 bits is upstream corruption, not a reason to widen the +// type. It has to fail loudly rather than land as a truncated balance. +func TestInventoryRejectsAnUnrepresentableQuantity(t *testing.T) { + fake := &fakeInventorySubgraph{ + balances: []subgraphItemBalance{ + balance("0xabcdef", "1", "115792089237316195423570985008687907853269984665640564039457584007913129639935", "100"), + }, + } + + ix := newTestIndexer(t, fake.handler(t), 10) + items, equipment, _ := collectInventory(10) + + if _, err := ix.ScanInventory(context.Background(), items, equipment); err == nil { + t.Fatal("expected an error for a quantity that does not fit 64 bits") + } +} diff --git a/services/indexer-go/internal/evm/mapping.go b/services/indexer-go/internal/evm/mapping.go index f73170e5..93024f18 100644 --- a/services/indexer-go/internal/evm/mapping.go +++ b/services/indexer-go/internal/evm/mapping.go @@ -56,6 +56,48 @@ func (ix *Indexer) toUpdate(pet subgraphPet) (indexer.RosterUpdate, error) { }, nil } +// toItemUpdate converts one ItemBalance row (roadmap §4). +// +// A quantity that does not fit 64 bits is an error rather than a truncation. The +// item id beside it is kept as a string precisely because a uint256 token id can +// be that large, so the asymmetry is a claim: ids are arbitrary, quantities are +// counts of things a player holds, and one that overflows means something is +// wrong upstream rather than that a wider type was needed. +func (ix *Indexer) toItemUpdate(row subgraphItemBalance) (indexer.ItemUpdate, error) { + quantity, err := strconv.ParseUint(row.Quantity, 10, 64) + if err != nil { + return indexer.ItemUpdate{}, fmt.Errorf("item %s: invalid quantity %q: %w", row.ID, row.Quantity, err) + } + updatedAt, err := strconv.ParseUint(row.UpdatedAt, 10, 64) + if err != nil { + return indexer.ItemUpdate{}, fmt.Errorf("item %s: invalid updatedAt %q: %w", row.ID, row.UpdatedAt, err) + } + + return indexer.ItemUpdate{ + Chain: ix.chain, + Owner: strings.ToLower(row.Owner), // EVM addresses normalize lowercase + ItemType: row.ItemType, + Quantity: quantity, + Version: updatedAt, + }, nil +} + +// toEquipmentUpdate converts one PetEquipment row (roadmap §4). +func (ix *Indexer) toEquipmentUpdate(row subgraphPetEquipment) (indexer.EquipmentUpdate, error) { + updatedAt, err := strconv.ParseUint(row.UpdatedAt, 10, 64) + if err != nil { + return indexer.EquipmentUpdate{}, fmt.Errorf("equipment %s: invalid updatedAt %q: %w", row.ID, row.UpdatedAt, err) + } + + return indexer.EquipmentUpdate{ + Chain: ix.chain, + PetID: row.PetID, + Slot: row.Slot, + ItemType: idOrZero(row.ItemType), + Version: updatedAt, + }, nil +} + // parseTimeField parses a BigInt cooldown string, treating "" (field absent on // a pre-v2 subgraph) as 0. func parseTimeField(s string) (int64, error) { diff --git a/services/indexer-go/internal/indexer/types.go b/services/indexer-go/internal/indexer/types.go index 201047fd..89ab31d9 100644 --- a/services/indexer-go/internal/indexer/types.go +++ b/services/indexer-go/internal/indexer/types.go @@ -36,6 +36,35 @@ type RosterUpdate struct { Asset string // Metaplex Core asset pubkey (Solana only, §2.3); "" on EVM / pre-Core } +// ItemUpdate is one holder's balance of one item type (roadmap §4), headed for +// item_roster. Version is the same monotonic source version RosterUpdate uses, +// so the writer's guard discards a stale update the same way. +// +// Quantity is a plain uint64 while ItemType is a string, and the asymmetry is +// deliberate: an ERC-1155 token id is a uint256 that does not fit 64 bits, the +// way pet ids and DNA do not, whereas a quantity above 2^64 is not a game state +// this can reach. The mapping errors on one that big rather than truncating. +type ItemUpdate struct { + Chain string + Owner string // normalized: lowercase on EVM + ItemType string // ERC-1155 token id, which is the item *type* + Quantity uint64 + Version uint64 +} + +// EquipmentUpdate is one pet's one equip slot, headed for pet_equipment. +// +// ItemType is "0" for an empty slot rather than the row being absent. The source +// never deletes, because a watermark reader cannot see a deletion (see the +// subgraph's schema comment), so "unequipped" has to arrive as a value. +type EquipmentUpdate struct { + Chain string + PetID string + Slot uint32 // 0 = weapon, 1 = armor, 2 = trinket (ItemCore.SLOT_*) + ItemType string // "0" = empty slot + Version uint64 +} + // ChainIndexer is one roster source, any chain. type ChainIndexer interface { Chain() string @@ -49,3 +78,23 @@ type ChainIndexer interface { // never returned. Run(ctx context.Context, roster chan<- RosterUpdate) error } + +// InventoryIndexer is the optional second half of an adapter: the chains that +// have an item contract also index items and equipment (roadmap §4). +// +// A separate interface rather than more channels on ChainIndexer, for two +// reasons. Inventory is EVM-only for now, and widening the shared contract to +// carry channels one implementation will never write is the kind of speculative +// shape AGENTS.md warns against on the TypeScript side for the same reason. +// More practically, keeping the loops apart means a failing inventory query +// cannot stall roster sync, which is the read everything else depends on. +// +// Adapters that have no item contract simply do not implement this, and the +// caller type-asserts. +type InventoryIndexer interface { + // ScanInventory sweeps every balance and equip slot, priming the + // watermarks. Returns how many rows were emitted across both. + ScanInventory(ctx context.Context, items chan<- ItemUpdate, equipment chan<- EquipmentUpdate) (int, error) + // RunInventory is the live loop, with the same contract as Run. + RunInventory(ctx context.Context, items chan<- ItemUpdate, equipment chan<- EquipmentUpdate) error +} From 26215ed8975a924100ffa3aa136897cf1f49cc10 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:36:02 -0400 Subject: [PATCH 06/56] feat(inventory): persist item balances and equipment from indexer-go --- services/indexer-go/cmd/indexer/app.go | 17 +- services/indexer-go/cmd/indexer/scan.go | 20 +- services/indexer-go/cmd/indexer/storage.go | 21 ++- services/indexer-go/internal/store/pg.go | 94 ++++++++++ services/indexer-go/internal/store/writer.go | 139 ++++++++++++-- .../indexer-go/internal/store/writer_test.go | 175 +++++++++++++++++- 6 files changed, 439 insertions(+), 27 deletions(-) diff --git a/services/indexer-go/cmd/indexer/app.go b/services/indexer-go/cmd/indexer/app.go index 787228e1..38111990 100644 --- a/services/indexer-go/cmd/indexer/app.go +++ b/services/indexer-go/cmd/indexer/app.go @@ -43,8 +43,10 @@ func run() error { } roster := make(chan indexer.RosterUpdate, 256) + items := make(chan indexer.ItemUpdate, 256) + equipment := make(chan indexer.EquipmentUpdate, 256) - st, err := startStorage(ctx, cfg, roster) + st, err := startStorage(ctx, cfg, roster, items, equipment) if err != nil { return err } @@ -66,6 +68,19 @@ func run() error { slog.Error("adapter exited", "chain", a.Chain(), "err", err) } }(adapter) + + // Inventory is optional per chain (roadmap §4 is EVM-first), so an adapter + // opts in by implementing InventoryIndexer. Its own goroutine, so a stalled + // inventory poll cannot hold up the roster loop everything else reads. + if inv, ok := adapter.(indexer.InventoryIndexer); ok { + wg.Add(1) + go func(a indexer.ChainIndexer, in indexer.InventoryIndexer) { + defer wg.Done() + if err := in.RunInventory(ctx, items, equipment); err != nil { + slog.Error("inventory adapter exited", "chain", a.Chain(), "err", err) + } + }(adapter, inv) + } } chains := make([]string, len(adapters)) diff --git a/services/indexer-go/cmd/indexer/scan.go b/services/indexer-go/cmd/indexer/scan.go index 094d92cd..69dd83a6 100644 --- a/services/indexer-go/cmd/indexer/scan.go +++ b/services/indexer-go/cmd/indexer/scan.go @@ -39,11 +39,13 @@ func runScanOnce(cfg *config.Config) error { defer pg.Close() roster := make(chan indexer.RosterUpdate, 256) + items := make(chan indexer.ItemUpdate, 256) + equipment := make(chan indexer.EquipmentUpdate, 256) writerCtx, stopWriter := context.WithCancel(ctx) writerDone := make(chan struct{}) go func() { defer close(writerDone) - if err := store.NewWriter(pg).Run(writerCtx, roster); err != nil { + if err := store.NewWriter(pg).Run(writerCtx, roster, items, equipment); err != nil { slog.Error("writer exited", "err", err) } }() @@ -59,6 +61,22 @@ func runScanOnce(cfg *config.Config) error { continue } slog.Info("scan complete", "chain", a.Chain(), "scanned", scanned) + + // A backfill run has to cover inventory too, or -scan-once leaves + // item_roster and pet_equipment behind whatever the live loop last wrote. + inv, ok := a.(indexer.InventoryIndexer) + if !ok { + continue + } + scanned, err = inv.ScanInventory(ctx, items, equipment) + if err != nil { + slog.Error("inventory scan failed", "chain", a.Chain(), "err", err) + if firstErr == nil { + firstErr = err + } + continue + } + slog.Info("inventory scan complete", "chain", a.Chain(), "scanned", scanned) } stopWriter() // triggers the writer's final drain diff --git a/services/indexer-go/cmd/indexer/storage.go b/services/indexer-go/cmd/indexer/storage.go index 6e1cbac7..66c925e9 100644 --- a/services/indexer-go/cmd/indexer/storage.go +++ b/services/indexer-go/cmd/indexer/storage.go @@ -24,10 +24,12 @@ func startStorage( ctx context.Context, cfg *config.Config, roster chan indexer.RosterUpdate, + items chan indexer.ItemUpdate, + equipment chan indexer.EquipmentUpdate, ) (*storage, error) { if cfg.DatabaseURL == "" { slog.Warn("DATABASE_URL not set; draining pipeline to logs only") - go drainSink(ctx, roster) + go drainSink(ctx, roster, items, equipment) return &storage{close: func() {}}, nil } @@ -58,7 +60,7 @@ func startStorage( done := make(chan struct{}) go func() { defer close(done) - if err := writer.Run(ctx, roster); err != nil { + if err := writer.Run(ctx, roster, items, equipment); err != nil { slog.Error("writer exited", "err", err) } }() @@ -70,15 +72,24 @@ func startStorage( }, nil } -// drainSink discards roster updates to logs when no database is configured, so the -// channel never blocks the adapters. -func drainSink(ctx context.Context, roster <-chan indexer.RosterUpdate) { +// drainSink discards updates to logs when no database is configured, so the +// channels never block the adapters. +func drainSink( + ctx context.Context, + roster <-chan indexer.RosterUpdate, + items <-chan indexer.ItemUpdate, + equipment <-chan indexer.EquipmentUpdate, +) { for { select { case <-ctx.Done(): return case u := <-roster: slog.Debug("roster update (drained)", "chain", u.Chain, "pet", u.PetID, "version", u.Version) + case u := <-items: + slog.Debug("item update (drained)", "chain", u.Chain, "owner", u.Owner, "itemType", u.ItemType, "version", u.Version) + case u := <-equipment: + slog.Debug("equipment update (drained)", "chain", u.Chain, "pet", u.PetID, "slot", u.Slot, "version", u.Version) } } } diff --git a/services/indexer-go/internal/store/pg.go b/services/indexer-go/internal/store/pg.go index 4840a93c..969f3f08 100644 --- a/services/indexer-go/internal/store/pg.go +++ b/services/indexer-go/internal/store/pg.go @@ -60,6 +60,42 @@ var rosterUpdateColumns = []string{ "spouse_id", "breed_ready_at", "train_ready_at", "asset", "last_version", "updated_at", } +// itemRosterRow maps indexer.ItemUpdate onto item_roster (roadmap §4). +// +// Keyed on (chain, owner, item_type) rather than on a per-instance id: an +// ERC-1155 balance is a count of a fungible type, so "how many of type 7 does +// this wallet hold" is the whole row and there is no individual item to name. +type itemRosterRow struct { + Chain string `gorm:"column:chain;primaryKey"` + Owner string `gorm:"column:owner;primaryKey"` + ItemType string `gorm:"column:item_type;primaryKey"` + Quantity int64 `gorm:"column:quantity"` + LastVersion int64 `gorm:"column:last_version"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (itemRosterRow) TableName() string { return "item_roster" } + +var itemUpdateColumns = []string{"quantity", "last_version", "updated_at"} + +// petEquipmentRow maps indexer.EquipmentUpdate onto pet_equipment (roadmap §4). +// +// A row per (pet, slot) that persists once written, holding item type "0" for an +// empty slot. Deleting the row instead would be invisible to the watermark read +// that produced it, so an unequip would never reach this table. +type petEquipmentRow struct { + Chain string `gorm:"column:chain;primaryKey"` + PetID string `gorm:"column:pet_id;primaryKey"` + Slot int32 `gorm:"column:slot;primaryKey"` + ItemType string `gorm:"column:item_type"` + LastVersion int64 `gorm:"column:last_version"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (petEquipmentRow) TableName() string { return "pet_equipment" } + +var equipmentUpdateColumns = []string{"item_type", "last_version", "updated_at"} + // NewPgFlusher opens a GORM connection (pgx driver) and verifies it with a ping. func NewPgFlusher(ctx context.Context, databaseURL string) (*PgFlusher, error) { db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ @@ -128,6 +164,64 @@ func (f *PgFlusher) FlushRoster(ctx context.Context, batch []indexer.RosterUpdat } +// FlushItems bulk-upserts one coalesced batch of item balances (roadmap §4). +// Same version guard as FlushRoster, for the same reason: a stale or replayed +// version is discarded by Postgres itself, so delivery order never matters. +func (f *PgFlusher) FlushItems(ctx context.Context, batch []indexer.ItemUpdate) error { + if len(batch) == 0 { + return nil + } + + now := time.Now() + rows := make([]itemRosterRow, len(batch)) + for i, u := range batch { + rows[i] = itemRosterRow{ + Chain: u.Chain, Owner: u.Owner, ItemType: u.ItemType, + Quantity: int64(u.Quantity), LastVersion: int64(u.Version), UpdatedAt: now, + } + } + + err := f.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "chain"}, {Name: "owner"}, {Name: "item_type"}}, + DoUpdates: clause.AssignmentColumns(itemUpdateColumns), + Where: clause.Where{Exprs: []clause.Expression{ + clause.Expr{SQL: "item_roster.last_version <= excluded.last_version"}, + }}, + }).Create(&rows).Error + if err != nil { + return fmt.Errorf("store: item upsert (%d rows): %w", len(batch), err) + } + return nil +} + +// FlushEquipment bulk-upserts one coalesced batch of equip slots (roadmap §4). +func (f *PgFlusher) FlushEquipment(ctx context.Context, batch []indexer.EquipmentUpdate) error { + if len(batch) == 0 { + return nil + } + + now := time.Now() + rows := make([]petEquipmentRow, len(batch)) + for i, u := range batch { + rows[i] = petEquipmentRow{ + Chain: u.Chain, PetID: u.PetID, Slot: int32(u.Slot), ItemType: u.ItemType, + LastVersion: int64(u.Version), UpdatedAt: now, + } + } + + err := f.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "chain"}, {Name: "pet_id"}, {Name: "slot"}}, + DoUpdates: clause.AssignmentColumns(equipmentUpdateColumns), + Where: clause.Where{Exprs: []clause.Expression{ + clause.Expr{SQL: "pet_equipment.last_version <= excluded.last_version"}, + }}, + }).Create(&rows).Error + if err != nil { + return fmt.Errorf("store: equipment upsert (%d rows): %w", len(batch), err) + } + return nil +} + // LoadRoster reads the whole pet_roster table — the cache warm-up source // (the table is the persistent copy of the exact data the cache mirrors). func (f *PgFlusher) LoadRoster(ctx context.Context) ([]indexer.RosterUpdate, error) { diff --git a/services/indexer-go/internal/store/writer.go b/services/indexer-go/internal/store/writer.go index 9788ed21..d10e39e9 100644 --- a/services/indexer-go/internal/store/writer.go +++ b/services/indexer-go/internal/store/writer.go @@ -27,6 +27,8 @@ const ( // is unit-testable without Postgres; pgFlusher is the real implementation. type flusher interface { FlushRoster(ctx context.Context, batch []indexer.RosterUpdate) error + FlushItems(ctx context.Context, batch []indexer.ItemUpdate) error + FlushEquipment(ctx context.Context, batch []indexer.EquipmentUpdate) error } type petKey struct { @@ -34,6 +36,23 @@ type petKey struct { petID string } +// itemKey matches item_roster's primary key: a balance is per holder per item +// type, with no per-instance identity to coalesce on. +type itemKey struct { + chain string + owner string + itemType string +} + +// equipKey matches pet_equipment's primary key. The slot is part of it, so two +// updates to different slots of one pet are separate rows rather than one +// overwriting the other. +type equipKey struct { + chain string + petID string + slot uint32 +} + type Writer struct { flusher flusher batchSize int @@ -46,22 +65,38 @@ type Writer struct { // Pending state is owned exclusively by the Run goroutine. // pendingRoster coalesces by pet — only the highest version survives — - // so a flush failure can never grow memory past the roster size. - pendingRoster map[petKey]indexer.RosterUpdate + // so a flush failure can never grow memory past the roster size. The two + // inventory maps do the same per item balance and per equip slot. + pendingRoster map[petKey]indexer.RosterUpdate + pendingItems map[itemKey]indexer.ItemUpdate + pendingEquipment map[equipKey]indexer.EquipmentUpdate } func NewWriter(f flusher) *Writer { return &Writer{ - flusher: f, - batchSize: DefaultBatchSize, - flushEvery: DefaultFlushEvery, - pendingRoster: make(map[petKey]indexer.RosterUpdate), + flusher: f, + batchSize: DefaultBatchSize, + flushEvery: DefaultFlushEvery, + pendingRoster: make(map[petKey]indexer.RosterUpdate), + pendingItems: make(map[itemKey]indexer.ItemUpdate), + pendingEquipment: make(map[equipKey]indexer.EquipmentUpdate), } } -// Run drains the roster channel until ctx is done, then performs a final flush on -// a fresh deadline so in-flight batches survive shutdown. -func (w *Writer) Run(ctx context.Context, roster <-chan indexer.RosterUpdate) error { +// Run drains the update channels until ctx is done, then performs a final flush +// on a fresh deadline so in-flight batches survive shutdown. +// +// One goroutine for all three streams, which is the point of this type: ordering +// and idempotency are enforced in a single place rather than per entity. The +// inventory channels may be nil on a deployment with no item source, and a nil +// channel blocks forever in a select, which is exactly the "never fires" +// behaviour that needs. +func (w *Writer) Run( + ctx context.Context, + roster <-chan indexer.RosterUpdate, + items <-chan indexer.ItemUpdate, + equipment <-chan indexer.EquipmentUpdate, +) error { ticker := time.NewTicker(w.flushEvery) defer ticker.Stop() @@ -70,7 +105,7 @@ func (w *Writer) Run(ctx context.Context, roster <-chan indexer.RosterUpdate) er case <-ctx.Done(): drainCtx, cancel := context.WithTimeout(context.Background(), drainTimeout) defer cancel() - w.flushRoster(drainCtx) + w.flushAll(drainCtx) return nil case u := <-roster: @@ -79,12 +114,33 @@ func (w *Writer) Run(ctx context.Context, roster <-chan indexer.RosterUpdate) er w.flushRoster(ctx) } + case u := <-items: + w.coalesceItem(u) + if len(w.pendingItems) >= w.batchSize { + w.flushItems(ctx) + } + + case u := <-equipment: + w.coalesceEquipment(u) + if len(w.pendingEquipment) >= w.batchSize { + w.flushEquipment(ctx) + } + case <-ticker.C: - w.flushRoster(ctx) + w.flushAll(ctx) } } } +// flushAll attempts each pending batch. Independent calls rather than one +// transaction: the three tables have no invariant spanning them, so a failure to +// write balances should not hold back the roster write that already succeeded. +func (w *Writer) flushAll(ctx context.Context) { + w.flushRoster(ctx) + w.flushItems(ctx) + w.flushEquipment(ctx) +} + // coalesce keeps the freshest state per pet. Equal versions prefer the later // arrival (same source state re-delivered). func (w *Writer) coalesce(u indexer.RosterUpdate) { @@ -119,3 +175,64 @@ func (w *Writer) flushRoster(ctx context.Context) { clear(w.pendingRoster) } +// coalesceItem keeps the freshest balance per (chain, owner, item type). +// +// No metrics.RosterUpdate / SetLastVersion call here, unlike coalesce: those +// gauges describe the roster's freshness per chain, and feeding a second, +// faster-moving stream into them would report the inventory's progress as the +// roster's. The generic flush counters below still cover these writes. +func (w *Writer) coalesceItem(u indexer.ItemUpdate) { + k := itemKey{chain: u.Chain, owner: u.Owner, itemType: u.ItemType} + if existing, ok := w.pendingItems[k]; ok && existing.Version > u.Version { + return + } + w.pendingItems[k] = u +} + +// coalesceEquipment keeps the freshest state per (chain, pet, slot). +func (w *Writer) coalesceEquipment(u indexer.EquipmentUpdate) { + k := equipKey{chain: u.Chain, petID: u.PetID, slot: u.Slot} + if existing, ok := w.pendingEquipment[k]; ok && existing.Version > u.Version { + return + } + w.pendingEquipment[k] = u +} + +// flushItems attempts one batch write, retaining the batch for retry on failure. +func (w *Writer) flushItems(ctx context.Context) { + if len(w.pendingItems) == 0 { + return + } + batch := make([]indexer.ItemUpdate, 0, len(w.pendingItems)) + for _, u := range w.pendingItems { + batch = append(batch, u) + } + if err := w.flusher.FlushItems(ctx, batch); err != nil { + metrics.FlushError() + slog.Error("item flush failed; batch retained for retry", "rows", len(batch), "err", err) + return + } + metrics.Flush(len(batch)) + // No cache hook. The read cache mirrors pet_roster only; inventory reads go + // to Postgres, so there is nothing here to keep coherent. + clear(w.pendingItems) +} + +// flushEquipment attempts one batch write, retaining the batch for retry on failure. +func (w *Writer) flushEquipment(ctx context.Context) { + if len(w.pendingEquipment) == 0 { + return + } + batch := make([]indexer.EquipmentUpdate, 0, len(w.pendingEquipment)) + for _, u := range w.pendingEquipment { + batch = append(batch, u) + } + if err := w.flusher.FlushEquipment(ctx, batch); err != nil { + metrics.FlushError() + slog.Error("equipment flush failed; batch retained for retry", "rows", len(batch), "err", err) + return + } + metrics.Flush(len(batch)) + clear(w.pendingEquipment) +} + diff --git a/services/indexer-go/internal/store/writer_test.go b/services/indexer-go/internal/store/writer_test.go index 4dfe4496..73396b7a 100644 --- a/services/indexer-go/internal/store/writer_test.go +++ b/services/indexer-go/internal/store/writer_test.go @@ -13,9 +13,11 @@ import ( // fakeFlusher records flushes and can be told to fail. type fakeFlusher struct { - mu sync.Mutex - rosterCalls [][]indexer.RosterUpdate - fail bool + mu sync.Mutex + rosterCalls [][]indexer.RosterUpdate + itemCalls [][]indexer.ItemUpdate + equipmentCalls [][]indexer.EquipmentUpdate + fail bool } func (f *fakeFlusher) FlushRoster(_ context.Context, batch []indexer.RosterUpdate) error { @@ -28,6 +30,46 @@ func (f *fakeFlusher) FlushRoster(_ context.Context, batch []indexer.RosterUpdat return nil } +func (f *fakeFlusher) FlushItems(_ context.Context, batch []indexer.ItemUpdate) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail { + return errors.New("flush refused") + } + f.itemCalls = append(f.itemCalls, append([]indexer.ItemUpdate(nil), batch...)) + return nil +} + +func (f *fakeFlusher) FlushEquipment(_ context.Context, batch []indexer.EquipmentUpdate) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail { + return errors.New("flush refused") + } + f.equipmentCalls = append(f.equipmentCalls, append([]indexer.EquipmentUpdate(nil), batch...)) + return nil +} + +func (f *fakeFlusher) allItemRows() []indexer.ItemUpdate { + f.mu.Lock() + defer f.mu.Unlock() + var all []indexer.ItemUpdate + for _, c := range f.itemCalls { + all = append(all, c...) + } + return all +} + +func (f *fakeFlusher) allEquipmentRows() []indexer.EquipmentUpdate { + f.mu.Lock() + defer f.mu.Unlock() + var all []indexer.EquipmentUpdate + for _, c := range f.equipmentCalls { + all = append(all, c...) + } + return all +} + func (f *fakeFlusher) setFail(v bool) { f.mu.Lock() @@ -50,20 +92,31 @@ func update(petID string, version uint64, level uint32) indexer.RosterUpdate { return indexer.RosterUpdate{Chain: "evm", PetID: petID, Level: level, Version: version} } -// runWriter starts the writer and returns channels plus a stop function that -// cancels and waits for the final drain. -func runWriter(t *testing.T, w *Writer) (chan indexer.RosterUpdate, func()) { +// writerChans are the three streams a running writer drains. +type writerChans struct { + roster chan indexer.RosterUpdate + items chan indexer.ItemUpdate + equipment chan indexer.EquipmentUpdate +} + +// runWriterAll starts the writer and returns all three channels plus a stop +// function that cancels and waits for the final drain. +func runWriterAll(t *testing.T, w *Writer) (writerChans, func()) { t.Helper() - roster := make(chan indexer.RosterUpdate, 256) + chans := writerChans{ + roster: make(chan indexer.RosterUpdate, 256), + items: make(chan indexer.ItemUpdate, 256), + equipment: make(chan indexer.EquipmentUpdate, 256), + } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) - if err := w.Run(ctx, roster); err != nil { + if err := w.Run(ctx, chans.roster, chans.items, chans.equipment); err != nil { t.Errorf("Run: %v", err) } }() - return roster, func() { + return chans, func() { cancel() select { case <-done: @@ -73,6 +126,13 @@ func runWriter(t *testing.T, w *Writer) (chan indexer.RosterUpdate, func()) { } } +// runWriter is the roster-only convenience the existing tests are written against. +func runWriter(t *testing.T, w *Writer) (chan indexer.RosterUpdate, func()) { + t.Helper() + chans, stop := runWriterAll(t, w) + return chans.roster, stop +} + func TestFlushesWhenBatchSizeReached(t *testing.T) { f := &fakeFlusher{} w := NewWriter(f) @@ -164,3 +224,100 @@ func TestFinalDrainOnShutdown(t *testing.T) { t.Errorf("final drain flushed %d roster rows, want 1", got) } } + +// ─── inventory (roadmap §4) ────────────────────────────────────────────────── + +func itemUpdate(owner, itemType string, quantity, version uint64) indexer.ItemUpdate { + return indexer.ItemUpdate{ + Chain: "evm", Owner: owner, ItemType: itemType, Quantity: quantity, Version: version, + } +} + +func equipUpdate(petID string, slot uint32, itemType string, version uint64) indexer.EquipmentUpdate { + return indexer.EquipmentUpdate{ + Chain: "evm", PetID: petID, Slot: slot, ItemType: itemType, Version: version, + } +} + +func TestCoalescesItemsByHolderAndType(t *testing.T) { + f := &fakeFlusher{} + w := NewWriter(f) + w.flushEvery = time.Hour + + // Fed directly rather than through the channel: coalesce is loop-owned state, + // and a ticker that fired mid-sequence would split the batch and make the row + // count depend on timing rather than on the coalescing being tested. + w.coalesceItem(itemUpdate("0xa", "1", 5, 100)) + w.coalesceItem(itemUpdate("0xa", "1", 2, 300)) + w.coalesceItem(itemUpdate("0xa", "1", 9, 200)) // stale arrival after fresher state + // A different type for the same holder is a different row, not an overwrite. + w.coalesceItem(itemUpdate("0xa", "2", 1, 100)) + + w.flushItems(context.Background()) + + rows := f.allItemRows() + if len(rows) != 2 { + t.Fatalf("flushed %d item rows, want 2 (coalesced)", len(rows)) + } + for _, r := range rows { + if r.ItemType == "1" && (r.Version != 300 || r.Quantity != 2) { + t.Errorf("type 1 coalesced to the wrong version: %+v", r) + } + if r.ItemType == "2" && r.Quantity != 1 { + t.Errorf("type 2 should be its own row: %+v", r) + } + } +} + +// The slot is part of the key, so two slots on one pet are two rows. Keying on +// the pet alone would have the armor write silently discard the weapon write. +func TestCoalescesEquipmentPerSlot(t *testing.T) { + f := &fakeFlusher{} + w := NewWriter(f) + w.flushEvery = time.Hour + + w.coalesceEquipment(equipUpdate("1", 0, "7", 100)) + w.coalesceEquipment(equipUpdate("1", 1, "8", 100)) + w.coalesceEquipment(equipUpdate("1", 0, "0", 200)) // weapon unequipped + + w.flushEquipment(context.Background()) + + rows := f.allEquipmentRows() + if len(rows) != 2 { + t.Fatalf("flushed %d equipment rows, want 2 (one per slot)", len(rows)) + } + for _, r := range rows { + if r.Slot == 0 && (r.ItemType != "0" || r.Version != 200) { + t.Errorf("slot 0 should hold the unequip: %+v", r) + } + if r.Slot == 1 && r.ItemType != "8" { + t.Errorf("slot 1 should be untouched: %+v", r) + } + } +} + +// All three streams drain through one goroutine, so shutdown has to flush all of +// them rather than only the roster. +func TestFinalDrainCoversInventory(t *testing.T) { + f := &fakeFlusher{} + w := NewWriter(f) + w.flushEvery = time.Hour + + chans, stop := runWriterAll(t, w) + chans.roster <- update("1", 1, 1) + chans.items <- itemUpdate("0xa", "1", 5, 100) + chans.equipment <- equipUpdate("1", 0, "7", 100) + + time.Sleep(50 * time.Millisecond) // buffered, no tick will fire + stop() + + if got := len(f.allRosterRows()); got != 1 { + t.Errorf("final drain flushed %d roster rows, want 1", got) + } + if got := len(f.allItemRows()); got != 1 { + t.Errorf("final drain flushed %d item rows, want 1", got) + } + if got := len(f.allEquipmentRows()); got != 1 { + t.Errorf("final drain flushed %d equipment rows, want 1", got) + } +} From b33608cbaedd80571d26bb2ffdd6aad29134a0d8 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:43:28 -0400 Subject: [PATCH 07/56] feat(inventory): add the item catalog, projections and entitlement tables --- .../migration.sql | 81 +++++++++++++ backend/prisma/schema.prisma | 114 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 backend/prisma/migrations/20260807160000_add_inventory/migration.sql diff --git a/backend/prisma/migrations/20260807160000_add_inventory/migration.sql b/backend/prisma/migrations/20260807160000_add_inventory/migration.sql new file mode 100644 index 00000000..bf652d62 --- /dev/null +++ b/backend/prisma/migrations/20260807160000_add_inventory/migration.sql @@ -0,0 +1,81 @@ +-- CreateTable +CREATE TABLE "item_definition" ( + "item_type" TEXT NOT NULL, + "key" TEXT NOT NULL, + "category" TEXT NOT NULL, + "slot" INTEGER, + "rarity" INTEGER NOT NULL, + "effect" JSONB, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + + CONSTRAINT "item_definition_pkey" PRIMARY KEY ("item_type") +); + +-- CreateTable +CREATE TABLE "item_roster" ( + "chain" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "item_type" TEXT NOT NULL, + "quantity" BIGINT NOT NULL, + "last_version" BIGINT NOT NULL DEFAULT 0, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "item_roster_pkey" PRIMARY KEY ("chain","owner","item_type") +); + +-- CreateTable +CREATE TABLE "pet_equipment" ( + "chain" TEXT NOT NULL, + "pet_id" TEXT NOT NULL, + "slot" INTEGER NOT NULL, + "item_type" TEXT NOT NULL, + "last_version" BIGINT NOT NULL DEFAULT 0, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pet_equipment_pkey" PRIMARY KEY ("chain","pet_id","slot") +); + +-- CreateTable +CREATE TABLE "item_entitlement" ( + "id" TEXT NOT NULL, + "chain" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "item_type" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "source" TEXT NOT NULL, + "source_ref" TEXT NOT NULL, + "claimed_at" TIMESTAMP(3), + "tx_hash" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "item_entitlement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "item_definition_key_key" ON "item_definition"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "item_entitlement_source_ref_owner_item_type_key" ON "item_entitlement"("source_ref", "owner", "item_type"); + +-- CreateIndex +CREATE INDEX "item_entitlement_chain_owner_claimed_at_idx" ON "item_entitlement"("chain", "owner", "claimed_at"); + +-- EnableRowLevelSecurity +-- +-- Prisma emits no RLS statements, and on Supabase `ALTER DEFAULT PRIVILEGES` grants every +-- newly created table in `public` to `anon` and `authenticated` with ALL privileges — +-- including DELETE and TRUNCATE. So a table shipped without this line is readable and +-- writable by anyone holding the project's public anon key. Here that would mean anyone +-- being able to grant themselves items, or delete the equipment a battle snapshot is about +-- to be built from. +-- +-- Enabled with no policies, matching every other table in this database: that denies all +-- access to the PostgREST roles, while the backend connects as the table owner +-- (`postgres`) and owners bypass RLS unless FORCE is set. indexer-go connects on the same +-- URL and is unaffected for the same reason. Do NOT add FORCE here — it would apply these +-- policy-less tables to the owner too and deny both services everything. +ALTER TABLE "item_definition" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "item_roster" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "pet_equipment" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "item_entitlement" ENABLE ROW LEVEL SECURITY; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1476135e..34641fbf 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -706,3 +706,117 @@ model BattleSigningKey { @@map("battle_signing_key") } + +// ─── Inventory (roadmap §4) ─────────────────────────────────────────────────── +// Two owners, kept apart the way pet_roster and pet_battle_progress are. The +// catalog is backend content that a rebalance edits; item_roster and +// pet_equipment are projections of chain state that only indexer-go writes, and +// carry the same monotonic `lastVersion` guard pet_roster does. + +/// One item type's content: what it is called, how rare it is, and what it does. +/// +/// Keyed on the on-chain token id rather than on a slug, because that is what +/// every projection below joins against and what a battle snapshot records. The +/// slug is kept beside it as the handle content authors and seed data use, since +/// "xp_potion_i" survives a redeploy that renumbers ids and a bare 7 does not. +/// +/// `effect` is deliberately off chain. ItemCore stores only the numeric type, so +/// a rebalance is a row edit here rather than a transaction, matching how +/// GameConfig keeps balance knobs owner-tunable away from the asset contract. +/// The cost is that a combat-affecting effect has to be versioned some other way, +/// which is what the ruleset's itemCatalogHash is for (§4 phase 4). +model ItemDefinition { + /// ERC-1155 token id as a decimal string, matching item_roster.item_type. + itemType String @id @map("item_type") + /// Stable content key, e.g. 'xp_potion_i'. + key String @unique + /// 'consumable' | 'equipment' | 'collectible' | 'material'. + category String + /// Equip slot 0-2 (ItemCore.SLOT_*), null unless category = 'equipment'. + /// + /// Duplicated from ItemCore's own registry, which is authoritative: the + /// contract rejects an equip into the wrong slot and this column cannot. They + /// are written together by the catalog seeder, and a disagreement shows up as + /// an equip the UI offers and the chain refuses. + slot Int? + /// 1-5, the same scale as pet rarity (shared/src/utils/pets/cosmetics.ts). + rarity Int + /// Modifier payload for equipment; null for everything else. + effect Json? + name String + description String + + @@map("item_definition") +} + +/// One holder's balance of one item type. Written only by indexer-go. +/// +/// Keyed on (chain, owner, itemType) because an ERC-1155 balance is a count of a +/// fungible type: there is no individual item to name, unlike a pet. The +/// ItemCore contract itself appears here as a holder, since equipping escrows the +/// token into it. +model ItemRoster { + chain String + owner String + itemType String @map("item_type") + quantity BigInt + + /// Monotonic source version (subgraph updatedAt), guarding the upsert exactly + /// as pet_roster.last_version does. + lastVersion BigInt @default(0) @map("last_version") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([chain, owner, itemType]) + @@map("item_roster") +} + +/// What is equipped in one pet's one slot. Written only by indexer-go. +/// +/// A row persists once written, holding item type "0" for an empty slot rather +/// than being deleted. The subgraph is read on an updatedAt watermark, and a +/// deletion is invisible to a watermark reader, so an unequip that removed the +/// row would never arrive here at all. +model PetEquipment { + chain String + petId String @map("pet_id") + slot Int + /// Equipped item type, or "0" for an empty slot. + itemType String @map("item_type") + + lastVersion BigInt @default(0) @map("last_version") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([chain, petId, slot]) + @@map("pet_equipment") +} + +/// An item a wallet has earned but not yet minted: a battle drop, or an admin +/// grant. Backend-owned, unlike the two projections above. +/// +/// The row exists because minting costs gas and a drop should not block a battle +/// on a transaction. Claiming it is what calls ItemCore.mintTo, after which +/// item_roster picks the balance up through the indexer like any other mint. +model ItemEntitlement { + id String @id @default(cuid()) + chain String + owner String + itemType String @map("item_type") + quantity Int + /// 'battle_drop' | 'admin_grant'. + source String + /// What produced it: the battleId for a drop, the request id for a grant. + /// + /// Part of a unique key with owner and item type, so the battle worker writing + /// a drop inside the receipt transaction stays idempotent under a retry. A + /// battle that settles twice must not pay out twice. + sourceRef String @map("source_ref") + + claimedAt DateTime? @map("claimed_at") + /// Mint transaction hash, set when the claim lands. + txHash String? @map("tx_hash") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([sourceRef, owner, itemType]) + @@index([chain, owner, claimedAt]) + @@map("item_entitlement") +} From ee1d2c0a672e6dd9b02b3c0b28b2e15da4fcd98b Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:48:31 -0400 Subject: [PATCH 08/56] feat(inventory): add the v1 item catalog and its seeder --- backend/scripts/seed-item-catalog.ts | 166 ++++++++++++++++ .../src/features/inventory/catalog.data.ts | 184 ++++++++++++++++++ backend/src/features/inventory/catalog.ts | 169 ++++++++++++++++ .../tests/features/inventory/catalog.test.ts | 133 +++++++++++++ 4 files changed, 652 insertions(+) create mode 100644 backend/scripts/seed-item-catalog.ts create mode 100644 backend/src/features/inventory/catalog.data.ts create mode 100644 backend/src/features/inventory/catalog.ts create mode 100644 backend/tests/features/inventory/catalog.test.ts diff --git a/backend/scripts/seed-item-catalog.ts b/backend/scripts/seed-item-catalog.ts new file mode 100644 index 00000000..fbe58be4 --- /dev/null +++ b/backend/scripts/seed-item-catalog.ts @@ -0,0 +1,166 @@ +/** + * Seeds the item catalog (roadmap §4) into `item_definition`, and optionally registers + * every equipment item's slot on ItemCore. + * + * The two halves belong in one command because they are one fact stored twice. The + * contract is authoritative for slots (only it can reject a wrong-slot equip) and the + * table is what the UI and the snapshot builder read, so a run that updated one and not + * the other would leave an equip the app offers and the chain refuses. + * + * Usage (from backend/): + * pnpm tsx scripts/seed-item-catalog.ts # database only + * pnpm tsx scripts/seed-item-catalog.ts --with-chain # database + slot registration + * pnpm tsx scripts/seed-item-catalog.ts --dry-run # print the plan, write nothing + * + * Chain registration needs, in backend/.env: + * ITEM_CORE_ADDRESS=0x... the ItemCore proxy (deploy.ts prints it) + * ITEM_CORE_RPC_URL=http://... + * ITEM_CORE_PRIVATE_KEY=0x... must be ItemCore's owner; registerItemSlot is onlyOwner + * + * Safe to re-run. Definitions upsert by token id, and registerItemSlot is idempotent for + * an unchanged slot, so this is the way a catalog edit ships rather than a one-shot. + */ +import 'dotenv/config'; + +import { PrismaClient } from '../src/generated/prisma/client'; +import { assertCatalog, SLOT } from '../src/features/inventory/catalog'; +import { ITEM_CATALOG } from '../src/features/inventory/catalog.data'; + +const ITEM_CORE_ABI = [ + { + type: 'function', + name: 'registerItemSlot', + stateMutability: 'nonpayable', + inputs: [ + { name: 'itemType', type: 'uint256' }, + { name: 'slot', type: 'uint8' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'slotOf', + stateMutability: 'view', + inputs: [{ name: 'itemType', type: 'uint256' }], + outputs: [ + { name: 'isEquipment', type: 'bool' }, + { name: 'slot', type: 'uint8' }, + ], + }, +] as const; + +async function seedDatabase(dryRun: boolean): Promise { + const catalog = assertCatalog(ITEM_CATALOG); + console.log(`[catalog] ${catalog.length} definitions validated`); + + if (dryRun) { + for (const item of catalog) { + console.log(` would upsert ${item.itemType.padStart(3)} ${item.key} (${item.category})`); + } + return; + } + + const prisma = new PrismaClient(); + try { + for (const item of catalog) { + const row = { + key: item.key, + category: item.category, + slot: item.slot === undefined ? null : SLOT[item.slot], + rarity: item.rarity, + effect: item.effect ?? null, + name: item.name, + description: item.description, + }; + await prisma.itemDefinition.upsert({ + where: { itemType: item.itemType }, + create: { itemType: item.itemType, ...row }, + update: row, + }); + } + console.log(`[catalog] ${catalog.length} definitions written`); + + // Deliberately not deleted. A definition removed from the source file may still be + // named by an item somebody holds, or by a receipt that has already been signed, and + // a missing row would leave both unreadable. Retiring an item is a content decision + // with its own migration, not a side effect of an edit here. + const orphans = await prisma.itemDefinition.findMany({ + where: { itemType: { notIn: catalog.map((i) => i.itemType) } }, + select: { itemType: true, key: true }, + }); + for (const orphan of orphans) { + console.warn(`[catalog] ⚠️ ${orphan.itemType} (${orphan.key}) is in the database but not in the source; left in place`); + } + } finally { + await prisma.$disconnect(); + } +} + +async function registerSlots(dryRun: boolean): Promise { + const address = process.env.ITEM_CORE_ADDRESS; + const rpcUrl = process.env.ITEM_CORE_RPC_URL; + const privateKey = process.env.ITEM_CORE_PRIVATE_KEY; + if (!address || !rpcUrl || !privateKey) { + throw new Error('--with-chain needs ITEM_CORE_ADDRESS, ITEM_CORE_RPC_URL and ITEM_CORE_PRIVATE_KEY'); + } + + const { createPublicClient, createWalletClient, http } = await import('viem'); + const { privateKeyToAccount } = await import('viem/accounts'); + + const account = privateKeyToAccount(privateKey as `0x${string}`); + const transport = http(rpcUrl); + const publicClient = createPublicClient({ transport }); + const walletClient = createWalletClient({ account, transport }); + const chainId = await publicClient.getChainId(); + + const equipment = ITEM_CATALOG.filter((item) => item.slot !== undefined); + console.log(`[chain] ${equipment.length} equipment items, ItemCore ${address} on chain ${chainId}`); + + for (const item of equipment) { + const want = SLOT[item.slot!]; + const [isEquipment, current] = await publicClient.readContract({ + address: address as `0x${string}`, + abi: ITEM_CORE_ABI, + functionName: 'slotOf', + args: [BigInt(item.itemType)], + }); + + if (isEquipment && current === want) { + console.log(` ${item.key} already registered to slot ${want}`); + continue; + } + if (dryRun) { + console.log(` would register ${item.key} -> slot ${want}`); + continue; + } + + // Sent one at a time and awaited: this runs a handful of times per deployment, so + // a nonce queue would be machinery for throughput that does not exist. + const hash = await walletClient.writeContract({ + address: address as `0x${string}`, + abi: ITEM_CORE_ABI, + functionName: 'registerItemSlot', + args: [BigInt(item.itemType), want], + chain: null, + }); + await publicClient.waitForTransactionReceipt({ hash }); + console.log(` registered ${item.key} -> slot ${want} (${hash})`); + } +} + +async function main(): Promise { + const dryRun = process.argv.includes('--dry-run'); + const withChain = process.argv.includes('--with-chain'); + + await seedDatabase(dryRun); + if (withChain) { + await registerSlots(dryRun); + } else { + console.log('[chain] skipped; pass --with-chain to register equipment slots on ItemCore'); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/backend/src/features/inventory/catalog.data.ts b/backend/src/features/inventory/catalog.data.ts new file mode 100644 index 00000000..badd529f --- /dev/null +++ b/backend/src/features/inventory/catalog.data.ts @@ -0,0 +1,184 @@ +import type { ItemDefinitionSeed } from './catalog'; + +/** + * The v1 item catalog (roadmap §4). + * + * Breadth is the point here rather than depth: §4 takes the wide-catalog idea from + * OwoBot and the gear-matters idea from Dota, and says not to attempt Dota's scale on + * day one. Sixteen items across the four shipping categories is enough to exercise every + * path (a stackable, a burn, an equip, a slot conflict) without pretending to be content + * design, which is a human call. + * + * Token ids are banded by category so a later addition slots in without renumbering, and + * so a stray id in a log is recognisable. Nothing enforces the bands; they are a reading + * convenience, and `key` is the identifier that actually has to stay stable. + * + * 1-99 equipment + * 100-199 consumables + * 200-299 collectibles + * 300-399 crafting materials + * + * Rarity reuses the game's five tiers verbatim (shared/src/utils/pets/cosmetics.ts), so + * pets and items share one vocabulary rather than inventing a second scale. + */ +export const ITEM_CATALOG: readonly ItemDefinitionSeed[] = [ + // ─── equipment: weapons ─────────────────────────────────────────────────── + { + itemType: '1', + key: 'iron_fang', + category: 'equipment', + slot: 'weapon', + rarity: 1, + effect: { kind: 'stat_bonus', hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + name: 'Iron Fang', + description: 'A blunt starter blade. Chipped, but it swings.', + }, + { + itemType: '2', + key: 'storm_talon', + category: 'equipment', + slot: 'weapon', + rarity: 3, + effect: { kind: 'stat_bonus', hp: 0, atk: 10, def: 0, int: 4, mdef: 0 }, + name: 'Storm Talon', + description: 'Hums before a strike. Nobody agrees on why.', + }, + { + itemType: '3', + key: 'sunder_maul', + category: 'equipment', + slot: 'weapon', + rarity: 5, + effect: { kind: 'stat_bonus', hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }, + name: 'Sunder Maul', + description: 'Too heavy for most pets. The ones who lift it rarely need a second hit.', + }, + + // ─── equipment: armor ───────────────────────────────────────────────────── + { + itemType: '10', + key: 'hide_vest', + category: 'equipment', + slot: 'armor', + rarity: 1, + effect: { kind: 'stat_bonus', hp: 12, atk: 0, def: 4, int: 0, mdef: 0 }, + name: 'Hide Vest', + description: 'Cheap, scratchy, and better than nothing.', + }, + { + itemType: '11', + key: 'scale_mail', + category: 'equipment', + slot: 'armor', + rarity: 3, + effect: { kind: 'stat_bonus', hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, + name: 'Scale Mail', + description: 'Shed plates, re-stitched. The previous owner did not need them.', + }, + { + itemType: '12', + key: 'aegis_carapace', + category: 'equipment', + slot: 'armor', + rarity: 4, + effect: { kind: 'stat_bonus', hp: 45, atk: 0, def: 16, int: 0, mdef: 6 }, + name: 'Aegis Carapace', + description: 'Grown, not forged. It closes over a wound on its own.', + }, + + // ─── equipment: trinkets ────────────────────────────────────────────────── + { + itemType: '20', + key: 'river_charm', + category: 'equipment', + slot: 'trinket', + rarity: 2, + effect: { kind: 'stat_bonus', hp: 0, atk: 0, def: 0, int: 0, mdef: 6 }, + name: 'River Charm', + description: 'Cold to the touch, always. Wards off the worst of a spell.', + }, + { + itemType: '21', + key: 'focus_sigil', + category: 'equipment', + slot: 'trinket', + rarity: 4, + effect: { kind: 'stat_bonus', hp: 0, atk: 0, def: 0, int: 12, mdef: 8 }, + name: 'Focus Sigil', + description: 'Sharpens whatever the wearer was already thinking about.', + }, + + // ─── consumables ────────────────────────────────────────────────────────── + { + itemType: '100', + key: 'xp_potion_i', + category: 'consumable', + rarity: 1, + effect: { kind: 'grant_xp', amount: 50 }, + name: 'Lesser Tonic', + description: 'Tastes of copper. Grants 50 XP.', + }, + { + itemType: '101', + key: 'xp_potion_ii', + category: 'consumable', + rarity: 3, + effect: { kind: 'grant_xp', amount: 200 }, + name: 'Greater Tonic', + description: 'Tastes worse, works better. Grants 200 XP.', + }, + { + itemType: '110', + key: 'cooldown_draught', + category: 'consumable', + rarity: 2, + effect: { kind: 'clear_battle_cooldown' }, + name: 'Second Wind', + description: 'Clears a pet’s battle cooldown. The ache comes back later.', + }, + { + itemType: '111', + key: 'fertility_charm', + category: 'consumable', + rarity: 4, + effect: { kind: 'clear_breed_cooldown' }, + name: 'Fertility Charm', + description: 'Clears a pet’s breeding cooldown.', + }, + + // ─── collectibles ───────────────────────────────────────────────────────── + { + itemType: '200', + key: 'crate_key', + category: 'collectible', + rarity: 2, + name: 'Crate Key', + description: 'Opens nothing yet. Crates are a later feature; the keys drop now.', + }, + { + itemType: '201', + key: 'founders_badge', + category: 'collectible', + rarity: 5, + name: 'Founder’s Badge', + description: 'Proof you were here early. Does nothing else, deliberately.', + }, + + // ─── crafting materials ─────────────────────────────────────────────────── + { + itemType: '300', + key: 'ember_shard', + category: 'material', + rarity: 1, + name: 'Ember Shard', + description: 'Still warm. Common enough that nobody keeps count.', + }, + { + itemType: '301', + key: 'void_dust', + category: 'material', + rarity: 3, + name: 'Void Dust', + description: 'Pools in corners and does not settle.', + }, +]; diff --git a/backend/src/features/inventory/catalog.ts b/backend/src/features/inventory/catalog.ts new file mode 100644 index 00000000..0372bb14 --- /dev/null +++ b/backend/src/features/inventory/catalog.ts @@ -0,0 +1,169 @@ +/** + * The item catalog's shape and its validation (roadmap §4). + * + * Content lives in `catalog.data.ts` as a typed literal rather than a JSON file. + * The backend has no `resolveJsonModule` and imports no JSON anywhere in `src`, so a + * `.json` would need a compiler flag plus a build-copy step to reach `dist`; a data + * module is the same flat list with type errors caught at compile time instead. + * + * Validation is here, separate from the data and from the database, because these rules + * are the ones a content edit gets wrong: an equipment item with no slot, a consumable + * with no effect, two items sharing a token id. All of them are cheap to check and + * expensive to discover in production, and none of them need a connection to check. + */ + +/** Equip slots, mirroring ItemCore.SLOT_*. The contract is authoritative. */ +export const SLOT = { weapon: 0, armor: 1, trinket: 2 } as const; +export type SlotName = keyof typeof SLOT; + +export const ITEM_CATEGORIES = ['consumable', 'equipment', 'collectible', 'material'] as const; +export type ItemCategory = (typeof ITEM_CATEGORIES)[number]; + +/** + * Flat, non-negative additions to a pet's extracted attributes. + * + * Non-negative and additive only in v1, which §4 recommends and which also removes a + * real hazard: the engine truncates to 16 bits with wraparound rather than clamping, so + * a negative modifier is one underflow away from a pet with 65,000 HP. A multiplicative + * or conditional effect system is a v2 of the equipment model, not a field added here. + */ +export interface StatBonus { + kind: 'stat_bonus'; + hp: number; + atk: number; + def: number; + int: number; + mdef: number; +} + +export type ItemEffect = + | StatBonus + | { kind: 'grant_xp'; amount: number } + | { kind: 'clear_battle_cooldown' } + | { kind: 'clear_breed_cooldown' }; + +/** One catalog entry, as authored. */ +export interface ItemDefinitionSeed { + /** ERC-1155 token id as a decimal string, matching item_roster.item_type. */ + itemType: string; + /** Stable content key. Survives a redeploy that renumbers token ids. */ + key: string; + category: ItemCategory; + /** Required for equipment, absent otherwise. */ + slot?: SlotName; + /** 1-5, the same scale as pet rarity. */ + rarity: number; + effect?: ItemEffect; + name: string; + description: string; +} + +/** + * Sanity bounds, not game-design opinions. + * + * A pet's extracted attributes land in the low hundreds, so a bonus in the thousands is + * a typo rather than a tuning choice. Anything inside these is the designer's call. + */ +const MAX_STAT_BONUS = 500; +const MAX_XP_GRANT = 100_000; + +const SAFE_KEY_PATTERN = /^[a-z][a-z0-9_]{1,63}$/; + +/** + * Validates the whole catalog, returning it unchanged. + * + * Whole-catalog rather than per-item, because the two failures that matter most are + * collisions: a duplicate token id would give two definitions to one on-chain item, and + * a duplicate key would make seed data ambiguous about which row it meant. + */ +export function assertCatalog(items: readonly ItemDefinitionSeed[]): readonly ItemDefinitionSeed[] { + const seenTypes = new Set(); + const seenKeys = new Set(); + + for (const item of items) { + assertItem(item); + if (seenTypes.has(item.itemType)) { + throw new Error(`duplicate item type ${item.itemType} (${item.key})`); + } + if (seenKeys.has(item.key)) { + throw new Error(`duplicate item key ${item.key}`); + } + seenTypes.add(item.itemType); + seenKeys.add(item.key); + } + + return items; +} + +function assertItem(item: ItemDefinitionSeed): void { + const label = item.key || item.itemType; + + if (!/^[1-9][0-9]*$/.test(item.itemType)) { + // Type 0 is ItemCore's empty-slot sentinel, and registerItemSlot refuses it. + throw new Error(`${label}: itemType must be a positive decimal string, got ${JSON.stringify(item.itemType)}`); + } + if (!SAFE_KEY_PATTERN.test(item.key)) { + throw new Error(`${label}: key must be lower_snake_case, got ${JSON.stringify(item.key)}`); + } + if (!ITEM_CATEGORIES.includes(item.category)) { + throw new Error(`${label}: unknown category ${JSON.stringify(item.category)}`); + } + if (!Number.isInteger(item.rarity) || item.rarity < 1 || item.rarity > 5) { + throw new Error(`${label}: rarity must be 1-5, got ${item.rarity}`); + } + if (!item.name.trim() || !item.description.trim()) { + throw new Error(`${label}: name and description are required`); + } + + if (item.category === 'equipment') { + if (item.slot === undefined) { + throw new Error(`${label}: equipment needs a slot`); + } + if (item.effect?.kind !== 'stat_bonus') { + // Equipment with no modifier would be a cosmetic, and cosmetics are out of + // the v1 catalog. Letting one in would mean a geared snapshot whose entry + // resolves to nothing, which reads as a bug rather than as a choice. + throw new Error(`${label}: equipment needs a stat_bonus effect`); + } + assertStatBonus(item.effect, label); + return; + } + + if (item.slot !== undefined) { + throw new Error(`${label}: only equipment may declare a slot`); + } + + if (item.category === 'consumable') { + if (!item.effect) { + throw new Error(`${label}: a consumable needs an effect`); + } + if (item.effect.kind === 'stat_bonus') { + throw new Error(`${label}: stat_bonus is an equipment effect, not a consumable one`); + } + if (item.effect.kind === 'grant_xp') { + if (!Number.isInteger(item.effect.amount) || item.effect.amount < 1 || item.effect.amount > MAX_XP_GRANT) { + throw new Error(`${label}: grant_xp amount must be 1-${MAX_XP_GRANT}, got ${item.effect.amount}`); + } + } + return; + } + + // Collectibles and materials are inert by definition: they are gacha inputs and + // crafting inputs, and an effect on one would be a consumable wearing the wrong + // category. + if (item.effect) { + throw new Error(`${label}: a ${item.category} must not carry an effect`); + } +} + +function assertStatBonus(effect: StatBonus, label: string): void { + for (const field of ['hp', 'atk', 'def', 'int', 'mdef'] as const) { + const value = effect[field]; + if (!Number.isInteger(value) || value < 0 || value > MAX_STAT_BONUS) { + throw new Error(`${label}: ${field} bonus must be an integer 0-${MAX_STAT_BONUS}, got ${value}`); + } + } + if (effect.hp + effect.atk + effect.def + effect.int + effect.mdef === 0) { + throw new Error(`${label}: a stat_bonus that grants nothing is a cosmetic, which v1 does not carry`); + } +} diff --git a/backend/tests/features/inventory/catalog.test.ts b/backend/tests/features/inventory/catalog.test.ts new file mode 100644 index 00000000..9230bcac --- /dev/null +++ b/backend/tests/features/inventory/catalog.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { assertCatalog, SLOT, type ItemDefinitionSeed } from '@features/inventory/catalog'; +import { ITEM_CATALOG } from '@features/inventory/catalog.data'; + +/** + * The shipped catalog has to pass its own validator, and the validator has to reject the + * mistakes a content edit actually makes. Both halves matter: a validator nothing runs + * against real data drifts, and a catalog checked only by hand ships a duplicate token id + * eventually. + */ + +const EQUIPMENT: ItemDefinitionSeed = { + itemType: '1', + key: 'test_blade', + category: 'equipment', + slot: 'weapon', + rarity: 3, + effect: { kind: 'stat_bonus', hp: 0, atk: 5, def: 0, int: 0, mdef: 0 }, + name: 'Test Blade', + description: 'For tests.', +}; + +const CONSUMABLE: ItemDefinitionSeed = { + itemType: '100', + key: 'test_tonic', + category: 'consumable', + rarity: 1, + effect: { kind: 'grant_xp', amount: 50 }, + name: 'Test Tonic', + description: 'For tests.', +}; + +/** The shipped item with `patch` applied, so each case states only what it changes. */ +function variant(base: ItemDefinitionSeed, patch: Partial): ItemDefinitionSeed[] { + return [{ ...base, ...patch }]; +} + +describe('the shipped catalog', () => { + it('validates', () => { + expect(() => assertCatalog(ITEM_CATALOG)).not.toThrow(); + }); + + it('covers every category the v1 scope ships', () => { + const categories = new Set(ITEM_CATALOG.map((item) => item.category)); + expect(categories).toEqual(new Set(['equipment', 'consumable', 'collectible', 'material'])); + }); + + it('covers all three equip slots, so no slot is defined but unreachable', () => { + const slots = new Set(ITEM_CATALOG.filter((i) => i.slot).map((i) => SLOT[i.slot!])); + expect(slots).toEqual(new Set([SLOT.weapon, SLOT.armor, SLOT.trinket])); + }); +}); + +describe('assertCatalog', () => { + it('rejects two items sharing a token id, which would give one on-chain item two definitions', () => { + expect(() => assertCatalog([EQUIPMENT, { ...CONSUMABLE, itemType: EQUIPMENT.itemType }])).toThrow( + /duplicate item type/, + ); + }); + + it('rejects two items sharing a key, which makes seed data ambiguous', () => { + expect(() => assertCatalog([EQUIPMENT, { ...CONSUMABLE, key: EQUIPMENT.key }])).toThrow(/duplicate item key/); + }); + + it('rejects token type 0, ItemCore’s empty-slot sentinel', () => { + expect(() => assertCatalog(variant(EQUIPMENT, { itemType: '0' }))).toThrow(/positive decimal string/); + }); + + it('rejects equipment with no slot', () => { + expect(() => assertCatalog(variant(EQUIPMENT, { slot: undefined }))).toThrow(/needs a slot/); + }); + + it('rejects a slot on anything that is not equipment', () => { + expect(() => assertCatalog(variant(CONSUMABLE, { slot: 'weapon' }))).toThrow(/only equipment/); + }); + + it('rejects equipment carrying a consumable effect', () => { + expect(() => + assertCatalog(variant(EQUIPMENT, { effect: { kind: 'grant_xp', amount: 10 } })), + ).toThrow(/needs a stat_bonus/); + }); + + it('rejects a consumable carrying a stat bonus', () => { + expect(() => + assertCatalog( + variant(CONSUMABLE, { effect: { kind: 'stat_bonus', hp: 1, atk: 0, def: 0, int: 0, mdef: 0 } }), + ), + ).toThrow(/equipment effect/); + }); + + it('rejects an inert collectible that carries an effect', () => { + expect(() => + assertCatalog([ + { + itemType: '200', + key: 'test_badge', + category: 'collectible', + rarity: 2, + effect: { kind: 'grant_xp', amount: 10 }, + name: 'Test Badge', + description: 'For tests.', + }, + ]), + ).toThrow(/must not carry an effect/); + }); + + // Negative modifiers are excluded because the engine truncates to 16 bits with + // wraparound rather than clamping, so one underflow produces a 65,000 HP pet. + it('rejects a negative stat bonus', () => { + expect(() => + assertCatalog(variant(EQUIPMENT, { effect: { kind: 'stat_bonus', hp: 0, atk: -5, def: 0, int: 0, mdef: 0 } })), + ).toThrow(/must be an integer 0-/); + }); + + it('rejects a stat bonus past the sanity bound', () => { + expect(() => + assertCatalog( + variant(EQUIPMENT, { effect: { kind: 'stat_bonus', hp: 0, atk: 5000, def: 0, int: 0, mdef: 0 } }), + ), + ).toThrow(/must be an integer 0-/); + }); + + it('rejects a stat bonus that grants nothing, which would be a cosmetic', () => { + expect(() => + assertCatalog(variant(EQUIPMENT, { effect: { kind: 'stat_bonus', hp: 0, atk: 0, def: 0, int: 0, mdef: 0 } })), + ).toThrow(/grants nothing/); + }); + + it('rejects a rarity outside the five shared tiers', () => { + expect(() => assertCatalog(variant(EQUIPMENT, { rarity: 6 }))).toThrow(/rarity must be 1-5/); + }); +}); From fac9c0f567864dea027e07f1e93e42d3d41e2d5c Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 16:53:05 -0400 Subject: [PATCH 09/56] feat(inventory): add the inventory read service --- backend/src/features/inventory/catalog.ts | 43 +++++ backend/src/features/inventory/index.ts | 25 +++ .../features/inventory/inventory.service.ts | 159 +++++++++++++++++ backend/src/graphql/schema.ts | 42 +++++ .../src/repositories/inventory.repository.ts | 90 ++++++++++ .../inventory/inventory.service.test.ts | 161 ++++++++++++++++++ 6 files changed, 520 insertions(+) create mode 100644 backend/src/features/inventory/index.ts create mode 100644 backend/src/features/inventory/inventory.service.ts create mode 100644 backend/src/repositories/inventory.repository.ts create mode 100644 backend/tests/features/inventory/inventory.service.test.ts diff --git a/backend/src/features/inventory/catalog.ts b/backend/src/features/inventory/catalog.ts index 0372bb14..2b0e3e39 100644 --- a/backend/src/features/inventory/catalog.ts +++ b/backend/src/features/inventory/catalog.ts @@ -156,6 +156,49 @@ function assertItem(item: ItemDefinitionSeed): void { } } +/** + * Reads an effect back off a stored `item_definition.effect` column. + * + * Lenient where `assertCatalog` is strict, and the split is deliberate. This runs on a + * read path, where a single unrecognised row should cost that item its effect rather + * than fail a player's whole inventory. Authoring is where a malformed effect gets + * rejected, and the seeder is the only writer. + * + * Not the path a ruleset hash may use later (§4 phase 4): once an effect feeds combat, + * an unreadable one has to be a hard error, because silently dropping it would change a + * fight rather than a label. + */ +export function asItemEffect(value: unknown): ItemEffect | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + const record = value as Record; + + switch (record.kind) { + case 'stat_bonus': { + const fields = ['hp', 'atk', 'def', 'int', 'mdef'] as const; + if (fields.some((f) => !Number.isInteger(record[f]) || (record[f] as number) < 0)) { + return null; + } + const bonus: StatBonus = { kind: 'stat_bonus', hp: 0, atk: 0, def: 0, int: 0, mdef: 0 }; + for (const field of fields) { + bonus[field] = record[field] as number; + } + return bonus; + } + case 'grant_xp': + return Number.isInteger(record.amount) && (record.amount as number) > 0 + ? { kind: 'grant_xp', amount: record.amount as number } + : null; + case 'clear_battle_cooldown': + return { kind: 'clear_battle_cooldown' }; + case 'clear_breed_cooldown': + return { kind: 'clear_breed_cooldown' }; + default: + return null; + } +} + function assertStatBonus(effect: StatBonus, label: string): void { for (const field of ['hp', 'atk', 'def', 'int', 'mdef'] as const) { const value = effect[field]; diff --git a/backend/src/features/inventory/index.ts b/backend/src/features/inventory/index.ts new file mode 100644 index 00000000..f38c0ef9 --- /dev/null +++ b/backend/src/features/inventory/index.ts @@ -0,0 +1,25 @@ +/** + * Public surface of the inventory feature (roadmap §4). External code imports from + * `@features/inventory` so the internal layout can change without touching call sites. + */ +export { + getCatalog, + getEquipmentForPets, + getInventory, + getPetEquipment, + type EquippedItem, + type InventoryEntry, + type ItemView, +} from './inventory.service'; +export { + assertCatalog, + asItemEffect, + ITEM_CATEGORIES, + SLOT, + type ItemCategory, + type ItemDefinitionSeed, + type ItemEffect, + type SlotName, + type StatBonus, +} from './catalog'; +export { ITEM_CATALOG } from './catalog.data'; diff --git a/backend/src/features/inventory/inventory.service.ts b/backend/src/features/inventory/inventory.service.ts new file mode 100644 index 00000000..8c3b4c9d --- /dev/null +++ b/backend/src/features/inventory/inventory.service.ts @@ -0,0 +1,159 @@ +import { normalizeAccount } from '@cryptopets/protocol'; + +import { + findAllDefinitions, + findBalances, + findDefinitions, + findEquipment, + findEquipmentForPets, + type ItemDefinitionRow, +} from '@repositories/inventory.repository'; + +import { asItemEffect, type ItemEffect } from './catalog'; + +/** + * Inventory reads (roadmap §4). + * + * Every read joins a projection to the catalog, because neither half is useful alone: the + * projection knows a wallet holds three of type 100 and nothing about what that is, and + * the catalog knows what type 100 is and nothing about who holds it. The join happens here + * rather than in SQL so the two tables' different owners stay visible, and because the + * catalog is small enough that fetching the rows a page needs is one indexed lookup. + * + * An item held but missing from the catalog is dropped rather than surfaced as an unnamed + * row. That state means either a mint of a type nobody defined, or a catalog seeded behind + * the contract, and both are operational faults where a blank tile in a player's bag is the + * worst way to find out. It is logged instead. + */ + +/** One catalog entry as the API presents it. */ +export interface ItemView { + itemType: string; + key: string; + category: string; + /** Equip slot 0-2, null unless this is equipment. */ + slot: number | null; + rarity: number; + effect: ItemEffect | null; + name: string; + description: string; +} + +/** One stack in a wallet. */ +export interface InventoryEntry { + item: ItemView; + /** Serialized as a string: a uint256 balance does not fit a JS number. */ + quantity: string; +} + +/** One filled equip slot. */ +export interface EquippedItem { + slot: number; + item: ItemView; +} + +export async function getCatalog(): Promise { + return (await findAllDefinitions()).map(toItemView); +} + +/** + * A wallet's items, newest catalog data joined onto live balances. + * + * The owner is normalized here rather than at the call site, because it is a lookup key + * against rows indexer-go wrote lowercased, and an unnormalized spelling would silently + * return an empty bag rather than an error. + */ +export async function getInventory(chain: string, owner: string): Promise { + const balances = await findBalances(chain, normalizeAccount(owner)); + if (balances.length === 0) { + return []; + } + + const catalog = await definitionsByType(balances.map((b) => b.itemType)); + const entries: InventoryEntry[] = []; + for (const balance of balances) { + const definition = catalog.get(balance.itemType); + if (!definition) { + console.warn(`[inventory] held item type ${balance.itemType} is not in the catalog; hidden from ${owner}`); + continue; + } + entries.push({ item: definition, quantity: balance.quantity.toString() }); + } + return entries; +} + +/** What one pet has equipped, empty slots omitted. */ +export async function getPetEquipment(chain: string, petId: string): Promise { + const slots = await findEquipment(chain, petId); + if (slots.length === 0) { + return []; + } + + const catalog = await definitionsByType(slots.map((s) => s.itemType)); + const equipped: EquippedItem[] = []; + for (const slot of slots) { + const definition = catalog.get(slot.itemType); + if (!definition) { + console.warn(`[inventory] pet ${petId} has uncatalogued item type ${slot.itemType} in slot ${slot.slot}`); + continue; + } + equipped.push({ slot: slot.slot, item: definition }); + } + return equipped; +} + +/** + * Equipment for several pets at once, keyed by pet id. + * + * One query and one catalog fetch for the whole set: a pet list rendering gear per row is + * the shape that turns into an N+1 the moment it is written the obvious way. + */ +export async function getEquipmentForPets(chain: string, petIds: string[]): Promise> { + const byPet = new Map(); + const rows = await findEquipmentForPets(chain, petIds); + if (rows.length === 0) { + return byPet; + } + + const catalog = await definitionsByType(rows.map((r) => r.itemType)); + for (const row of rows) { + const definition = catalog.get(row.itemType); + if (!definition) { + continue; + } + const existing = byPet.get(row.petId); + const entry = { slot: row.slot, item: definition }; + if (existing) { + existing.push(entry); + } else { + byPet.set(row.petId, [entry]); + } + } + return byPet; +} + +async function definitionsByType(itemTypes: string[]): Promise> { + const unique = [...new Set(itemTypes)]; + const rows = await findDefinitions(unique); + return new Map(rows.map((row) => [row.itemType, toItemView(row)])); +} + +function toItemView(row: ItemDefinitionRow): ItemView { + const effect = asItemEffect(row.effect); + if (row.effect !== null && effect === null) { + // Readable but unrecognised: the item still renders, without whatever it does. + // Loud because the only writer is the seeder, so this means the stored shape and + // the code that reads it have diverged. + console.warn(`[inventory] item ${row.itemType} (${row.key}) has an unreadable effect payload`); + } + return { + itemType: row.itemType, + key: row.key, + category: row.category, + slot: row.slot, + rarity: row.rarity, + effect, + name: row.name, + description: row.description, + }; +} diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index c408c050..28150359 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -120,6 +120,48 @@ export const schema = buildSchema(` samples: Int! } + """ + One item type's content (roadmap §4): what it is called, how rare it is, what it does. + + Content, not ownership. The same definition is returned for a stack in a bag and for a + sword bolted to a pet, because the item type is what both of those name. + """ + type ItemDefinition { + "ERC-1155 token id as a decimal string." + itemType: String! + "Stable content key, e.g. 'xp_potion_i'. Survives a redeploy that renumbers ids." + key: String! + "'consumable' | 'equipment' | 'collectible' | 'material'." + category: String! + "Equip slot 0-2 (weapon/armor/trinket); null unless this is equipment." + slot: Int + "1-5, the same scale as pet rarity." + rarity: Int! + """ + Effect payload as JSON, or null for an inert item. Serialized as a string rather + than typed per variant: the shapes differ by category, and a union here would have + to be rebuilt every time a new effect kind lands, for a value the client only + renders. + """ + effect: String + name: String! + description: String! + } + + "One stack a wallet holds." + type InventoryEntry { + item: ItemDefinition! + "Quantity as a decimal string, since a uint256 balance does not fit a JS number." + quantity: String! + } + + "One filled equip slot on a pet." + type EquippedItem { + "0 = weapon, 1 = armor, 2 = trinket." + slot: Int! + item: ItemDefinition! + } + type Query { opponents( chain: String! diff --git a/backend/src/repositories/inventory.repository.ts b/backend/src/repositories/inventory.repository.ts new file mode 100644 index 00000000..617dad83 --- /dev/null +++ b/backend/src/repositories/inventory.repository.ts @@ -0,0 +1,90 @@ +import { prisma } from '@config/prisma'; + +/** + * Queries over the inventory tables (roadmap §4). + * + * Three tables with two owners, and the queries keep that split visible: `item_definition` + * is backend content, while `item_roster` and `pet_equipment` are projections indexer-go + * writes. Nothing here writes to either projection, because a second writer would defeat + * the version guard that makes them idempotent. + */ + +export interface ItemDefinitionRow { + itemType: string; + key: string; + category: string; + slot: number | null; + rarity: number; + effect: unknown; + name: string; + description: string; +} + +export interface ItemBalanceRow { + itemType: string; + quantity: bigint; +} + +export interface EquipmentSlotRow { + slot: number; + itemType: string; +} + +/** The whole catalog, ordered by token id so a page reads in the banded order it was authored in. */ +export function findAllDefinitions(): Promise { + return prisma.itemDefinition.findMany({ orderBy: { itemType: 'asc' } }); +} + +export function findDefinitions(itemTypes: string[]): Promise { + if (itemTypes.length === 0) { + return Promise.resolve([]); + } + return prisma.itemDefinition.findMany({ where: { itemType: { in: itemTypes } } }); +} + +export function findDefinitionByType(itemType: string): Promise { + return prisma.itemDefinition.findUnique({ where: { itemType } }); +} + +/** + * One wallet's balances. + * + * Zero-quantity rows are filtered out here rather than deleted upstream. The projection + * has to keep them (a deletion is invisible to the watermark read that produced it), so + * "spent to nothing" is a value in the table and an absence in the API. + */ +export function findBalances(chain: string, owner: string): Promise { + return prisma.itemRoster.findMany({ + where: { chain, owner, quantity: { gt: 0 } }, + select: { itemType: true, quantity: true }, + orderBy: { itemType: 'asc' }, + }); +} + +export function findBalance(chain: string, owner: string, itemType: string): Promise { + return prisma.itemRoster.findUnique({ + where: { chain_owner_itemType: { chain, owner, itemType } }, + select: { itemType: true, quantity: true }, + }); +} + +/** One pet's filled slots. Item type "0" means empty, so those are dropped. */ +export function findEquipment(chain: string, petId: string): Promise { + return prisma.petEquipment.findMany({ + where: { chain, petId, itemType: { not: '0' } }, + select: { slot: true, itemType: true }, + orderBy: { slot: 'asc' }, + }); +} + +/** Every filled slot across several pets, for a list view that would otherwise N+1. */ +export function findEquipmentForPets(chain: string, petIds: string[]): Promise<(EquipmentSlotRow & { petId: string })[]> { + if (petIds.length === 0) { + return Promise.resolve([]); + } + return prisma.petEquipment.findMany({ + where: { chain, petId: { in: petIds }, itemType: { not: '0' } }, + select: { petId: true, slot: true, itemType: true }, + orderBy: [{ petId: 'asc' }, { slot: 'asc' }], + }); +} diff --git a/backend/tests/features/inventory/inventory.service.test.ts b/backend/tests/features/inventory/inventory.service.test.ts new file mode 100644 index 00000000..e1690ff7 --- /dev/null +++ b/backend/tests/features/inventory/inventory.service.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const repo = { + findAllDefinitions: vi.fn(), + findBalances: vi.fn(), + findDefinitions: vi.fn(), + findEquipment: vi.fn(), + findEquipmentForPets: vi.fn(), +}; + +vi.mock('@repositories/inventory.repository', () => ({ + findAllDefinitions: () => repo.findAllDefinitions(), + findBalances: (chain: string, owner: string) => repo.findBalances(chain, owner), + findDefinitions: (itemTypes: string[]) => repo.findDefinitions(itemTypes), + findEquipment: (chain: string, petId: string) => repo.findEquipment(chain, petId), + findEquipmentForPets: (chain: string, petIds: string[]) => repo.findEquipmentForPets(chain, petIds), +})); + +import { getEquipmentForPets, getInventory, getPetEquipment } from '@features/inventory'; + +const POTION = { + itemType: '100', + key: 'xp_potion_i', + category: 'consumable', + slot: null, + rarity: 1, + effect: { kind: 'grant_xp', amount: 50 }, + name: 'Lesser Tonic', + description: 'Tastes of copper.', +}; + +const BLADE = { + itemType: '1', + key: 'iron_fang', + category: 'equipment', + slot: 0, + rarity: 1, + effect: { kind: 'stat_bonus', hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + name: 'Iron Fang', + description: 'A blunt starter blade.', +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +describe('getInventory', () => { + it('joins balances onto the catalog', async () => { + repo.findBalances.mockResolvedValue([{ itemType: '100', quantity: 3n }]); + repo.findDefinitions.mockResolvedValue([POTION]); + + const entries = await getInventory('evm', '0xABC'); + + expect(entries).toEqual([ + { item: expect.objectContaining({ key: 'xp_potion_i', effect: { kind: 'grant_xp', amount: 50 } }), quantity: '3' }, + ]); + }); + + // The rows indexer-go writes are lowercased, so an unnormalized lookup key would return + // an empty bag rather than an error, which reads as "you own nothing". + it('folds an EVM owner to lowercase before looking anything up', async () => { + repo.findBalances.mockResolvedValue([]); + await getInventory('evm', '0xAbC0000000000000000000000000000000000DEF'); + expect(repo.findBalances).toHaveBeenCalledWith('evm', '0xabc0000000000000000000000000000000000def'); + }); + + // Case-folding base58 would merge two distinct Solana pubkeys into one player, so + // normalization deliberately only applies to a full 20-byte EVM address. + it('leaves a Solana pubkey unfolded', async () => { + repo.findBalances.mockResolvedValue([]); + await getInventory('solana', 'So11111111111111111111111111111111111111112'); + expect(repo.findBalances).toHaveBeenCalledWith('solana', 'So11111111111111111111111111111111111111112'); + }); + + // A uint256 balance does not fit a JS number, so it has to leave as a string. + it('serializes the quantity as a string', async () => { + repo.findBalances.mockResolvedValue([{ itemType: '100', quantity: 9007199254740993n }]); + repo.findDefinitions.mockResolvedValue([POTION]); + + const entries = await getInventory('evm', '0xabc'); + + expect(entries[0]!.quantity).toBe('9007199254740993'); + }); + + it('hides a held item that is not in the catalog rather than showing a blank tile', async () => { + repo.findBalances.mockResolvedValue([ + { itemType: '100', quantity: 1n }, + { itemType: '999', quantity: 5n }, + ]); + repo.findDefinitions.mockResolvedValue([POTION]); + + const entries = await getInventory('evm', '0xabc'); + + expect(entries).toHaveLength(1); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('999')); + }); + + it('skips the catalog fetch entirely for an empty bag', async () => { + repo.findBalances.mockResolvedValue([]); + expect(await getInventory('evm', '0xabc')).toEqual([]); + expect(repo.findDefinitions).not.toHaveBeenCalled(); + }); + + // An unreadable payload costs that item its effect, not the whole page: this is a read + // path, and the only writer is the seeder, so it means stored shape and reader diverged. + it('keeps an item whose effect payload no longer parses, minus the effect', async () => { + repo.findBalances.mockResolvedValue([{ itemType: '100', quantity: 1n }]); + repo.findDefinitions.mockResolvedValue([{ ...POTION, effect: { kind: 'teleport' } }]); + + const entries = await getInventory('evm', '0xabc'); + + expect(entries).toHaveLength(1); + expect(entries[0]!.item.effect).toBeNull(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('unreadable effect')); + }); +}); + +describe('getPetEquipment', () => { + it('returns filled slots joined to the catalog', async () => { + repo.findEquipment.mockResolvedValue([{ slot: 0, itemType: '1' }]); + repo.findDefinitions.mockResolvedValue([BLADE]); + + expect(await getPetEquipment('evm', '7')).toEqual([ + { slot: 0, item: expect.objectContaining({ key: 'iron_fang' }) }, + ]); + }); + + it('returns nothing for a pet with no gear', async () => { + repo.findEquipment.mockResolvedValue([]); + expect(await getPetEquipment('evm', '7')).toEqual([]); + expect(repo.findDefinitions).not.toHaveBeenCalled(); + }); +}); + +describe('getEquipmentForPets', () => { + // One query and one catalog fetch for the whole set: a pet list rendering gear per row + // is exactly the shape that becomes an N+1 when written the obvious way. + it('groups by pet without a query per pet', async () => { + repo.findEquipmentForPets.mockResolvedValue([ + { petId: '7', slot: 0, itemType: '1' }, + { petId: '7', slot: 1, itemType: '1' }, + { petId: '8', slot: 0, itemType: '1' }, + ]); + repo.findDefinitions.mockResolvedValue([BLADE]); + + const byPet = await getEquipmentForPets('evm', ['7', '8']); + + expect(byPet.get('7')).toHaveLength(2); + expect(byPet.get('8')).toHaveLength(1); + expect(repo.findEquipmentForPets).toHaveBeenCalledTimes(1); + expect(repo.findDefinitions).toHaveBeenCalledTimes(1); + // Deduplicated: three rows share one item type, so the catalog is asked once for it. + expect(repo.findDefinitions).toHaveBeenCalledWith(['1']); + }); + + it('returns an empty map when no pet has gear', async () => { + repo.findEquipmentForPets.mockResolvedValue([]); + expect((await getEquipmentForPets('evm', ['7'])).size).toBe(0); + }); +}); From 8d094b8ee0e8d5d7f8119756118d06b05b53e07d Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 17:05:39 -0400 Subject: [PATCH 10/56] feat(inventory): expose the inventory reads over GraphQL --- backend/API.md | 53 +++++++++++++++++ backend/src/graphql/resolvers.ts | 49 ++++++++++++++++ backend/src/graphql/schema.ts | 29 ++++++++++ backend/tests/graphql/resolvers.test.ts | 76 +++++++++++++++++++++++++ backend/tests/graphql/schema.test.ts | 13 ++++- 5 files changed, 217 insertions(+), 3 deletions(-) diff --git a/backend/API.md b/backend/API.md index d10bbd46..8be1a36a 100644 --- a/backend/API.md +++ b/backend/API.md @@ -261,6 +261,59 @@ Neither board has a gRPC fast path, for the same reason `opponents` lost its own indexer-go's cache holds chain state and has no view of `pet_battle_progress`, a backend-owned table, so it cannot answer these correctly. Both read Postgres directly. +### Inventory (roadmap §4) + +```graphql +query($chain: String!, $petId: String!) { + itemCatalog { itemType key category slot rarity effect name description } + inventory(chain: $chain) { + item { itemType key category slot rarity effect name description } + quantity + } + petEquipment(chain: $chain, petId: $petId) { + slot + item { itemType key category slot rarity effect name description } + } +} +``` + +Three read-only joins of an indexer-written projection onto the backend-owned catalog. +`item_roster` and `pet_equipment` are written **only** by indexer-go from the `ItemCore` +subgraph, under the same monotonic `last_version` guard `pet_roster` uses; +`item_definition` is content the catalog seeder writes. + +| Field | Type | Notes | +| --- | --- | --- | +| `itemType` | String | ERC-1155 token id as a decimal string. The join key everywhere, including the battle snapshot | +| `key` | String | Stable content key (`xp_potion_i`). Survives a redeploy that renumbers token ids | +| `category` | String | `consumable` \| `equipment` \| `collectible` \| `material`. No cosmetics in v1 | +| `slot` | Int | 0 = weapon, 1 = armor, 2 = trinket; `null` unless equipment | +| `rarity` | Int | 1-5, the same five tiers as pet rarity, not a second scale | +| `effect` | String | Effect payload as a JSON string, `null` for an inert item. A string rather than a typed union: the shape differs per category and gains a variant per effect kind, for a value the client only renders | +| `quantity` | String | Decimal string — a uint256 balance does not fit a JS number | + +`inventory` takes **no owner argument**: whose bag it is comes from the session, so there +is no spelling of the query that reads another wallet's items. An unauthenticated caller +gets an empty list rather than an error, matching `playerRank`'s treatment of no standing. +Stacks spent to nothing are omitted — the projection has to keep a zero row, because a +deletion would be invisible to the watermark read that produced it, but a player has no +reason to see one. + +`petEquipment` is public, unlike `inventory`. Gear changes a pet's stats in a battle +anyone can be matched into, so hiding it from an opponent would make the fight less +checkable without making it more private. Empty slots (item type `"0"` in the table) are +omitted. + +An item held but absent from the catalog is **hidden and logged**, not returned unnamed. +That state means a mint of an undefined type or a catalog seeded behind the contract, and +a blank tile in a player's bag is the worst way to discover either. `itemCatalog` reads +the database rather than the shipped source file, so a rebalance is a row edit rather than +a redeploy; an unseeded deployment therefore returns an empty catalog, which is the honest +answer. + +Like the leaderboards, none of these have a gRPC fast path: indexer-go's cache holds pet +state only and has no view of these tables. + ### Battle data `battle_history` carries `loserPetId, seed (0x-hex), rounds, winnerHpRemaining, diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 1f5bace3..461a7b69 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -6,6 +6,7 @@ import { findPlayerRank, } from '@repositories/leaderboard.repository'; import { tryGrpcEstimateWin } from '@grpc-client/estimateWin'; +import { getCatalog, getInventory, getPetEquipment, type ItemView } from '@features/inventory'; import { isSupportedChain, SUPPORTED_CHAINS } from '@typings/chain'; const DEFAULT_PAGE_SIZE = 20; @@ -58,6 +59,11 @@ interface AllPetsArgs { limit?: number | null; } +interface PetEquipmentArgs { + chain: string; + petId: string; +} + export interface GraphQLContext { /** Authenticated wallet address; empty string when unauthenticated. */ caller: string; @@ -69,6 +75,19 @@ export interface GraphQLContext { * Shared by the opponents list and the single-pet detail read so both stay in * lockstep. */ +/** + * Project an `ItemView` to the GraphQL `ItemDefinition` shape. + * + * The effect is serialized to a JSON string rather than exposed as a typed union. The + * payload shape differs per category and gains a variant each time a new effect kind + * lands, so a union would need a schema change for a value the client only ever renders. + * Null stays null, so "inert item" and "unreadable payload" both read as absence, which + * is what they mean to a client either way. + */ +function toItemDefinition(item: ItemView) { + return { ...item, effect: item.effect ? JSON.stringify(item.effect) : null }; +} + function toOpponentPet({ petId: id, readyAt, breedReadyAt, trainReadyAt, ...rest }: RosterPet) { return { id, @@ -222,4 +241,34 @@ export const rootValue = { }), }); }, + + itemCatalog: async () => (await getCatalog()).map(toItemDefinition), + + inventory: async (args: { chain: string }, context: GraphQLContext) => { + if (!isSupportedChain(args.chain)) { + throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); + } + + // An unauthenticated caller owns nothing rather than erroring, matching how + // `playerRank` treats having no standing. The address is never an argument, so + // there is no spelling of this query that reads someone else's bag. + if (!context.caller) { + return []; + } + return (await getInventory(args.chain, context.caller)).map((entry) => ({ + item: toItemDefinition(entry.item), + quantity: entry.quantity, + })); + }, + + petEquipment: async (args: PetEquipmentArgs) => { + if (!isSupportedChain(args.chain)) { + throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); + } + + return (await getPetEquipment(args.chain, args.petId)).map((equipped) => ({ + slot: equipped.slot, + item: toItemDefinition(equipped.item), + })); + }, }; diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 28150359..7be6368f 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -256,5 +256,34 @@ export const schema = buildSchema(` "Seeds to sample; omit to let the server choose." samples: Int ): WinEstimate + + """ + The whole item catalog (roadmap §4), ordered by token id. + + Read from the database rather than from the shipped source file, so a rebalance is + a row edit rather than a redeploy. That means an unseeded deployment returns an + empty catalog, which is the honest answer: no items are defined on it. + """ + itemCatalog: [ItemDefinition!]! + + """ + The authenticated caller's own items. + + The owner comes from the session and is never an argument, so this cannot be used + to read another wallet's bag. Stacks spent to nothing are omitted rather than + returned as zero: the projection has to keep a zero row, because a deletion would + be invisible to the watermark read that produced it, but a player has no reason to + see one. + """ + inventory(chain: String!): [InventoryEntry!]! + + """ + What a pet has equipped. Empty slots are omitted. + + Public, unlike the inventory read: gear changes a pet's stats in a battle anyone + can be matched into, so hiding it from an opponent would make the fight less + checkable without making it more private. + """ + petEquipment(chain: String!, petId: String!): [EquippedItem!]! } `); diff --git a/backend/tests/graphql/resolvers.test.ts b/backend/tests/graphql/resolvers.test.ts index b7481cc1..1048eb24 100644 --- a/backend/tests/graphql/resolvers.test.ts +++ b/backend/tests/graphql/resolvers.test.ts @@ -18,6 +18,13 @@ vi.mock('@repositories/battleProgress.overlay', () => ({ withBattleProgress: vi.fn(async (_chain: unknown, pets: unknown[]) => pets), findBattleProgress: vi.fn(async () => []), })); +// The service's own join rule is covered in features/inventory/inventory.service.test.ts; +// stubbed here so these tests stay about resolver shaping and the session-owner rule. +vi.mock('@features/inventory', () => ({ + getCatalog: vi.fn(), + getInventory: vi.fn(), + getPetEquipment: vi.fn(), +})); import { rootValue } from '../../src/graphql/resolvers'; import { findReadyOpponents, getPetById } from '@repositories/roster.repository'; @@ -27,6 +34,7 @@ import { findPlayerRank, } from '@repositories/leaderboard.repository'; import { tryGrpcEstimateWin } from '../../src/grpc/estimateWin'; +import { getCatalog, getInventory, getPetEquipment } from '@features/inventory'; const ctx = { caller: '0xcaller' }; @@ -188,3 +196,71 @@ describe('winEstimate resolver', () => { await expect(rootValue.winEstimate({ chain: 'tron', petId1: 'p1', petId2: 'p2' })).rejects.toThrow('chain must be one of'); }); }); + +// ─── inventory (roadmap §4) ────────────────────────────────────────────────── + +const POTION = { + itemType: '100', + key: 'xp_potion_i', + category: 'consumable', + slot: null, + rarity: 1, + effect: { kind: 'grant_xp' as const, amount: 50 }, + name: 'Lesser Tonic', + description: 'Tastes of copper.', +}; + +const INERT = { ...POTION, itemType: '200', key: 'crate_key', category: 'collectible', effect: null }; + +describe('itemCatalog', () => { + it('serializes the effect payload to a JSON string', async () => { + vi.mocked(getCatalog).mockResolvedValue([POTION]); + + const [entry] = await rootValue.itemCatalog(); + + expect(entry).toMatchObject({ itemType: '100', key: 'xp_potion_i' }); + expect(JSON.parse(entry!.effect!)).toEqual({ kind: 'grant_xp', amount: 50 }); + }); + + it('leaves an inert item’s effect null rather than the string "null"', async () => { + vi.mocked(getCatalog).mockResolvedValue([INERT]); + expect((await rootValue.itemCatalog())[0]!.effect).toBeNull(); + }); +}); + +describe('inventory', () => { + it('reads the owner from the session, not from an argument', async () => { + vi.mocked(getInventory).mockResolvedValue([{ item: POTION, quantity: '3' }]); + + const entries = await rootValue.inventory({ chain: 'evm' }, ctx); + + expect(getInventory).toHaveBeenCalledWith('evm', '0xcaller'); + expect(entries).toEqual([{ item: expect.objectContaining({ key: 'xp_potion_i' }), quantity: '3' }]); + }); + + // "Owns nothing" rather than an error, matching how playerRank treats no standing. + it('returns an empty bag for an unauthenticated caller without querying', async () => { + expect(await rootValue.inventory({ chain: 'evm' }, { caller: '' })).toEqual([]); + expect(getInventory).not.toHaveBeenCalled(); + }); + + it('rejects an unsupported chain', async () => { + await expect(rootValue.inventory({ chain: 'dogecoin' }, ctx)).rejects.toThrow(/chain must be one of/); + }); +}); + +describe('petEquipment', () => { + it('returns filled slots with their definitions', async () => { + vi.mocked(getPetEquipment).mockResolvedValue([{ slot: 0, item: POTION }]); + + expect(await rootValue.petEquipment({ chain: 'evm', petId: '7' }, ctx)).toEqual([ + { slot: 0, item: expect.objectContaining({ key: 'xp_potion_i' }) }, + ]); + }); + + it('rejects an unsupported chain', async () => { + await expect(rootValue.petEquipment({ chain: 'dogecoin', petId: '7' }, ctx)).rejects.toThrow( + /chain must be one of/, + ); + }); +}); diff --git a/backend/tests/graphql/schema.test.ts b/backend/tests/graphql/schema.test.ts index 5230fb7c..58b88f28 100644 --- a/backend/tests/graphql/schema.test.ts +++ b/backend/tests/graphql/schema.test.ts @@ -21,13 +21,20 @@ function fieldsOf(typeName: string): Record { describe('GraphQL schema — Query surface', () => { const query = fieldsOf('Query'); - it('exposes the pet reads, both leaderboards, battleProgress, and winEstimate', () => { + it('exposes the pet reads, both leaderboards, battleProgress, winEstimate, and the inventory reads', () => { expect(Object.keys(query).sort()).toEqual([ - 'allPets', 'battleProgress', 'leaderboard', 'opponents', 'pet', 'playerLeaderboard', - 'playerRank', 'searchPets', 'winEstimate', + 'allPets', 'battleProgress', 'inventory', 'itemCatalog', 'leaderboard', 'opponents', + 'pet', 'petEquipment', 'playerLeaderboard', 'playerRank', 'searchPets', 'winEstimate', ]); }); + // The owner is the session, never an argument. A chain-only signature is what makes + // "read someone else's bag" unspellable rather than merely unauthorized. + it('takes no owner argument on inventory', () => { + const inventory = (schema.getType('Query') as GraphQLObjectType).getFields().inventory; + expect(inventory!.args.map((a) => a.name)).toEqual(['chain']); + }); + it('returns a non-null list of PetBattleProgress from battleProgress', () => { // Non-null list, but pets without a backend record are simply absent from it — // absence is the signal that chain state is the whole truth for that pet. From 5499ceb5d1916939f1b48802350ea93c7d20fd3b Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 17:18:52 -0400 Subject: [PATCH 11/56] feat(inventory): add the item write surface --- backend/API.md | 37 +++ backend/env.example | 26 ++ backend/scripts/seed-item-catalog.ts | 14 +- backend/src/app.ts | 2 + backend/src/config/env.ts | 40 +++ .../src/features/inventory/catalog.data.ts | 11 +- backend/src/features/inventory/catalog.ts | 13 +- backend/src/features/inventory/index.ts | 12 + .../src/features/inventory/inventory.chain.ts | 154 ++++++++++ .../inventory/inventory.controller.ts | 136 +++++++++ .../features/inventory/inventory.schema.ts | 39 +++ .../src/features/inventory/inventory.write.ts | 274 ++++++++++++++++++ backend/src/middleware/rateLimit.ts | 15 + backend/src/routes/inventory.ts | 23 ++ .../inventory/inventory.write.test.ts | 241 +++++++++++++++ 15 files changed, 1019 insertions(+), 18 deletions(-) create mode 100644 backend/src/features/inventory/inventory.chain.ts create mode 100644 backend/src/features/inventory/inventory.controller.ts create mode 100644 backend/src/features/inventory/inventory.schema.ts create mode 100644 backend/src/features/inventory/inventory.write.ts create mode 100644 backend/src/routes/inventory.ts create mode 100644 backend/tests/features/inventory/inventory.write.test.ts diff --git a/backend/API.md b/backend/API.md index 8be1a36a..5a8d002b 100644 --- a/backend/API.md +++ b/backend/API.md @@ -314,6 +314,43 @@ answer. Like the leaderboards, none of these have a gRPC fast path: indexer-go's cache holds pet state only and has no view of these tables. +#### Inventory writes + +| Method | Path | Auth | Notes | +| --- | --- | --- | --- | +| POST | `/api/inventory/use` | JWT | Body `{ chain, petId, itemType }`. Spends one consumable on one of the caller's pets | +| POST | `/api/inventory/entitlements/:id/claim` | JWT | Mints an item the caller has earned | +| POST | `/api/inventory/admin/grant` | JWT + allowlist | Body `{ chain, owner, itemType, quantity }`. Creates an entitlement for any wallet | + +All three send a transaction from the backend's item wallet and are rate-limited per +wallet at 15/min, because each one spends gas from that key whether or not it settles. +They return **503** when `ITEM_CORE_ENABLED` is unset: writes refuse individually rather +than the feature going dark, so a missing key never hides a player's items. + +**Equipping is not here, and will not be.** `ItemCore.equip` requires `msg.sender` to be +the pet's owner, so the player's own wallet sends it from the client. That is the property +that makes gear in a battle snapshot checkable against chain state by someone who does not +trust this server, rather than an assertion by it. + +`use` burns on chain **first**, then applies the effect. The ordering is deliberate: a +burn that lands with a failed apply costs the player an item and gains them nothing, while +applying first and failing to burn would leave them the item *and* the effect, which +repeats. The failed-apply case is logged with everything needed to fix it by hand; +automating that means an outbox, worth building when volume justifies it. + +XP grants go through the combat engine's own `applyXp`, so a potion moves a pet on exactly +the curve a battle does, and a pet with no progression row is seeded from its on-chain +level the way its first battle would seed it. + +`claim` marks the row claimed **before** minting, conditioned on it still being unclaimed, +so two concurrent claims mint at most once — the loser's update matches no row and it +stops before sending. A failed mint releases the claim so it stays retryable, which is safe +because the client waits for a receipt and treats a reverted one as a failure. + +`grant` creates an entitlement rather than minting directly, so an admin grant and a battle +drop reach a bag by the same path. Its allowlist (`ITEM_ADMIN_WALLETS`) is empty by +default: the route is closed until someone is named, not open until someone is excluded. + ### Battle data `battle_history` carries `loserPetId, seed (0x-hex), rounds, winnerHpRemaining, diff --git a/backend/env.example b/backend/env.example index 42c2291b..b7d93f90 100644 --- a/backend/env.example +++ b/backend/env.example @@ -173,3 +173,29 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # 900s on-chain default as a starting value only — the two are not required to agree. # Default: 900. # BATTLE_COOLDOWN_SECONDS=900 + +# ─── Inventory (roadmap §4) ─────────────────────────────────────────────────── +# +# The wallet that mints claimed items and burns spent consumables through ItemCore. +# Off by default. All four of RPC_URL/PRIVATE_KEY/CHAIN_ID/ADDRESS are required once +# enabled; item writes stay disabled and log if any are missing, while every inventory +# READ keeps working, so a missing key never hides a player's items. +# +# The wallet must be an authorized caller on ItemCore (authorizeCaller). That is a real +# trust grant: an authorized caller can burn any wallet's items without their approval, +# which is what lets a consumable settle in one call after the player has authenticated. +# ITEM_CORE_ENABLED=true +# ITEM_CORE_RPC_URL=http://127.0.0.1:8545 +# ITEM_CORE_PRIVATE_KEY=0x... +# ITEM_CORE_CHAIN_ID=31337 +# ITEM_CORE_ADDRESS=0x... +# +# Wallets allowed to call POST /api/inventory/admin/grant, comma-separated. Empty by +# default, so the route is closed until someone is named rather than open until someone +# is excluded. Checksummed addresses are fine; they are lowercased to match the JWT. +# ITEM_ADMIN_WALLETS=0x...,0x... +# +# The catalog seeder (scripts/seed-item-catalog.ts) reuses ITEM_CORE_ADDRESS and +# ITEM_CORE_RPC_URL, but needs its own key: registerItemSlot is onlyOwner, while the +# runtime wallet above only needs authorizeCaller. +# ITEM_CORE_OWNER_PRIVATE_KEY=0x... diff --git a/backend/scripts/seed-item-catalog.ts b/backend/scripts/seed-item-catalog.ts index fbe58be4..d19624fb 100644 --- a/backend/scripts/seed-item-catalog.ts +++ b/backend/scripts/seed-item-catalog.ts @@ -13,9 +13,13 @@ * pnpm tsx scripts/seed-item-catalog.ts --dry-run # print the plan, write nothing * * Chain registration needs, in backend/.env: - * ITEM_CORE_ADDRESS=0x... the ItemCore proxy (deploy.ts prints it) + * ITEM_CORE_ADDRESS=0x... the ItemCore proxy (deploy.ts prints it) * ITEM_CORE_RPC_URL=http://... - * ITEM_CORE_PRIVATE_KEY=0x... must be ItemCore's owner; registerItemSlot is onlyOwner + * ITEM_CORE_OWNER_PRIVATE_KEY=0x... ItemCore's owner + * + * Its own key, not the runtime ITEM_CORE_PRIVATE_KEY, because the two roles differ: + * registerItemSlot is onlyOwner, while the server's wallet only needs authorizeCaller. + * Sharing one key would hand the always-on service the ability to reshape the catalog. * * Safe to re-run. Definitions upsert by token id, and registerItemSlot is idempotent for * an unchanged slot, so this is the way a catalog edit ships rather than a one-shot. @@ -99,9 +103,11 @@ async function seedDatabase(dryRun: boolean): Promise { async function registerSlots(dryRun: boolean): Promise { const address = process.env.ITEM_CORE_ADDRESS; const rpcUrl = process.env.ITEM_CORE_RPC_URL; - const privateKey = process.env.ITEM_CORE_PRIVATE_KEY; + const privateKey = process.env.ITEM_CORE_OWNER_PRIVATE_KEY; if (!address || !rpcUrl || !privateKey) { - throw new Error('--with-chain needs ITEM_CORE_ADDRESS, ITEM_CORE_RPC_URL and ITEM_CORE_PRIVATE_KEY'); + throw new Error( + '--with-chain needs ITEM_CORE_ADDRESS, ITEM_CORE_RPC_URL and ITEM_CORE_OWNER_PRIVATE_KEY', + ); } const { createPublicClient, createWalletClient, http } = await import('viem'); diff --git a/backend/src/app.ts b/backend/src/app.ts index e143d26c..80c52758 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,6 +12,7 @@ import battleRoutes from '@routes/battle'; import receiptRoutes from '@routes/receipts'; import rewardRoutes from '@routes/rewards'; import chatRoutes from '@routes/chat'; +import inventoryRoutes from '@routes/inventory'; const app = express(); @@ -40,6 +41,7 @@ app.use('/api/battle', battleRoutes); app.use('/api/receipts', receiptRoutes); app.use('/api/rewards', rewardRoutes); app.use('/api/chat', chatRoutes); +app.use('/api/inventory', inventoryRoutes); app.get('/', (_req: Request, res: Response) => { res.json({ diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 33161fc1..8e29a6d3 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -113,6 +113,46 @@ export const env = { Number(process.env.KEEPER_CHAIN_ID) === 31337, }, + /** + * Inventory (roadmap §4): the wallet that mints claimed items and burns spent + * consumables through ItemCore. + * + * Off unless ITEM_CORE_ENABLED=true, and every field below is required once it is. + * With it off, the read surface still works — a bag renders from what the indexer + * saw — and only the two writes that need a transaction refuse. That is the useful + * degradation: a missing key should not hide a player's items. + * + * The wallet must be an authorized caller on ItemCore (`authorizeCaller`), which is a + * real trust grant: an authorized caller can burn any wallet's items without that + * wallet's approval. It is what lets a consumable settle in one call after the player + * has already authenticated, and it is the reason this key belongs nowhere near a + * shared environment. + */ + inventory: { + enabled: process.env.ITEM_CORE_ENABLED?.trim().toLowerCase() === 'true', + rpcUrl: process.env.ITEM_CORE_RPC_URL?.trim() || undefined, + privateKey: (process.env.ITEM_CORE_PRIVATE_KEY?.trim() + ? (process.env.ITEM_CORE_PRIVATE_KEY.trim().startsWith('0x') + ? process.env.ITEM_CORE_PRIVATE_KEY.trim() + : `0x${process.env.ITEM_CORE_PRIVATE_KEY.trim()}`) + : undefined) as `0x${string}` | undefined, + chainId: process.env.ITEM_CORE_CHAIN_ID ? Number(process.env.ITEM_CORE_CHAIN_ID) : undefined, + address: process.env.ITEM_CORE_ADDRESS?.trim() as `0x${string}` | undefined, + /** + * Wallets allowed to grant items, comma-separated. Empty by default, so the admin + * route is closed until someone is named rather than open until someone is + * excluded. Normalized here so a checksummed address in the env still matches the + * lowercased one the JWT carries. + */ + adminWallets: new Set( + (process.env.ITEM_ADMIN_WALLETS ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => (/^0x[0-9a-fA-F]{40}$/.test(entry) ? entry.toLowerCase() : entry)), + ), + }, + /** * Backend-authoritative battles (docs/battle-protocol.md). * diff --git a/backend/src/features/inventory/catalog.data.ts b/backend/src/features/inventory/catalog.data.ts index badd529f..59fd3be2 100644 --- a/backend/src/features/inventory/catalog.data.ts +++ b/backend/src/features/inventory/catalog.data.ts @@ -5,7 +5,7 @@ import type { ItemDefinitionSeed } from './catalog'; * * Breadth is the point here rather than depth: §4 takes the wide-catalog idea from * OwoBot and the gear-matters idea from Dota, and says not to attempt Dota's scale on - * day one. Sixteen items across the four shipping categories is enough to exercise every + * day one. Fifteen items across the four shipping categories is enough to exercise every * path (a stackable, a burn, an equip, a slot conflict) without pretending to be content * design, which is a human call. * @@ -136,15 +136,6 @@ export const ITEM_CATALOG: readonly ItemDefinitionSeed[] = [ name: 'Second Wind', description: 'Clears a pet’s battle cooldown. The ache comes back later.', }, - { - itemType: '111', - key: 'fertility_charm', - category: 'consumable', - rarity: 4, - effect: { kind: 'clear_breed_cooldown' }, - name: 'Fertility Charm', - description: 'Clears a pet’s breeding cooldown.', - }, // ─── collectibles ───────────────────────────────────────────────────────── { diff --git a/backend/src/features/inventory/catalog.ts b/backend/src/features/inventory/catalog.ts index 2b0e3e39..9d41fc51 100644 --- a/backend/src/features/inventory/catalog.ts +++ b/backend/src/features/inventory/catalog.ts @@ -36,11 +36,18 @@ export interface StatBonus { mdef: number; } +/** + * Every effect v1 can actually apply. + * + * Breeding cooldowns are deliberately absent. They live in on-chain state, and clearing + * one means an authorized `PetCore.triggerBreedCooldown` call the inventory feature does + * not have and should not quietly acquire. A fertility charm is a real item to build, with + * that authorization as its first step, rather than a catalog entry that errors on use. + */ export type ItemEffect = | StatBonus | { kind: 'grant_xp'; amount: number } - | { kind: 'clear_battle_cooldown' } - | { kind: 'clear_breed_cooldown' }; + | { kind: 'clear_battle_cooldown' }; /** One catalog entry, as authored. */ export interface ItemDefinitionSeed { @@ -192,8 +199,6 @@ export function asItemEffect(value: unknown): ItemEffect | null { : null; case 'clear_battle_cooldown': return { kind: 'clear_battle_cooldown' }; - case 'clear_breed_cooldown': - return { kind: 'clear_breed_cooldown' }; default: return null; } diff --git a/backend/src/features/inventory/index.ts b/backend/src/features/inventory/index.ts index f38c0ef9..bb0e3ca0 100644 --- a/backend/src/features/inventory/index.ts +++ b/backend/src/features/inventory/index.ts @@ -23,3 +23,15 @@ export { type StatBonus, } from './catalog'; export { ITEM_CATALOG } from './catalog.data'; +export { postClaim, postGrant, postUseItem } from './inventory.controller'; +export { + claimEntitlement, + grantItem, + isAdmin, + useItem, + type ClaimResult, + type GrantResult, + type UseItemResult, + type WriteFailure, +} from './inventory.write'; +export { getItemCoreClient, resetItemCoreClient, type ItemCoreClient } from './inventory.chain'; diff --git a/backend/src/features/inventory/inventory.chain.ts b/backend/src/features/inventory/inventory.chain.ts new file mode 100644 index 00000000..0ea545ce --- /dev/null +++ b/backend/src/features/inventory/inventory.chain.ts @@ -0,0 +1,154 @@ +import { + createPublicClient, + createWalletClient, + defineChain, + http, + type Address, + type PublicClient, + type WalletClient, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { env } from '@config/env'; + +/** + * The ItemCore write client (roadmap §4): mint a claimed item, burn a spent consumable. + * + * Hand-written ABI fragments for the two functions this calls, matching the settle + * keeper's approach, so `backend` never takes a build dependency on + * `contracts/ethereum`'s compiled artifacts. + * + * Equip and unequip are deliberately absent. `ItemCore.equip` requires `msg.sender` to be + * the pet's owner, so the backend physically cannot send one; the player's wallet does, + * from the client. That is not a gap to fill later — it is the property that makes an + * equip a statement by the owner rather than by us. + */ + +const ITEM_CORE_ABI = [ + { + type: 'function', + name: 'mintTo', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'itemType', type: 'uint256' }, + { name: 'quantity', type: 'uint256' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'burnFrom', + stateMutability: 'nonpayable', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'itemType', type: 'uint256' }, + { name: 'quantity', type: 'uint256' }, + ], + outputs: [], + }, +] as const; + +export interface ItemCoreClient { + mintTo(to: string, itemType: string, quantity: number): Promise<`0x${string}`>; + burnFrom(from: string, itemType: string, quantity: number): Promise<`0x${string}`>; +} + +let cached: ItemCoreClient | null | undefined; + +/** + * The configured client, or null when inventory writes are disabled. + * + * Null rather than a throw, so a deployment without the key still serves every read. The + * two callers that need a transaction check for null and refuse individually, which keeps + * a missing key from hiding a player's items. + */ +export function getItemCoreClient(): ItemCoreClient | null { + if (cached !== undefined) { + return cached; + } + cached = buildClient(); + return cached; +} + +/** Test seam: drops the memoized client so a changed env is picked up. */ +export function resetItemCoreClient(): void { + cached = undefined; +} + +function buildClient(): ItemCoreClient | null { + const { enabled, rpcUrl, privateKey, chainId, address } = env.inventory; + if (!enabled) { + return null; + } + if (!rpcUrl || !privateKey || !chainId || !address) { + console.error( + '[inventory] ITEM_CORE_ENABLED is set but ITEM_CORE_RPC_URL/PRIVATE_KEY/CHAIN_ID/ADDRESS are not all present; item writes stay disabled', + ); + return null; + } + + // Defined from the configured id rather than looked up from viem's chain list, so a + // local Hardhat node and an unlisted testnet work the same as a known network. Only + // the id matters here: nothing in this client reads a chain's currency or explorer. + const chain = defineChain({ + id: chainId, + name: `chain-${chainId}`, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }); + + const account = privateKeyToAccount(privateKey); + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }); + const walletClient = createWalletClient({ account, chain, transport: http(rpcUrl) }); + + return { + mintTo: (to, itemType, quantity) => + send(publicClient, walletClient, address, 'mintTo', [to as Address, BigInt(itemType), BigInt(quantity)]), + burnFrom: (from, itemType, quantity) => + send(publicClient, walletClient, address, 'burnFrom', [from as Address, BigInt(itemType), BigInt(quantity)]), + }; +} + +/** + * Simulates, sends, and waits for the receipt. + * + * Simulated first so a revert surfaces as a rejected request rather than as a failed + * transaction the player has already been told succeeded. Awaited to completion because + * both callers change state that depends on the transaction having landed: a burn that is + * still pending is an item the player could spend again. + * + * One at a time, like the settle keeper's submitter. Item writes are rare relative to + * block times, so a single in-flight transaction avoids nonce management entirely. + */ +let queue: Promise = Promise.resolve(); + +async function send( + publicClient: PublicClient, + walletClient: WalletClient, + address: Address, + functionName: 'mintTo' | 'burnFrom', + args: readonly [Address, bigint, bigint], +): Promise<`0x${string}`> { + const run = async (): Promise<`0x${string}`> => { + const { request } = await publicClient.simulateContract({ + account: walletClient.account, + address, + abi: ITEM_CORE_ABI, + functionName, + args, + }); + const hash = await walletClient.writeContract(request as Parameters[0]); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') { + throw new Error(`ItemCore.${functionName} reverted on chain (${hash})`); + } + return hash; + }; + + const next = queue.then(run, run); + // Swallowed on the queue itself, not on the returned promise: a rejection here must + // not poison the next caller's turn, but it still has to reach the one who asked. + queue = next.catch(() => undefined); + return next; +} diff --git a/backend/src/features/inventory/inventory.controller.ts b/backend/src/features/inventory/inventory.controller.ts new file mode 100644 index 00000000..7cd9895c --- /dev/null +++ b/backend/src/features/inventory/inventory.controller.ts @@ -0,0 +1,136 @@ +import type { Request, Response } from 'express'; + +import type { AuthenticatedRequest } from '@middleware/auth'; + +import { GrantSchema, UseItemSchema } from './inventory.schema'; +import { claimEntitlement, grantItem, useItem, type WriteFailure } from './inventory.write'; + +/** + * HTTP surface for the inventory writes (roadmap §4). + * + * Every route is JWT-gated at the router, and the acting wallet is always the session, + * never a request field. The one exception is the admin grant's recipient, which is an + * argument because granting to yourself is not what that route is for. + * + * Reads are not here. They are GraphQL fields, matching how this repo serves data reads, + * while REST carries the actions. + */ + +/** Every named failure the write layer can return, mapped once. */ +const FAILURES: Record = { + 'writes-disabled': { status: 503, error: 'Item writes are not configured on this deployment' }, + 'unknown-item': { status: 404, error: 'No such item' }, + 'not-consumable': { status: 400, error: 'That item is not something a pet can use' }, + 'not-held': { status: 400, error: 'You do not hold that item' }, + 'not-pet-owner': { status: 403, error: 'That pet is not yours' }, + 'unknown-pet': { status: 404, error: 'No such pet' }, + 'unsupported-chain': { status: 400, error: 'This deployment does not serve that chain' }, + 'no-progress-row': { status: 409, error: 'That pet has no progression record yet' }, + // 404, not 403: an entitlement belonging to someone else is indistinguishable from one + // that does not exist, so an id cannot be probed by watching the status change. + 'unknown-entitlement': { status: 404, error: 'No such entitlement' }, + 'already-claimed': { status: 409, error: 'That entitlement has already been claimed' }, + 'not-admin': { status: 403, error: 'Not permitted' }, +}; + +function isFailure(value: unknown): value is WriteFailure { + return typeof value === 'string' && value in FAILURES; +} + +function respond(res: Response, failure: WriteFailure): void { + const { status, error } = FAILURES[failure]; + res.status(status).json({ error }); +} + +function callerOf(req: Request): string | undefined { + return (req as AuthenticatedRequest).user?.address; +} + +/** POST /api/inventory/use — spend one consumable on one of the caller's pets. */ +export async function postUseItem(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const body = UseItemSchema.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: 'Invalid request' }); + return; + } + + try { + const result = await useItem(body.data.chain, caller, body.data.petId, body.data.itemType); + if (isFailure(result)) { + respond(res, result); + return; + } + res.json(result); + } catch (err) { + // The burn may already have landed here; the write layer logs that case with + // everything needed to make it right, so this only has to avoid claiming success. + console.error('[inventory] failed to use item:', err); + res.status(500).json({ error: 'Failed to use item' }); + } +} + +/** POST /api/inventory/entitlements/:id/claim — mint an item the caller has earned. */ +export async function postClaim(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const entitlementId = req.params.id ?? ''; + if (!entitlementId) { + res.status(400).json({ error: 'Invalid entitlement' }); + return; + } + + try { + const result = await claimEntitlement(caller, entitlementId); + if (isFailure(result)) { + respond(res, result); + return; + } + res.json(result); + } catch (err) { + console.error('[inventory] failed to claim entitlement:', err); + res.status(500).json({ error: 'Failed to claim entitlement' }); + } +} + +/** POST /api/inventory/admin/grant — create an entitlement for any wallet. */ +export async function postGrant(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const body = GrantSchema.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: 'Invalid request' }); + return; + } + + try { + const result = await grantItem( + caller, + body.data.chain, + body.data.owner, + body.data.itemType, + body.data.quantity, + ); + if (isFailure(result)) { + respond(res, result); + return; + } + res.status(201).json(result); + } catch (err) { + console.error('[inventory] failed to grant item:', err); + res.status(500).json({ error: 'Failed to grant item' }); + } +} diff --git a/backend/src/features/inventory/inventory.schema.ts b/backend/src/features/inventory/inventory.schema.ts new file mode 100644 index 00000000..1372394c --- /dev/null +++ b/backend/src/features/inventory/inventory.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { SUPPORTED_CHAINS } from '@typings/chain'; + +/** + * Request shapes for the inventory writes (roadmap §4). + * + * Ids that are uint256 on chain stay decimal strings rather than becoming numbers, since a + * token id or pet id past 2^53 would silently lose precision on the way through JSON. + */ + +const decimalId = z.string().regex(/^[0-9]+$/, 'must be a decimal id'); + +/** Most of one item a single grant may hand out. A sanity bound, not a balance rule. */ +export const MAX_GRANT_QUANTITY = 1000; + +/** Body of POST /api/inventory/use. */ +export const UseItemSchema = z.object({ + chain: z.enum(SUPPORTED_CHAINS), + petId: decimalId, + itemType: decimalId, +}); + +/** Body of POST /api/inventory/entitlements/:id/claim — nothing but the path id. */ +export const ClaimSchema = z.object({}); + +/** + * Body of POST /api/inventory/admin/grant. + * + * The recipient is an argument here, unlike everywhere else in this feature, because + * granting to yourself is not what the route is for. Authorization is the allowlist, not + * the shape. + */ +export const GrantSchema = z.object({ + chain: z.enum(SUPPORTED_CHAINS), + owner: z.string().min(1).max(128), + itemType: decimalId, + quantity: z.number().int().positive().max(MAX_GRANT_QUANTITY).default(1), +}); diff --git a/backend/src/features/inventory/inventory.write.ts b/backend/src/features/inventory/inventory.write.ts new file mode 100644 index 00000000..56b7dcc4 --- /dev/null +++ b/backend/src/features/inventory/inventory.write.ts @@ -0,0 +1,274 @@ +import { applyXp, normalizeAccount } from '@cryptopets/protocol'; + +import { env } from '@config/env'; +import { prisma } from '@config/prisma'; +import { servedChainIdForFamily } from '@repositories/battleProgress.overlay'; +import { findBalance, findDefinitionByType } from '@repositories/inventory.repository'; +import { servedDeploymentId } from '@features/battle/ledger'; + +import { asItemEffect } from './catalog'; +import { getItemCoreClient } from './inventory.chain'; + +/** + * Inventory writes (roadmap §4): spend a consumable, claim an earned item, grant one. + * + * Equipping is not here, and not by omission. `ItemCore.equip` requires `msg.sender` to be + * the pet's owner, so the player's own wallet sends it; that is what makes an equip a + * statement by the owner rather than by this server, and it is why gear in a battle + * snapshot is checkable against chain state by someone who does not trust us. + * + * Every failure below is a named result rather than an exception, so the controller maps + * one list of outcomes to status codes instead of pattern-matching error strings. + */ + +export type WriteFailure = + | 'writes-disabled' + | 'unknown-item' + | 'not-consumable' + | 'not-held' + | 'not-pet-owner' + | 'unknown-pet' + | 'unsupported-chain' + | 'no-progress-row' + | 'unknown-entitlement' + | 'already-claimed' + | 'not-admin'; + +export interface UseItemResult { + burnTxHash: string; + level: number; + xp: number; + readyAt: number; + leveledUp: boolean; +} + +/** + * Spends one consumable on one of the caller's pets. + * + * Ordering is burn-then-apply, and the direction matters. If the burn lands and the apply + * fails, the player has lost an item and gained nothing, which is bad. If the apply landed + * first and the burn failed, the player would keep the item *and* the effect, which is a + * repeatable exploit rather than a bad afternoon. The burn is also what ItemCore's own + * doc comment calls the record that the effect was spent, so a spend with no burn is a + * spend with no record. + * + * The failed-apply case is logged loudly with everything needed to make it right by hand. + * Making it automatic means an outbox, which is worth building when the volume justifies + * it and is not worth pretending to have now. + */ +export async function useItem( + chain: string, + caller: string, + petId: string, + itemType: string, +): Promise { + const client = getItemCoreClient(); + if (!client) { + return 'writes-disabled'; + } + + const owner = normalizeAccount(caller); + const definition = await findDefinitionByType(itemType); + if (!definition) { + return 'unknown-item'; + } + const effect = asItemEffect(definition.effect); + if (definition.category !== 'consumable' || !effect || effect.kind === 'stat_bonus') { + return 'not-consumable'; + } + + // Checked before the burn as a courtesy, not as the guard. ItemCore reverts on an + // insufficient balance regardless, which is the check that actually holds under two + // concurrent requests for the same last potion. + const balance = await findBalance(chain, owner, itemType); + if (!balance || balance.quantity <= 0n) { + return 'not-held'; + } + + const pet = await prisma.petRoster.findUnique({ + where: { chain_petId: { chain, petId } }, + select: { owner: true, level: true, winCount: true, lossCount: true }, + }); + if (!pet) { + return 'unknown-pet'; + } + if (normalizeAccount(pet.owner) !== owner) { + return 'not-pet-owner'; + } + + const chainId = servedChainIdForFamily(chain as never); + if (!chainId) { + return 'unsupported-chain'; + } + + const burnTxHash = await client.burnFrom(owner, itemType, 1); + + try { + const progress = await applyEffect(chainId, petId, effect, pet); + return { burnTxHash, ...progress }; + } catch (error) { + console.error( + `[inventory] burned item ${itemType} from ${owner} (tx ${burnTxHash}) but failed to apply its effect to pet ${petId}; the player is owed this effect`, + error, + ); + throw error; + } +} + +type EffectTarget = { level: number; winCount: number; lossCount: number }; + +async function applyEffect( + chainId: string, + petId: string, + effect: Exclude, null> & { kind: 'grant_xp' | 'clear_battle_cooldown' }, + pet: EffectTarget, +): Promise<{ level: number; xp: number; readyAt: number; leveledUp: boolean }> { + const deploymentId = servedDeploymentId(); + const key = { chainId_deploymentId_petId: { chainId, deploymentId, petId } }; + + // Seeded from on-chain level the same way a pet's first battle seeds it, so a + // level-40 pet that has never fought does not start its progression at level 1. + const existing = await prisma.petBattleProgress.findUnique({ where: key }); + const current = existing ?? { + level: pet.level, + xp: 0, + winCount: pet.winCount, + lossCount: pet.lossCount, + readyAt: 0n, + }; + + if (effect.kind === 'clear_battle_cooldown') { + const row = await prisma.petBattleProgress.upsert({ + where: key, + create: { chainId, deploymentId, petId, ...withoutReadyAt(current), readyAt: 0n }, + update: { readyAt: 0n }, + }); + return { level: row.level, xp: row.xp, readyAt: Number(row.readyAt), leveledUp: false }; + } + + // Level cap and threshold curve come from the combat engine rather than being + // restated here: an XP grant has to move a pet exactly the way a battle would, or a + // potion and a fight would disagree about what level 12 means. + const next = applyXp({ level: current.level, xp: current.xp }, effect.amount); + const row = await prisma.petBattleProgress.upsert({ + where: key, + create: { chainId, deploymentId, petId, ...withoutReadyAt(current), level: next.level, xp: next.xp, readyAt: current.readyAt }, + update: { level: next.level, xp: next.xp }, + }); + return { level: row.level, xp: row.xp, readyAt: Number(row.readyAt), leveledUp: next.leveledUp }; +} + +function withoutReadyAt(state: { level: number; xp: number; winCount: number; lossCount: number }) { + return { level: state.level, xp: state.xp, winCount: state.winCount, lossCount: state.lossCount }; +} + +export interface ClaimResult { + mintTxHash: string; + itemType: string; + quantity: number; +} + +/** + * Mints an entitlement the caller has earned. + * + * Claimed-then-minted would let a crash between the two lose the item; minted-then-claimed + * risks minting twice if the mark fails. This takes the second and makes it safe by + * conditioning the mark on the row still being unclaimed, so a double call mints at most + * once: the loser's update matches no row and it stops before sending anything. + */ +export async function claimEntitlement(caller: string, entitlementId: string): Promise { + const client = getItemCoreClient(); + if (!client) { + return 'writes-disabled'; + } + + const owner = normalizeAccount(caller); + const entitlement = await prisma.itemEntitlement.findUnique({ where: { id: entitlementId } }); + // A row belonging to someone else reads as absent, so an id cannot be probed for + // existence by whether the error changes. + if (!entitlement || normalizeAccount(entitlement.owner) !== owner) { + return 'unknown-entitlement'; + } + if (entitlement.claimedAt) { + return 'already-claimed'; + } + + // Claims the row first, conditioned on it still being unclaimed. Two concurrent calls + // both pass the read above; only one updates a row here, and the other sees zero. + const claimed = await prisma.itemEntitlement.updateMany({ + where: { id: entitlementId, claimedAt: null }, + data: { claimedAt: new Date() }, + }); + if (claimed.count === 0) { + return 'already-claimed'; + } + + try { + const mintTxHash = await client.mintTo(owner, entitlement.itemType, entitlement.quantity); + await prisma.itemEntitlement.update({ where: { id: entitlementId }, data: { txHash: mintTxHash } }); + return { mintTxHash, itemType: entitlement.itemType, quantity: entitlement.quantity }; + } catch (error) { + // Released, so a failed mint is retryable rather than a permanently burned claim. + // Safe because the mint did not land: the client waits for a receipt and treats a + // reverted one as a throw. + await prisma.itemEntitlement.updateMany({ + where: { id: entitlementId, txHash: null }, + data: { claimedAt: null }, + }); + throw error; + } +} + +export interface GrantResult { + entitlementId: string; + owner: string; + itemType: string; + quantity: number; +} + +/** + * Creates an entitlement for any wallet. Admin only. + * + * Grants an entitlement rather than minting directly, so an admin grant and a battle drop + * reach a player's bag by the same path and there is one place where a mint can go wrong. + * It also means the recipient pays attention: an item appears when they claim it, not + * silently. + */ +export async function grantItem( + caller: string, + chain: string, + owner: string, + itemType: string, + quantity: number, +): Promise { + if (!isAdmin(caller)) { + return 'not-admin'; + } + + const definition = await findDefinitionByType(itemType); + if (!definition) { + return 'unknown-item'; + } + + const recipient = normalizeAccount(owner); + // A fresh reference per grant, so repeated grants of the same item to the same wallet + // are separate entitlements rather than one deduplicated by the unique key that keeps + // battle drops idempotent. + const sourceRef = `admin:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`; + const created = await prisma.itemEntitlement.create({ + data: { chain, owner: recipient, itemType, quantity, source: 'admin_grant', sourceRef }, + }); + + console.warn(`[inventory] ${caller} granted ${quantity}x item ${itemType} to ${recipient} (${created.id})`); + return { entitlementId: created.id, owner: recipient, itemType, quantity }; +} + +/** + * Whether a wallet may grant items. + * + * An allowlist that is empty by default, so the route is closed until someone is named + * rather than open until someone is excluded. + */ +export function isAdmin(caller: string): boolean { + return env.inventory.adminWallets.has(normalizeAccount(caller)); +} diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index 24f1d8d6..1d207c1f 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -82,3 +82,18 @@ export const battleRoomRateLimit = rateLimit({ keyGenerator: walletKey, message: { error: 'Too many battle room requests, try again shortly' }, }); + +/** + * Inventory writes each send a transaction and wait for its receipt, so the real limit is + * block time rather than server cost. A tight budget here is about the wallet: every call + * spends gas from the backend's own key, and a loop of failed uses would drain it whether + * or not anything settled. + */ +export const inventoryWriteRateLimit = rateLimit({ + windowMs: 60_000, + limit: 15, + standardHeaders: 'draft-8', + legacyHeaders: false, + keyGenerator: walletKey, + message: { error: 'Too many item actions, try again shortly' }, +}); diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts new file mode 100644 index 00000000..d6edfa03 --- /dev/null +++ b/backend/src/routes/inventory.ts @@ -0,0 +1,23 @@ +import express, { Router } from 'express'; + +import { postClaim, postGrant, postUseItem } from '@features/inventory'; +import { verifyToken } from '@middleware/auth'; +import { inventoryWriteRateLimit } from '@middleware/rateLimit'; + +const router: Router = express.Router(); + +// Writes only. Inventory reads are GraphQL fields, matching how this repo serves data +// reads, so nothing here duplicates a query. +// +// Rate limits run after verifyToken so the budget is per wallet rather than per IP, as in +// the chat routes. There is no read/write split to make: every route below sends a +// transaction from the backend's own wallet, so they all belong to the tighter budget. +router.post('/use', verifyToken, inventoryWriteRateLimit, postUseItem); +router.post('/entitlements/:id/claim', verifyToken, inventoryWriteRateLimit, postClaim); + +// Authorization is the allowlist inside the handler, not a separate middleware. Keeping it +// in the write layer means the rule holds for any future caller of grantItem, rather than +// only for requests that happen to arrive through this line. +router.post('/admin/grant', verifyToken, inventoryWriteRateLimit, postGrant); + +export default router; diff --git a/backend/tests/features/inventory/inventory.write.test.ts b/backend/tests/features/inventory/inventory.write.test.ts new file mode 100644 index 00000000..b60eb96c --- /dev/null +++ b/backend/tests/features/inventory/inventory.write.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const client = { mintTo: vi.fn(), burnFrom: vi.fn() }; +const chain = { getItemCoreClient: vi.fn(() => client as { mintTo: unknown; burnFrom: unknown } | null) }; + +vi.mock('@features/inventory/inventory.chain', () => ({ + getItemCoreClient: () => chain.getItemCoreClient(), +})); + +const repo = { findBalance: vi.fn(), findDefinitionByType: vi.fn() }; +vi.mock('@repositories/inventory.repository', () => ({ + findBalance: (c: string, o: string, t: string) => repo.findBalance(c, o, t), + findDefinitionByType: (t: string) => repo.findDefinitionByType(t), +})); + +vi.mock('@repositories/battleProgress.overlay', () => ({ + servedChainIdForFamily: vi.fn(() => 'eip155:31337'), +})); + +vi.mock('@features/battle/ledger', () => ({ + servedDeploymentId: vi.fn(() => 'local'), +})); + +vi.mock('@config/env', () => ({ + env: { inventory: { adminWallets: new Set(['0xadmin']) } }, +})); + +vi.mock('@config/prisma', () => ({ + prisma: { + petRoster: { findUnique: vi.fn() }, + petBattleProgress: { findUnique: vi.fn(), upsert: vi.fn() }, + itemEntitlement: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn(), create: vi.fn() }, + }, +})); + +import { claimEntitlement, grantItem, isAdmin, useItem } from '@features/inventory/inventory.write'; +import { prisma } from '@config/prisma'; + +const OWNER = '0xaaa0000000000000000000000000000000000001'; + +const POTION = { itemType: '100', category: 'consumable', effect: { kind: 'grant_xp', amount: 50 } }; +const DRAUGHT = { itemType: '110', category: 'consumable', effect: { kind: 'clear_battle_cooldown' } }; +const BLADE = { itemType: '1', category: 'equipment', effect: { kind: 'stat_bonus', hp: 0, atk: 4, def: 0, int: 0, mdef: 0 } }; + +/** A held item, an owned pet, and no prior progression row. */ +function happyPath(definition: unknown) { + repo.findDefinitionByType.mockResolvedValue(definition); + repo.findBalance.mockResolvedValue({ itemType: '100', quantity: 2n }); + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue({ + owner: OWNER, level: 4, winCount: 1, lossCount: 0, + } as never); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.petBattleProgress.upsert).mockImplementation((async (args: { + create?: Record; + update?: Record; + }) => ({ level: 4, xp: 0, readyAt: 0n, ...args.create, ...args.update })) as never); + client.burnFrom.mockResolvedValue('0xburn'); +} + +beforeEach(() => { + vi.clearAllMocks(); + chain.getItemCoreClient.mockReturnValue(client); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('useItem', () => { + it('burns the item and credits the XP', async () => { + happyPath(POTION); + + const result = await useItem('evm', OWNER, '7', '100'); + + expect(client.burnFrom).toHaveBeenCalledWith(OWNER, '100', 1); + expect(result).toMatchObject({ burnTxHash: '0xburn', xp: 50, leveledUp: false }); + }); + + // The threshold curve and level cap come from the combat engine, so a potion moves a + // pet exactly the way a fight would. Level 4 crosses at 400 XP. + it('levels a pet up on the same curve a battle uses', async () => { + happyPath({ ...POTION, effect: { kind: 'grant_xp', amount: 400 } }); + + const result = await useItem('evm', OWNER, '7', '100'); + + expect(result).toMatchObject({ level: 5, xp: 0, leveledUp: true }); + }); + + it('clears the backend battle cooldown', async () => { + happyPath(DRAUGHT); + + const result = await useItem('evm', OWNER, '7', '110'); + + expect(result).toMatchObject({ readyAt: 0, leveledUp: false }); + expect(vi.mocked(prisma.petBattleProgress.upsert).mock.calls[0]![0]).toMatchObject({ + update: { readyAt: 0n }, + }); + }); + + // Seeded from on-chain level the way a first battle seeds it, so a level-40 pet that + // has never fought does not restart its progression at level 1. + it('seeds a missing progression row from the pet’s on-chain level', async () => { + happyPath(POTION); + + await useItem('evm', OWNER, '7', '100'); + + expect(vi.mocked(prisma.petBattleProgress.upsert).mock.calls[0]![0]).toMatchObject({ + create: expect.objectContaining({ level: 4 }), + }); + }); + + it('refuses equipment, which is worn rather than used', async () => { + happyPath(BLADE); + expect(await useItem('evm', OWNER, '7', '1')).toBe('not-consumable'); + expect(client.burnFrom).not.toHaveBeenCalled(); + }); + + it('refuses an item the caller does not hold', async () => { + happyPath(POTION); + repo.findBalance.mockResolvedValue({ itemType: '100', quantity: 0n }); + expect(await useItem('evm', OWNER, '7', '100')).toBe('not-held'); + expect(client.burnFrom).not.toHaveBeenCalled(); + }); + + it('refuses a pet the caller does not own', async () => { + happyPath(POTION); + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue({ + owner: '0xsomeoneelse', level: 4, winCount: 0, lossCount: 0, + } as never); + expect(await useItem('evm', OWNER, '7', '100')).toBe('not-pet-owner'); + expect(client.burnFrom).not.toHaveBeenCalled(); + }); + + it('refuses when item writes are not configured, rather than half-applying', async () => { + chain.getItemCoreClient.mockReturnValue(null); + expect(await useItem('evm', OWNER, '7', '100')).toBe('writes-disabled'); + }); + + // Burn-then-apply is the safer failure direction: the player loses an item and gains + // nothing, rather than keeping both the item and the effect, which repeats. + it('checks ownership and holding before burning anything', async () => { + happyPath(POTION); + repo.findDefinitionByType.mockResolvedValue(null); + + expect(await useItem('evm', OWNER, '7', '999')).toBe('unknown-item'); + expect(client.burnFrom).not.toHaveBeenCalled(); + }); + + it('logs the burned item when applying the effect fails, so it can be made right', async () => { + happyPath(POTION); + vi.mocked(prisma.petBattleProgress.upsert).mockRejectedValue(new Error('db down') as never); + + await expect(useItem('evm', OWNER, '7', '100')).rejects.toThrow('db down'); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('is owed this effect'), expect.anything()); + }); +}); + +describe('claimEntitlement', () => { + const ROW = { id: 'e1', owner: OWNER, itemType: '100', quantity: 2, claimedAt: null }; + + it('claims the row, then mints, then records the hash', async () => { + vi.mocked(prisma.itemEntitlement.findUnique).mockResolvedValue(ROW as never); + vi.mocked(prisma.itemEntitlement.updateMany).mockResolvedValue({ count: 1 } as never); + client.mintTo.mockResolvedValue('0xmint'); + + const result = await claimEntitlement(OWNER, 'e1'); + + expect(client.mintTo).toHaveBeenCalledWith(OWNER, '100', 2); + expect(result).toEqual({ mintTxHash: '0xmint', itemType: '100', quantity: 2 }); + expect(prisma.itemEntitlement.update).toHaveBeenCalledWith({ + where: { id: 'e1' }, data: { txHash: '0xmint' }, + }); + }); + + // Both callers pass the read; only one updates a row, and the loser stops before + // sending anything, so a double call mints at most once. + it('mints once when two claims race', async () => { + vi.mocked(prisma.itemEntitlement.findUnique).mockResolvedValue(ROW as never); + vi.mocked(prisma.itemEntitlement.updateMany).mockResolvedValue({ count: 0 } as never); + + expect(await claimEntitlement(OWNER, 'e1')).toBe('already-claimed'); + expect(client.mintTo).not.toHaveBeenCalled(); + }); + + // Released rather than left claimed, so a failed mint is retryable. Safe because the + // client waits for a receipt and treats a reverted one as a throw. + it('releases the claim when the mint fails', async () => { + vi.mocked(prisma.itemEntitlement.findUnique).mockResolvedValue(ROW as never); + vi.mocked(prisma.itemEntitlement.updateMany).mockResolvedValue({ count: 1 } as never); + client.mintTo.mockRejectedValue(new Error('rpc down')); + + await expect(claimEntitlement(OWNER, 'e1')).rejects.toThrow('rpc down'); + expect(vi.mocked(prisma.itemEntitlement.updateMany).mock.calls.at(-1)![0]).toMatchObject({ + where: { id: 'e1', txHash: null }, data: { claimedAt: null }, + }); + }); + + // 404, not 403: someone else's entitlement is indistinguishable from a missing one, so + // an id cannot be probed by watching the answer change. + it('reports another wallet’s entitlement as missing', async () => { + vi.mocked(prisma.itemEntitlement.findUnique).mockResolvedValue({ ...ROW, owner: '0xother' } as never); + expect(await claimEntitlement(OWNER, 'e1')).toBe('unknown-entitlement'); + }); + + it('refuses an entitlement already claimed', async () => { + vi.mocked(prisma.itemEntitlement.findUnique).mockResolvedValue({ ...ROW, claimedAt: new Date() } as never); + expect(await claimEntitlement(OWNER, 'e1')).toBe('already-claimed'); + }); +}); + +describe('grantItem', () => { + it('refuses a caller not on the allowlist', async () => { + expect(await grantItem(OWNER, 'evm', OWNER, '100', 1)).toBe('not-admin'); + expect(prisma.itemEntitlement.create).not.toHaveBeenCalled(); + }); + + it('creates an entitlement rather than minting directly', async () => { + repo.findDefinitionByType.mockResolvedValue(POTION); + vi.mocked(prisma.itemEntitlement.create).mockResolvedValue({ id: 'e9' } as never); + + const result = await grantItem('0xadmin', 'evm', OWNER, '100', 3); + + expect(result).toMatchObject({ entitlementId: 'e9', owner: OWNER, quantity: 3 }); + expect(client.mintTo).not.toHaveBeenCalled(); + expect(vi.mocked(prisma.itemEntitlement.create).mock.calls[0]![0]).toMatchObject({ + data: expect.objectContaining({ source: 'admin_grant' }), + }); + }); + + it('refuses an item that is not in the catalog', async () => { + repo.findDefinitionByType.mockResolvedValue(null); + expect(await grantItem('0xadmin', 'evm', OWNER, '999', 1)).toBe('unknown-item'); + }); +}); + +describe('isAdmin', () => { + // Empty by default, so the route is closed until someone is named rather than open + // until someone is excluded. + it('accepts only wallets on the allowlist', () => { + expect(isAdmin('0xadmin')).toBe(true); + expect(isAdmin(OWNER)).toBe(false); + }); +}); From 05c8388576b363a87ee659c9df245e7018e18dae Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 17:34:40 -0400 Subject: [PATCH 12/56] feat(inventory): pay item drops from settled battles --- backend/API.md | 29 +++ backend/env.example | 7 + backend/src/config/env.ts | 9 + .../src/features/battle/worker/sign.worker.ts | 20 ++ backend/src/features/inventory/drops.ts | 195 ++++++++++++++++++ backend/src/features/inventory/index.ts | 7 + .../battle/worker/sign.worker.test.ts | 62 +++++- .../tests/features/inventory/drops.test.ts | 150 ++++++++++++++ 8 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 backend/src/features/inventory/drops.ts create mode 100644 backend/tests/features/inventory/drops.test.ts diff --git a/backend/API.md b/backend/API.md index 5a8d002b..79cc0f8b 100644 --- a/backend/API.md +++ b/backend/API.md @@ -351,6 +351,35 @@ because the client waits for a receipt and treats a reverted one as a failure. drop reach a bag by the same path. Its allowlist (`ITEM_ADMIN_WALLETS`) is empty by default: the route is closed until someone is named, not open until someone is excluded. +#### Battle drops + +A settled battle can pay an item to each side, written as unclaimed entitlements **in the +same transaction as the receipt** — the rule `battle_history` already follows, because two +writes that can disagree eventually will. Off unless `ITEM_DROPS_ENABLED=true`, separately +from `ITEM_CORE_ENABLED`: recording a drop needs no transaction, only claiming one does. + +The roll derives from the battle's own drand seed rather than a new randomness source. +That seed is committed to a future round *before* the fight resolves, so nobody, this +server included, can grind a drop by re-rolling, and anyone holding the receipt can +recompute what should have dropped. Each side draws from its own labelled stream, so one +side's outcome reveals nothing about the other's. + +What that does **not** give you: the drop is not part of the signed receipt in v1. An +outsider can recompute what was owed and notice if something else was paid, but cannot +prove it from the receipt alone. Putting drops inside the signed payload means a receipt +schema version and a place in the ruleset hash, which is §4 phase 4 work. + +Equipment never drops — that tier is gated behind its own design review, and having gear +fall out of ordinary battles would settle that question by accident. Rarity is the weight, +inverted, so a Common lands five times as often as a Legendary. The pool comes from the +shipped catalog constant rather than `item_definition`, because a replay has to reproduce +what a battle dropped, and a table that content edits underneath would answer differently +next month for the same seed. + +Idempotent under a retried receipt transaction: the entitlement's unique key is +`(source_ref, owner, item_type)` with `source_ref` the battle id, so a replay collides with +its own earlier row rather than paying twice. + ### Battle data `battle_history` carries `loserPetId, seed (0x-hex), rounds, winnerHpRemaining, diff --git a/backend/env.example b/backend/env.example index b7d93f90..dd873877 100644 --- a/backend/env.example +++ b/backend/env.example @@ -190,6 +190,13 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # ITEM_CORE_CHAIN_ID=31337 # ITEM_CORE_ADDRESS=0x... # +# Whether a settled battle pays item drops. Separate from ITEM_CORE_ENABLED and off by +# default: recording a drop needs no transaction (only claiming one does), so an existing +# deployment should not start handing out items because a key was added for something +# else. Drops derive from the battle's own drand seed, committed before the fight +# resolved, so nobody can grind them and anyone holding the receipt can recompute them. +# ITEM_DROPS_ENABLED=true +# # Wallets allowed to call POST /api/inventory/admin/grant, comma-separated. Empty by # default, so the route is closed until someone is named rather than open until someone # is excluded. Checksummed addresses are fine; they are lowercased to match the JWT. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 8e29a6d3..a13aed8d 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -144,6 +144,15 @@ export const env = { * excluded. Normalized here so a checksummed address in the env still matches the * lowercased one the JWT carries. */ + /** + * Whether a settled battle pays item drops. + * + * Separate from ITEM_CORE_ENABLED and off by default. Recording a drop needs no + * transaction, only claiming one does, so the two are genuinely independent — and + * an existing deployment should not start handing out items because a key was + * added for something else. + */ + dropsEnabled: process.env.ITEM_DROPS_ENABLED?.trim().toLowerCase() === 'true', adminWallets: new Set( (process.env.ITEM_ADMIN_WALLETS ?? '') .split(',') diff --git a/backend/src/features/battle/worker/sign.worker.ts b/backend/src/features/battle/worker/sign.worker.ts index 13c4b6ca..ea0adf9c 100644 --- a/backend/src/features/battle/worker/sign.worker.ts +++ b/backend/src/features/battle/worker/sign.worker.ts @@ -13,6 +13,7 @@ import { env } from '@config/env'; import { prisma } from '@config/prisma'; import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle/ledger'; import { activeSigningKey, type EngineAttestation, sign, SignerRefusedError } from '@features/battle/signer'; +import { recordBattleDrops } from '@features/inventory'; import { recordBattleFromReceipt } from '@repositories/history.repository'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; @@ -212,6 +213,25 @@ export async function processSignMessage(message: ClaimedMessage, nowSeconds: nu attackerXp: progression.attacker.xpAwarded, defenderXp: progression.defender.xpAwarded, }); + // Item drops (roadmap §4). In this transaction for the same reason + // battle_history is: a battle that paid a drop without recording the + // battle, or recorded one without paying, is two writes that can + // disagree. Derived from the receipt's own seed, so it was fixed by a + // drand round committed before the fight resolved rather than chosen + // here, and recomputable by anyone holding the receipt. + if (env.inventory.dropsEnabled) { + await recordBattleDrops(tx, { + chain: chainFamily(battle.chainId as never), + battleId: battle.battleId, + seed: receipt.seed, + winnerOwner: receipt.result.attackerWon + ? snapshot.attacker.owner + : snapshot.defender.owner, + loserOwner: receipt.result.attackerWon + ? snapshot.defender.owner + : snapshot.attacker.owner, + }); + } }, outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.publish }], }); diff --git a/backend/src/features/inventory/drops.ts b/backend/src/features/inventory/drops.ts new file mode 100644 index 00000000..84cebf08 --- /dev/null +++ b/backend/src/features/inventory/drops.ts @@ -0,0 +1,195 @@ +import { hexToBytes, keccak256Hex, normalizeAccount, utf8ToBytes, type Hex } from '@cryptopets/protocol'; +import type { Prisma } from '@generated/prisma/client'; + +import { ITEM_CATALOG } from './catalog.data'; +import type { ItemDefinitionSeed } from './catalog'; + +/** + * Battle-reward drops (roadmap §4). + * + * Seeded from the battle's own drand seed rather than from a new randomness source. That + * seed is committed to a future drand round before the fight resolves, so nobody — + * including this server — can grind a drop by re-rolling: changing the outcome would mean + * changing a value that was published in advance. It also means a third party holding the + * receipt can recompute exactly what should have dropped. + * + * Be precise about how far that goes. The drop is **not** part of the signed receipt in + * v1, so an outsider can recompute what we owed and notice if we paid something else, but + * cannot prove it from the receipt alone. Putting drops inside the signed payload means a + * receipt schema version and a place in the ruleset hash, which is §4 phase 4 work. + * + * The pool is read from the shipped catalog constant rather than from `item_definition`, + * deliberately. A replay has to reproduce what a battle dropped, and a table that content + * edits underneath would give a different answer next month for the same seed. + */ + +/** A drop the battle owes one wallet. */ +export interface Drop { + owner: string; + itemType: string; + quantity: number; +} + +/** + * Odds and eligibility. Inputs, not a formula this file decides. + * + * How often a battle should pay, and whether losing pays at all, is game balance that + * depends on sinks that do not exist yet — the same reason `rewards/entitlements.ts` + * takes its rates as inputs. What lives here is the mechanism: derive deterministically, + * weight by rarity, pay at most one item per side. + */ +export interface DropRates { + /** Chance in basis points that the winner receives an item. */ + winnerChanceBps: number; + /** Chance in basis points for the loser. Non-zero keeps losing from being nothing. */ + loserChanceBps: number; +} + +/** + * A deliberately modest default: a win pays about one time in four, a loss about one in + * twenty. Low enough that a bag fills slowly while quests and crates are still missing, + * and easy to raise once there is something to spend items on. + */ +export const DEFAULT_DROP_RATES: DropRates = { + winnerChanceBps: 2500, + loserChanceBps: 500, +}; + +/** + * What can drop: everything except equipment. + * + * Gear is the tier §4 gates behind its own design review, and having it fall out of + * ordinary battles would settle that question by accident. Consumables, collectibles and + * materials are the categories whose whole purpose is to accumulate. + */ +const DROP_POOL: readonly ItemDefinitionSeed[] = ITEM_CATALOG.filter((item) => item.category !== 'equipment'); + +/** + * Rarity is the weight, inverted: a Common is five times as likely as a Legendary. + * + * Derived from the tier rather than hand-tabled, so adding an item to the catalog puts it + * in the pool at a sensible weight without a second list to keep in step. + */ +function weightOf(item: ItemDefinitionSeed): number { + return 6 - item.rarity; +} + +const TOTAL_WEIGHT = DROP_POOL.reduce((sum, item) => sum + weightOf(item), 0); + +/** + * What a battle owes, derived from its seed. + * + * Pure: same seed and same battle id give the same answer on any machine, at any time, + * to anyone holding the receipt. No clock and no ambient randomness, for the same reason + * `protocol` forbids both. + * + * Each side draws from its own labelled stream, so the two rolls cannot correlate and the + * loser's outcome cannot be inferred from the winner's. + */ +export function rollDrops( + seed: Hex, + battleId: string, + winnerOwner: string, + loserOwner: string, + rates: DropRates = DEFAULT_DROP_RATES, +): Drop[] { + const drops: Drop[] = []; + + const winner = rollSide(seed, battleId, 'winner', rates.winnerChanceBps); + if (winner) { + drops.push({ owner: winnerOwner, itemType: winner, quantity: 1 }); + } + + const loser = rollSide(seed, battleId, 'loser', rates.loserChanceBps); + if (loser) { + drops.push({ owner: loserOwner, itemType: loser, quantity: 1 }); + } + + return drops; +} + +/** One side's roll: does it pay, and if so with what. */ +function rollSide(seed: Hex, battleId: string, side: 'winner' | 'loser', chanceBps: number): string | null { + if (chanceBps <= 0 || DROP_POOL.length === 0) { + return null; + } + + // Two independent draws off one digest rather than two digests. The high half decides + // whether it pays and the low half decides what, so a near-miss on the chance roll + // cannot bias which item a hit would have produced. + const digest = hexToBytes( + keccak256Hex(concat(hexToBytes(seed), utf8ToBytes(`${battleId}:${side}:DROP`))), + ); + + if (readUint32(digest, 0) % 10_000 >= chanceBps) { + return null; + } + + let cursor = readUint32(digest, 4) % TOTAL_WEIGHT; + for (const item of DROP_POOL) { + const weight = weightOf(item); + if (cursor < weight) { + return item.itemType; + } + cursor -= weight; + } + // Unreachable: cursor started below the total of every weight subtracted above. + return DROP_POOL[DROP_POOL.length - 1]!.itemType; +} + +function readUint32(bytes: Uint8Array, offset: number): number { + return ( + ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0 + ); +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +/** + * Records a battle's drops as unclaimed entitlements. + * + * Called inside the same transaction that writes the receipt, so a battle cannot be + * recorded without its drops or the reverse — the rule `battle_history` already follows, + * and for the same reason: two writes that can disagree eventually will. + * + * Idempotent under the retry that transaction can take. The entitlement's unique key is + * (sourceRef, owner, itemType), and sourceRef is the battle id, so a replay of the same + * battle collides with its own earlier row instead of paying twice. Two drops of the same + * item to the same wallet from one battle would collide too, which is why each side rolls + * at most one item. + */ +export async function recordBattleDrops( + tx: Prisma.TransactionClient, + args: { + chain: string; + battleId: string; + seed: Hex; + winnerOwner: string; + loserOwner: string; + rates?: DropRates; + }, +): Promise { + const drops = rollDrops(args.seed, args.battleId, args.winnerOwner, args.loserOwner, args.rates); + if (drops.length === 0) { + return drops; + } + + await tx.itemEntitlement.createMany({ + data: drops.map((drop) => ({ + chain: args.chain, + owner: normalizeAccount(drop.owner), + itemType: drop.itemType, + quantity: drop.quantity, + source: 'battle_drop', + sourceRef: args.battleId, + })), + skipDuplicates: true, + }); + + return drops; +} diff --git a/backend/src/features/inventory/index.ts b/backend/src/features/inventory/index.ts index bb0e3ca0..fd89ae40 100644 --- a/backend/src/features/inventory/index.ts +++ b/backend/src/features/inventory/index.ts @@ -35,3 +35,10 @@ export { type WriteFailure, } from './inventory.write'; export { getItemCoreClient, resetItemCoreClient, type ItemCoreClient } from './inventory.chain'; +export { + DEFAULT_DROP_RATES, + recordBattleDrops, + rollDrops, + type Drop, + type DropRates, +} from './drops'; diff --git a/backend/tests/features/battle/worker/sign.worker.test.ts b/backend/tests/features/battle/worker/sign.worker.test.ts index 0f075f72..c45d7e4d 100644 --- a/backend/tests/features/battle/worker/sign.worker.test.ts +++ b/backend/tests/features/battle/worker/sign.worker.test.ts @@ -13,9 +13,14 @@ import { SOURCE_DEFAULT_RULESET, } from '@cryptopets/protocol'; -vi.mock('@config/env', () => ({ - env: { battle: { cooldownSeconds: 900 } }, +// Drops off by default here, matching the shipped default, so these tests keep asserting +// what the receipt transaction does on its own. Mutable so the one test that cares can +// switch them on; hoisted because vi.mock factories run before the imports below. +const envMock = vi.hoisted(() => ({ + battle: { cooldownSeconds: 900 }, + inventory: { dropsEnabled: false }, })); +vi.mock('@config/env', () => ({ env: envMock })); vi.mock('@config/prisma', () => ({ prisma: { @@ -45,8 +50,16 @@ vi.mock('@ws/battleRoomSocket', () => ({ notifyBattleRoomIfPresent: vi.fn(), })); +// Stubbed so the drop tests below assert the wiring — which seed, which owners, which +// transaction — rather than whether this fixture's seed happens to roll a payout. What a +// given seed produces is drops.test.ts's subject. +vi.mock('@features/inventory', () => ({ + recordBattleDrops: vi.fn().mockResolvedValue([]), +})); + import { prisma } from '@config/prisma'; import { applyTransition, completeOutbox } from '@features/battle/ledger'; +import { recordBattleDrops } from '@features/inventory'; import { activeSigningKey, sign, SignerRefusedError } from '@features/battle/signer'; import { processSignMessage } from '@features/battle/worker'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; @@ -163,11 +176,14 @@ function fakeTx() { petBattleProgress: { update: vi.fn().mockResolvedValue({}) }, // The rivalry record for the dialogue service, written on the same transaction. battleHistory: { upsert: vi.fn().mockResolvedValue({}) }, + // Item drops (roadmap §4), written on the same transaction for the same reason. + itemEntitlement: { createMany: vi.fn().mockResolvedValue({ count: 1 }) }, }; } beforeEach(() => { vi.clearAllMocks(); + envMock.inventory.dropsEnabled = false; vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(BATTLE as never); vi.mocked(prisma.battleReceipt.findFirst).mockResolvedValue(null); vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue(null); @@ -403,3 +419,45 @@ describe('idempotence', () => { await expect(processSignMessage(MESSAGE, NOW)).rejects.toThrow(/no commitment row/); }); }); + +describe('item drops (roadmap §4)', () => { + /** Runs the worker and hands back the transaction its onApplied saw. */ + async function runCapturingTx() { + const tx = fakeTx(); + vi.mocked(applyTransition).mockImplementationOnce((async (req: { onApplied?: (tx: unknown) => Promise }) => { + if (req.onApplied) await req.onApplied(tx); + return { applied: true, state: 'signed' }; + }) as never); + await processSignMessage(MESSAGE, NOW); + return tx; + } + + // Asserted on the call rather than on rows written, because whether this fixture's + // seed actually pays is a property of keccak, not of the wiring. It does not, as it + // happens — so a test that checked for rows would have passed while asserting nothing. + // What matters here is that the worker hands over the right inputs inside the right + // transaction; what those inputs produce is drops.test.ts's job. + it('records drops on the same transaction as the receipt', async () => { + envMock.inventory.dropsEnabled = true; + + const tx = await runCapturingTx(); + + expect(tx.battleReceipt.create).toHaveBeenCalled(); + expect(recordBattleDrops).toHaveBeenCalledWith(tx, { + chain: 'evm', + battleId: BATTLE.battleId, + seed: BATTLE.seed, + // Owners by outcome, not by role: paying the winner's drop to the loser is + // exactly the mistake this pins. + winnerOwner: BATTLE.attackerWon ? ATTACKER.owner : DEFENDER.owner, + loserOwner: BATTLE.attackerWon ? DEFENDER.owner : ATTACKER.owner, + }); + }); + + it('records no drops while the feature is off', async () => { + const tx = await runCapturingTx(); + + expect(tx.battleReceipt.create).toHaveBeenCalled(); + expect(recordBattleDrops).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/features/inventory/drops.test.ts b/backend/tests/features/inventory/drops.test.ts new file mode 100644 index 00000000..b37f4e56 --- /dev/null +++ b/backend/tests/features/inventory/drops.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_DROP_RATES, recordBattleDrops, rollDrops } from '@features/inventory/drops'; +import { ITEM_CATALOG } from '@features/inventory/catalog.data'; + +const SEED = `0x${'ab'.repeat(32)}` as const; +const WINNER = '0xaaa0000000000000000000000000000000000001'; +const LOSER = '0xbbb0000000000000000000000000000000000002'; + +const ALWAYS = { winnerChanceBps: 10_000, loserChanceBps: 10_000 }; +const NEVER = { winnerChanceBps: 0, loserChanceBps: 0 }; + +describe('rollDrops', () => { + // The whole point of seeding from the battle's drand seed: the seed is committed to a + // future round before the fight resolves, so a drop cannot be re-rolled by anyone, + // including this server, and anyone holding the receipt can recompute it. + it('is deterministic in the seed and the battle id', () => { + const a = rollDrops(SEED, 'battle-1', WINNER, LOSER, ALWAYS); + const b = rollDrops(SEED, 'battle-1', WINNER, LOSER, ALWAYS); + expect(a).toEqual(b); + }); + + it('gives a different answer for a different battle under the same seed', () => { + const a = rollDrops(SEED, 'battle-1', WINNER, LOSER, ALWAYS); + const b = rollDrops(SEED, 'battle-2', WINNER, LOSER, ALWAYS); + expect(a).not.toEqual(b); + }); + + // Separate labelled streams per side, so one side's outcome says nothing about the + // other's. + it('rolls the two sides independently', () => { + const drops = rollDrops(SEED, 'battle-1', WINNER, LOSER, ALWAYS); + expect(drops).toHaveLength(2); + expect(drops[0]!.owner).toBe(WINNER); + expect(drops[1]!.owner).toBe(LOSER); + }); + + it('pays nothing at zero chance', () => { + expect(rollDrops(SEED, 'battle-1', WINNER, LOSER, NEVER)).toEqual([]); + }); + + it('pays the loser only when the loser rate is non-zero', () => { + const drops = rollDrops(SEED, 'battle-1', WINNER, LOSER, { winnerChanceBps: 0, loserChanceBps: 10_000 }); + expect(drops).toHaveLength(1); + expect(drops[0]!.owner).toBe(LOSER); + }); + + // Equipment is the tier §4 gates behind its own design review; having it fall out of + // ordinary battles would settle that question by accident. + it('never drops equipment', () => { + const equipment = new Set(ITEM_CATALOG.filter((i) => i.category === 'equipment').map((i) => i.itemType)); + + for (let i = 0; i < 400; i++) { + for (const drop of rollDrops(SEED, `battle-${i}`, WINNER, LOSER, ALWAYS)) { + expect(equipment.has(drop.itemType)).toBe(false); + } + } + }); + + it('only ever drops items that exist in the catalog', () => { + const known = new Set(ITEM_CATALOG.map((i) => i.itemType)); + + for (let i = 0; i < 400; i++) { + for (const drop of rollDrops(SEED, `battle-${i}`, WINNER, LOSER, ALWAYS)) { + expect(known.has(drop.itemType)).toBe(true); + expect(drop.quantity).toBe(1); + } + } + }); + + // Rarity is the weight, inverted, so a Common should land far more often than a + // Legendary over a large sample. + it('favours common items over rare ones', () => { + const rarityOf = new Map(ITEM_CATALOG.map((i) => [i.itemType, i.rarity])); + const counts = new Map(); + + for (let i = 0; i < 3000; i++) { + for (const drop of rollDrops(SEED, `battle-${i}`, WINNER, LOSER, ALWAYS)) { + const rarity = rarityOf.get(drop.itemType)!; + counts.set(rarity, (counts.get(rarity) ?? 0) + 1); + } + } + + expect(counts.get(1) ?? 0).toBeGreaterThan(counts.get(5) ?? 0); + }); + + // The chance roll has to actually bite: a rate that never refuses would mean the high + // half of the digest was being ignored. + it('pays roughly at the configured rate', () => { + let paid = 0; + const trials = 2000; + for (let i = 0; i < trials; i++) { + paid += rollDrops(SEED, `battle-${i}`, WINNER, LOSER, { + winnerChanceBps: DEFAULT_DROP_RATES.winnerChanceBps, + loserChanceBps: 0, + }).length; + } + + // 25% nominal; a wide band, because this pins "the rate is applied" rather than + // the quality of keccak as a uniform source. + expect(paid / trials).toBeGreaterThan(0.2); + expect(paid / trials).toBeLessThan(0.3); + }); +}); + +describe('recordBattleDrops', () => { + function fakeTx() { + return { itemEntitlement: { createMany: vi.fn().mockResolvedValue({ count: 2 }) } }; + } + + it('writes each drop as an unclaimed entitlement keyed to the battle', async () => { + const tx = fakeTx(); + + await recordBattleDrops(tx as never, { + chain: 'evm', + battleId: 'battle-1', + seed: SEED, + winnerOwner: WINNER, + loserOwner: LOSER, + rates: ALWAYS, + }); + + const { data, skipDuplicates } = tx.itemEntitlement.createMany.mock.calls[0]![0]; + expect(skipDuplicates).toBe(true); + expect(data).toHaveLength(2); + expect(data[0]).toMatchObject({ chain: 'evm', source: 'battle_drop', sourceRef: 'battle-1', owner: WINNER }); + }); + + // sourceRef is the battle id and the unique key is (sourceRef, owner, itemType), so a + // retried receipt transaction collides with its own earlier row instead of paying + // twice. skipDuplicates is what turns that collision into a no-op. + it('skips duplicates so a retried receipt transaction cannot pay twice', async () => { + const tx = fakeTx(); + await recordBattleDrops(tx as never, { + chain: 'evm', battleId: 'battle-1', seed: SEED, + winnerOwner: WINNER, loserOwner: LOSER, rates: ALWAYS, + }); + expect(tx.itemEntitlement.createMany.mock.calls[0]![0].skipDuplicates).toBe(true); + }); + + it('writes nothing when the battle paid nothing', async () => { + const tx = fakeTx(); + const drops = await recordBattleDrops(tx as never, { + chain: 'evm', battleId: 'battle-1', seed: SEED, + winnerOwner: WINNER, loserOwner: LOSER, rates: NEVER, + }); + expect(drops).toEqual([]); + expect(tx.itemEntitlement.createMany).not.toHaveBeenCalled(); + }); +}); From 892fb59619e16d8469062d4db5c6a320e07bf679 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 17:51:52 -0400 Subject: [PATCH 13/56] feat(inventory): add the shared inventory hooks --- backend/src/features/inventory/catalog.ts | 57 ++---- shared/src/hooks/index.ts | 37 ++++ shared/src/hooks/inventory/useInventory.ts | 90 +++++++++ shared/src/hooks/inventory/useItemCatalog.ts | 74 +++++++ shared/src/hooks/inventory/usePetEquipment.ts | 92 +++++++++ shared/src/hooks/inventory/useUseItem.ts | 65 ++++++ shared/src/node.ts | 16 ++ shared/src/types/item.ts | 150 ++++++++++++++ shared/tests/hooks/useInventory.test.tsx | 190 ++++++++++++++++++ 9 files changed, 735 insertions(+), 36 deletions(-) create mode 100644 shared/src/hooks/inventory/useInventory.ts create mode 100644 shared/src/hooks/inventory/useItemCatalog.ts create mode 100644 shared/src/hooks/inventory/usePetEquipment.ts create mode 100644 shared/src/hooks/inventory/useUseItem.ts create mode 100644 shared/src/types/item.ts create mode 100644 shared/tests/hooks/useInventory.test.tsx diff --git a/backend/src/features/inventory/catalog.ts b/backend/src/features/inventory/catalog.ts index 9d41fc51..363f6bba 100644 --- a/backend/src/features/inventory/catalog.ts +++ b/backend/src/features/inventory/catalog.ts @@ -1,3 +1,11 @@ +import { + ITEM_CATEGORIES, + type ItemCategory, + type ItemEffect, + type SlotName, + type StatBonus, +} from '@shared/core/node'; + /** * The item catalog's shape and its validation (roadmap §4). * @@ -12,42 +20,19 @@ * expensive to discover in production, and none of them need a connection to check. */ -/** Equip slots, mirroring ItemCore.SLOT_*. The contract is authoritative. */ -export const SLOT = { weapon: 0, armor: 1, trinket: 2 } as const; -export type SlotName = keyof typeof SLOT; - -export const ITEM_CATEGORIES = ['consumable', 'equipment', 'collectible', 'material'] as const; -export type ItemCategory = (typeof ITEM_CATEGORIES)[number]; - -/** - * Flat, non-negative additions to a pet's extracted attributes. - * - * Non-negative and additive only in v1, which §4 recommends and which also removes a - * real hazard: the engine truncates to 16 bits with wraparound rather than clamping, so - * a negative modifier is one underflow away from a pet with 65,000 HP. A multiplicative - * or conditional effect system is a v2 of the equipment model, not a field added here. - */ -export interface StatBonus { - kind: 'stat_bonus'; - hp: number; - atk: number; - def: number; - int: number; - mdef: number; -} - -/** - * Every effect v1 can actually apply. - * - * Breeding cooldowns are deliberately absent. They live in on-chain state, and clearing - * one means an authorized `PetCore.triggerBreedCooldown` call the inventory feature does - * not have and should not quietly acquire. A fertility charm is a real item to build, with - * that authorization as its first step, rather than a catalog entry that errors on use. - */ -export type ItemEffect = - | StatBonus - | { kind: 'grant_xp'; amount: number } - | { kind: 'clear_battle_cooldown' }; +// The vocabulary itself lives in `@shared/core/node`, not here. Both this server and every +// client have to agree on what an effect is, and the way they agree is by importing one +// declaration: a second copy would drift the first time an effect kind was added, and the +// symptom would be a client rendering nothing rather than an error anyone notices. This +// module owns the *rules* about that vocabulary, which is a separate job. +export { + ITEM_CATEGORIES, + SLOT, + type ItemCategory, + type ItemEffect, + type SlotName, + type StatBonus, +} from '@shared/core/node'; /** One catalog entry, as authored. */ export interface ItemDefinitionSeed { diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index e3d7bef4..6a84e6d8 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -136,3 +136,40 @@ export { } from './battle/useVerifiedBattleReceipt'; export { usePetError, type PetError } from './tx/usePetError'; export { useTxError, type TxError } from './tx/useTxError'; + +// Inventory (roadmap §4). Reads are GraphQL; useUseItem is REST, because spending a +// consumable is settled by the backend's wallet rather than signed by the player. +// Equipping is not here: it is a chain write and lives on the inventory adapter. +export { + useInventory, + inventoryQueryKey, + type UseInventoryOptions, + type UseInventoryResult, +} from './inventory/useInventory'; +export { useItemCatalog, type UseItemCatalogResult } from './inventory/useItemCatalog'; +export { + usePetEquipment, + petEquipmentQueryKey, + type UsePetEquipmentOptions, + type UsePetEquipmentResult, +} from './inventory/usePetEquipment'; +export { + useUseItem, + type UseItemArgs, + type UseItemResult, + type UseUseItemResult, +} from './inventory/useUseItem'; +export { + describeItemEffect, + ITEM_CATEGORIES, + parseItemEffect, + SLOT, + SLOT_NAMES, + type EquippedItem, + type InventoryEntry, + type ItemCategory, + type ItemDefinition, + type ItemEffect, + type SlotName, + type StatBonus, +} from '../types/item'; diff --git a/shared/src/hooks/inventory/useInventory.ts b/shared/src/hooks/inventory/useInventory.ts new file mode 100644 index 00000000..d5feb72a --- /dev/null +++ b/shared/src/hooks/inventory/useInventory.ts @@ -0,0 +1,90 @@ +import { useQuery } from '@tanstack/react-query'; +import { useApiClient } from '../../contexts/ApiClientContext'; +import { useAuth } from '../../contexts/AuthContext'; +import type { PetChain } from '../../types/pet'; +import { parseItemEffect, type InventoryEntry, type ItemDefinition } from '../../types/item'; + +/** + * The caller's own items (roadmap §4). + * + * There is no owner argument, and that is the point: the backend takes it from the session, + * so there is no spelling of this that reads another wallet's bag. It also means the query + * key does not need an address in it — the session already varies with `baseURL`. + */ + +const INVENTORY_QUERY = ` + query Inventory($chain: String!) { + inventory(chain: $chain) { + item { itemType key category slot rarity effect name description } + quantity + } + } +`; + +/** The wire shape: `effect` arrives as a JSON string and is parsed on the way out. */ +interface WireItem extends Omit { + effect: string | null; +} + +interface GraphQLResponse { + data?: { inventory: { item: WireItem; quantity: string }[] }; + errors?: { message: string }[]; +} + +export interface UseInventoryOptions { + /** Active chain; the query is disabled until this is set. */ + chain: PetChain | null; + enabled?: boolean; +} + +export interface UseInventoryResult { + entries: InventoryEntry[]; + isLoading: boolean; + error: Error | null; + refetch(): void; +} + +/** Query key, exported so a mutation elsewhere can invalidate this without guessing it. */ +export function inventoryQueryKey(baseURL: string, chain: PetChain | null): unknown[] { + return ['inventory', baseURL, chain]; +} + +export function toItemDefinition(item: WireItem): ItemDefinition { + return { ...item, effect: parseItemEffect(item.effect) }; +} + +export const useInventory = ({ chain, enabled = true }: UseInventoryOptions): UseInventoryResult => { + const apiClient = useApiClient(); + const { isAuthenticated } = useAuth(); + const baseURL = apiClient.defaults.baseURL ?? ''; + + const query = useQuery({ + queryKey: inventoryQueryKey(baseURL, chain), + // Authenticated only, and not merely because /graphql sits behind the JWT: without + // a session the server has no owner to answer for and returns an empty bag, which + // would render as "you own nothing" rather than as "sign in". + enabled: enabled && chain != null && isAuthenticated, + queryFn: async () => { + const { data } = await apiClient.post('/graphql', { + query: INVENTORY_QUERY, + variables: { chain }, + }); + + if (data.errors?.length) { + throw new Error(data.errors.map((e) => e.message).join('; ')); + } + + return (data.data?.inventory ?? []).map((entry) => ({ + item: toItemDefinition(entry.item), + quantity: entry.quantity, + })); + }, + }); + + return { + entries: query.data ?? [], + isLoading: query.isLoading, + error: query.error as Error | null, + refetch: () => void query.refetch(), + }; +}; diff --git a/shared/src/hooks/inventory/useItemCatalog.ts b/shared/src/hooks/inventory/useItemCatalog.ts new file mode 100644 index 00000000..ab014a12 --- /dev/null +++ b/shared/src/hooks/inventory/useItemCatalog.ts @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query'; +import { useApiClient } from '../../contexts/ApiClientContext'; +import { useAuth } from '../../contexts/AuthContext'; +import type { ItemDefinition } from '../../types/item'; +import { toItemDefinition } from './useInventory'; + +/** + * The whole item catalog (roadmap §4). + * + * Cached hard: this is content that changes when someone runs the seeder, not per-render + * data. A bag view, an equip picker and a drop notification all want the same definitions, + * and re-fetching them per surface would be the same rows over and over. + */ + +const CATALOG_QUERY = ` + query ItemCatalog { + itemCatalog { itemType key category slot rarity effect name description } + } +`; + +interface WireItem extends Omit { + effect: string | null; +} + +interface GraphQLResponse { + data?: { itemCatalog: WireItem[] }; + errors?: { message: string }[]; +} + +export interface UseItemCatalogResult { + items: ItemDefinition[]; + /** Lookup by token id, which is what every other read joins on. */ + byType: Map; + isLoading: boolean; + error: Error | null; +} + +/** + * How long the catalog is treated as fresh. + * + * Five minutes rather than Infinity: a seeder run should reach an open tab eventually + * without a reload, and an item whose description is five minutes stale costs nothing. + */ +const CATALOG_STALE_MS = 5 * 60 * 1000; + +export const useItemCatalog = (options: { enabled?: boolean } = {}): UseItemCatalogResult => { + const { enabled = true } = options; + const apiClient = useApiClient(); + const { isAuthenticated } = useAuth(); + const baseURL = apiClient.defaults.baseURL ?? ''; + + const query = useQuery({ + queryKey: ['itemCatalog', baseURL], + enabled: enabled && isAuthenticated, + staleTime: CATALOG_STALE_MS, + queryFn: async () => { + const { data } = await apiClient.post('/graphql', { query: CATALOG_QUERY }); + + if (data.errors?.length) { + throw new Error(data.errors.map((e) => e.message).join('; ')); + } + + return (data.data?.itemCatalog ?? []).map(toItemDefinition); + }, + }); + + const items = query.data ?? []; + return { + items, + byType: new Map(items.map((item) => [item.itemType, item])), + isLoading: query.isLoading, + error: query.error as Error | null, + }; +}; diff --git a/shared/src/hooks/inventory/usePetEquipment.ts b/shared/src/hooks/inventory/usePetEquipment.ts new file mode 100644 index 00000000..558516f4 --- /dev/null +++ b/shared/src/hooks/inventory/usePetEquipment.ts @@ -0,0 +1,92 @@ +import { useQuery } from '@tanstack/react-query'; +import { useApiClient } from '../../contexts/ApiClientContext'; +import { useAuth } from '../../contexts/AuthContext'; +import type { PetChain } from '../../types/pet'; +import type { EquippedItem, ItemDefinition } from '../../types/item'; +import { toItemDefinition } from './useInventory'; + +/** + * What a pet has equipped (roadmap §4). + * + * Any pet, not only the caller's: gear changes a pet's stats in a battle anyone can be + * matched into, so an opponent's loadout is something a player is entitled to see before + * committing. Empty slots are omitted by the server. + */ + +const PET_EQUIPMENT_QUERY = ` + query PetEquipment($chain: String!, $petId: String!) { + petEquipment(chain: $chain, petId: $petId) { + slot + item { itemType key category slot rarity effect name description } + } + } +`; + +interface WireItem extends Omit { + effect: string | null; +} + +interface GraphQLResponse { + data?: { petEquipment: { slot: number; item: WireItem }[] }; + errors?: { message: string }[]; +} + +export interface UsePetEquipmentOptions { + chain: PetChain | null; + /** Pet id as a decimal string; the query is disabled until this is set. */ + petId: string | null; + enabled?: boolean; +} + +export interface UsePetEquipmentResult { + equipped: EquippedItem[]; + /** Lookup by slot index, for a UI drawing one tile per slot including the empty ones. */ + bySlot: Map; + isLoading: boolean; + error: Error | null; + refetch(): void; +} + +/** Query key, exported so an equip mutation can invalidate exactly this pet. */ +export function petEquipmentQueryKey(baseURL: string, chain: PetChain | null, petId: string | null): unknown[] { + return ['petEquipment', baseURL, chain, petId]; +} + +export const usePetEquipment = ({ + chain, + petId, + enabled = true, +}: UsePetEquipmentOptions): UsePetEquipmentResult => { + const apiClient = useApiClient(); + const { isAuthenticated } = useAuth(); + const baseURL = apiClient.defaults.baseURL ?? ''; + + const query = useQuery({ + queryKey: petEquipmentQueryKey(baseURL, chain, petId), + enabled: enabled && chain != null && petId != null && isAuthenticated, + queryFn: async () => { + const { data } = await apiClient.post('/graphql', { + query: PET_EQUIPMENT_QUERY, + variables: { chain, petId }, + }); + + if (data.errors?.length) { + throw new Error(data.errors.map((e) => e.message).join('; ')); + } + + return (data.data?.petEquipment ?? []).map((entry) => ({ + slot: entry.slot, + item: toItemDefinition(entry.item), + })); + }, + }); + + const equipped = query.data ?? []; + return { + equipped, + bySlot: new Map(equipped.map((entry) => [entry.slot, entry])), + isLoading: query.isLoading, + error: query.error as Error | null, + refetch: () => void query.refetch(), + }; +}; diff --git a/shared/src/hooks/inventory/useUseItem.ts b/shared/src/hooks/inventory/useUseItem.ts new file mode 100644 index 00000000..3131a31a --- /dev/null +++ b/shared/src/hooks/inventory/useUseItem.ts @@ -0,0 +1,65 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useApiClient } from '../../contexts/ApiClientContext'; +import type { PetChain } from '../../types/pet'; +import { inventoryQueryKey } from './useInventory'; + +/** + * Spending a consumable on a pet (roadmap §4). + * + * A REST call rather than a chain write, unlike equipping: the backend burns the item from + * its own authorized wallet after applying the effect, so the player signs nothing. That is + * the whole reason a consumable is one click and an equip is a wallet prompt. + */ + +export interface UseItemArgs { + chain: PetChain; + /** Pet id as a decimal string. */ + petId: string; + /** ERC-1155 token id as a decimal string. */ + itemType: string; +} + +/** What the server reports back: the burn, and the pet's progression after the effect. */ +export interface UseItemResult { + burnTxHash: string; + level: number; + xp: number; + /** Unix seconds the pet is next battle-ready, per the backend cooldown. */ + readyAt: number; + leveledUp: boolean; +} + +export interface UseUseItemResult { + useItem(args: UseItemArgs): Promise; + isPending: boolean; + error: Error | null; + reset(): void; +} + +export const useUseItem = (): UseUseItemResult => { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const baseURL = apiClient.defaults.baseURL ?? ''; + + const mutation = useMutation({ + mutationFn: async (args: UseItemArgs) => { + const { data } = await apiClient.post('/api/inventory/use', args); + return data; + }, + // Invalidated rather than patched, and never optimistically. The burn is a + // transaction: until the server says it landed, the item is still the player's, and + // a bag that already showed it gone would be lying about a spend that could fail. + // The pet's battle progression moved too, so the caller refreshes that itself — + // this hook does not know which pet query the screen is using. + onSuccess: (_result, args) => { + void queryClient.invalidateQueries({ queryKey: inventoryQueryKey(baseURL, args.chain) }); + }, + }); + + return { + useItem: mutation.mutateAsync, + isPending: mutation.isPending, + error: mutation.error as Error | null, + reset: mutation.reset, + }; +}; diff --git a/shared/src/node.ts b/shared/src/node.ts index 050a1b64..e78e147a 100644 --- a/shared/src/node.ts +++ b/shared/src/node.ts @@ -10,6 +10,22 @@ export type { BattleResolvedResult } from './types/battle'; // The chat reaction whitelist: the backend validates against the same list the client // offers, so a picker can never show an emoji the API refuses. export { CHAT_REACTIONS, isChatReaction, type ChatReaction } from './hooks/chat/reactions'; +// The item vocabulary (roadmap §4): the backend validates its catalog against the same +// types a client renders, so an added effect kind cannot land on one side only. +export { + describeItemEffect, + ITEM_CATEGORIES, + parseItemEffect, + SLOT, + SLOT_NAMES, + type EquippedItem, + type InventoryEntry, + type ItemCategory, + type ItemDefinition, + type ItemEffect, + type SlotName, + type StatBonus, +} from './types/item'; export { simulate, encodeSimOutcome, diff --git a/shared/src/types/item.ts b/shared/src/types/item.ts new file mode 100644 index 00000000..7742a80b --- /dev/null +++ b/shared/src/types/item.ts @@ -0,0 +1,150 @@ +/** + * The item vocabulary, shared by the backend and every client (roadmap §4). + * + * Here rather than in the backend feature for the same reason `CHAT_REACTIONS` is: both + * sides have to agree, and the way they agree is by importing one declaration instead of + * keeping two in step. The backend validates the catalog against these types and serializes + * `effect` as JSON; a client parses it back and renders it. A second copy would drift the + * first time an effect kind was added, and the symptom would be a client silently rendering + * nothing rather than an error anyone notices. + * + * Not in `@cryptopets/protocol`, deliberately. Nothing here is hashed or signed: a battle + * snapshot carries resolved stat numbers, not an item's declared effect, so the protocol + * package never needs this vocabulary and does not take a dependency it cannot have. + */ + +/** Equip slots, mirroring ItemCore.SLOT_*. The contract stays authoritative. */ +export const SLOT = { weapon: 0, armor: 1, trinket: 2 } as const; +export type SlotName = keyof typeof SLOT; + +/** Slot index back to its name, for a UI labelling what a pet is wearing. */ +export const SLOT_NAMES: Record = { 0: 'weapon', 1: 'armor', 2: 'trinket' }; + +export const ITEM_CATEGORIES = ['consumable', 'equipment', 'collectible', 'material'] as const; +export type ItemCategory = (typeof ITEM_CATEGORIES)[number]; + +/** + * Flat, non-negative additions to a pet's extracted attributes. + * + * Additive and non-negative only in v1, which roadmap §4 recommends and which removes a + * real hazard: the combat engine truncates to 16 bits with wraparound rather than clamping, + * so a negative modifier is one underflow away from a pet with 65,000 HP. + */ +export interface StatBonus { + kind: 'stat_bonus'; + hp: number; + atk: number; + def: number; + int: number; + mdef: number; +} + +/** + * Every effect v1 can apply. + * + * Breeding cooldowns are absent on purpose: they are on-chain state, and clearing one needs + * an authorized `PetCore` call the inventory feature does not have. + */ +export type ItemEffect = + | StatBonus + | { kind: 'grant_xp'; amount: number } + | { kind: 'clear_battle_cooldown' }; + +/** One catalog entry as the API returns it, with `effect` already parsed. */ +export interface ItemDefinition { + /** ERC-1155 token id as a decimal string. The join key everywhere. */ + itemType: string; + /** Stable content key, e.g. 'xp_potion_i'. */ + key: string; + category: ItemCategory; + /** Equip slot 0-2; null unless this is equipment. */ + slot: number | null; + /** 1-5, the same five tiers as pet rarity. */ + rarity: number; + effect: ItemEffect | null; + name: string; + description: string; +} + +/** One stack a wallet holds. */ +export interface InventoryEntry { + item: ItemDefinition; + /** Decimal string: a uint256 balance does not fit a JS number. */ + quantity: string; +} + +/** One filled equip slot on a pet. */ +export interface EquippedItem { + slot: number; + item: ItemDefinition; +} + +/** + * Parses the JSON string the API sends for `effect`. + * + * Returns null for anything unrecognised rather than throwing, matching how the backend + * reads the same column: on a render path an unknown effect should cost one item its + * label, not fail the whole bag. A client older than the effect kind it is looking at is + * the ordinary case here, not an error. + */ +export function parseItemEffect(value: string | null | undefined): ItemEffect | null { + if (!value) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + const record = parsed as Record; + + switch (record.kind) { + case 'stat_bonus': { + const fields = ['hp', 'atk', 'def', 'int', 'mdef'] as const; + if (fields.some((f) => !Number.isInteger(record[f]) || (record[f] as number) < 0)) { + return null; + } + const bonus: StatBonus = { kind: 'stat_bonus', hp: 0, atk: 0, def: 0, int: 0, mdef: 0 }; + for (const field of fields) { + bonus[field] = record[field] as number; + } + return bonus; + } + case 'grant_xp': + return Number.isInteger(record.amount) && (record.amount as number) > 0 + ? { kind: 'grant_xp', amount: record.amount as number } + : null; + case 'clear_battle_cooldown': + return { kind: 'clear_battle_cooldown' }; + default: + return null; + } +} + +/** + * A short human label for an effect, for a tooltip or a card line. + * + * Here rather than in a component so the web app and mobile describe an item the same way, + * and so a new effect kind has one place to be worded. + */ +export function describeItemEffect(effect: ItemEffect | null): string | null { + if (!effect) { + return null; + } + switch (effect.kind) { + case 'grant_xp': + return `Grants ${effect.amount} XP`; + case 'clear_battle_cooldown': + return 'Clears the battle cooldown'; + case 'stat_bonus': { + const parts = (['hp', 'atk', 'def', 'int', 'mdef'] as const) + .filter((field) => effect[field] > 0) + .map((field) => `+${effect[field]} ${field.toUpperCase()}`); + return parts.length > 0 ? parts.join(', ') : null; + } + } +} diff --git a/shared/tests/hooks/useInventory.test.tsx b/shared/tests/hooks/useInventory.test.tsx new file mode 100644 index 00000000..d23e8708 --- /dev/null +++ b/shared/tests/hooks/useInventory.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const post = vi.fn(); +const apiClient = { post, defaults: { baseURL: 'https://api.test' } }; +const auth = { isAuthenticated: true }; +vi.mock('../../src/contexts/ApiClientContext', () => ({ useApiClient: () => apiClient })); +vi.mock('../../src/contexts/AuthContext', () => ({ useAuth: () => auth })); + +import { useInventory } from '../../src/hooks/inventory/useInventory'; +import { usePetEquipment } from '../../src/hooks/inventory/usePetEquipment'; +import { useItemCatalog } from '../../src/hooks/inventory/useItemCatalog'; +import { useUseItem } from '../../src/hooks/inventory/useUseItem'; + +/** The wire shape: `effect` is a JSON string, as the server sends it. */ +const POTION = { + itemType: '100', + key: 'xp_potion_i', + category: 'consumable', + slot: null, + rarity: 1, + effect: '{"kind":"grant_xp","amount":50}', + name: 'Lesser Tonic', + description: 'Tastes of copper.', +}; + +const BADGE = { ...POTION, itemType: '201', key: 'founders_badge', category: 'collectible', effect: null }; + +const BLADE = { + ...POTION, + itemType: '1', + key: 'iron_fang', + category: 'equipment', + slot: 0, + effect: '{"kind":"stat_bonus","hp":0,"atk":4,"def":0,"int":0,"mdef":0}', + name: 'Iron Fang', +}; + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return {children}; +}; + +beforeEach(() => { + vi.clearAllMocks(); + auth.isAuthenticated = true; +}); + +describe('useInventory', () => { + beforeEach(() => { + post.mockResolvedValue({ data: { data: { inventory: [{ item: POTION, quantity: '3' }] } } }); + }); + + it('does not fetch without a chain', () => { + const { result } = renderHook(() => useInventory({ chain: null }), { wrapper }); + expect(post).not.toHaveBeenCalled(); + expect(result.current.entries).toEqual([]); + }); + + // Without a session the server has no owner to answer for and returns an empty bag, + // which would render as "you own nothing" rather than as "sign in". + it('does not fetch when unauthenticated', () => { + auth.isAuthenticated = false; + renderHook(() => useInventory({ chain: 'evm' }), { wrapper }); + expect(post).not.toHaveBeenCalled(); + }); + + it('parses the effect JSON the server sends as a string', async () => { + const { result } = renderHook(() => useInventory({ chain: 'evm' }), { wrapper }); + + await waitFor(() => expect(result.current.entries).toHaveLength(1)); + expect(result.current.entries[0]!.item.effect).toEqual({ kind: 'grant_xp', amount: 50 }); + expect(result.current.entries[0]!.quantity).toBe('3'); + }); + + // The owner is the session's, so the query carries no address at all — there is no + // spelling of this that reads another wallet's bag. + it('sends no owner argument', async () => { + renderHook(() => useInventory({ chain: 'evm' }), { wrapper }); + + await waitFor(() => expect(post).toHaveBeenCalled()); + const body = post.mock.calls[0]![1] as { query: string; variables: Record }; + expect(Object.keys(body.variables)).toEqual(['chain']); + expect(body.query).not.toContain('owner:'); + }); + + it('keeps a null effect null rather than inventing one', async () => { + post.mockResolvedValue({ data: { data: { inventory: [{ item: BADGE, quantity: '1' }] } } }); + const { result } = renderHook(() => useInventory({ chain: 'evm' }), { wrapper }); + + await waitFor(() => expect(result.current.entries).toHaveLength(1)); + expect(result.current.entries[0]!.item.effect).toBeNull(); + }); + + // A client older than the effect kind it is looking at is the ordinary case, not an + // error: the item still renders, without whatever it does. + it('drops an unrecognised effect kind without losing the item', async () => { + post.mockResolvedValue({ + data: { data: { inventory: [{ item: { ...POTION, effect: '{"kind":"teleport"}' }, quantity: '1' }] } }, + }); + const { result } = renderHook(() => useInventory({ chain: 'evm' }), { wrapper }); + + await waitFor(() => expect(result.current.entries).toHaveLength(1)); + expect(result.current.entries[0]!.item.effect).toBeNull(); + expect(result.current.entries[0]!.item.name).toBe('Lesser Tonic'); + }); + + it('surfaces a GraphQL error rather than an empty bag', async () => { + post.mockResolvedValue({ data: { errors: [{ message: 'boom' }] } }); + const { result } = renderHook(() => useInventory({ chain: 'evm' }), { wrapper }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.error!.message).toContain('boom'); + }); +}); + +describe('usePetEquipment', () => { + beforeEach(() => { + post.mockResolvedValue({ data: { data: { petEquipment: [{ slot: 0, item: BLADE }] } } }); + }); + + it('does not fetch without a pet id', () => { + renderHook(() => usePetEquipment({ chain: 'evm', petId: null }), { wrapper }); + expect(post).not.toHaveBeenCalled(); + }); + + it('indexes equipped items by slot', async () => { + const { result } = renderHook(() => usePetEquipment({ chain: 'evm', petId: '7' }), { wrapper }); + + await waitFor(() => expect(result.current.equipped).toHaveLength(1)); + expect(result.current.bySlot.get(0)!.item.key).toBe('iron_fang'); + expect(result.current.bySlot.get(1)).toBeUndefined(); + }); + + it('parses a stat bonus', async () => { + const { result } = renderHook(() => usePetEquipment({ chain: 'evm', petId: '7' }), { wrapper }); + + await waitFor(() => expect(result.current.equipped).toHaveLength(1)); + expect(result.current.equipped[0]!.item.effect).toEqual({ + kind: 'stat_bonus', hp: 0, atk: 4, def: 0, int: 0, mdef: 0, + }); + }); +}); + +describe('useItemCatalog', () => { + beforeEach(() => { + post.mockResolvedValue({ data: { data: { itemCatalog: [POTION, BLADE] } } }); + }); + + it('indexes the catalog by token id, which is what every read joins on', async () => { + const { result } = renderHook(() => useItemCatalog(), { wrapper }); + + await waitFor(() => expect(result.current.items).toHaveLength(2)); + expect(result.current.byType.get('1')!.key).toBe('iron_fang'); + expect(result.current.byType.get('100')!.key).toBe('xp_potion_i'); + }); + + it('does not fetch when unauthenticated', () => { + auth.isAuthenticated = false; + renderHook(() => useItemCatalog(), { wrapper }); + expect(post).not.toHaveBeenCalled(); + }); +}); + +describe('useUseItem', () => { + it('posts to the REST route, since the backend burns rather than the player signing', async () => { + post.mockResolvedValue({ data: { burnTxHash: '0xburn', level: 5, xp: 0, readyAt: 0, leveledUp: true } }); + const { result } = renderHook(() => useUseItem(), { wrapper }); + + const outcome = await result.current.useItem({ chain: 'evm', petId: '7', itemType: '100' }); + + expect(post).toHaveBeenCalledWith('/api/inventory/use', { chain: 'evm', petId: '7', itemType: '100' }); + expect(outcome.leveledUp).toBe(true); + }); + + // Never optimistic: the burn is a transaction, so until the server says it landed the + // item is still the player's, and a bag showing it gone would be lying about a spend + // that can still fail. + it('surfaces a rejected spend rather than reporting success', async () => { + post.mockRejectedValue(new Error('You do not hold that item')); + const { result } = renderHook(() => useUseItem(), { wrapper }); + + await expect( + result.current.useItem({ chain: 'evm', petId: '7', itemType: '100' }), + ).rejects.toThrow('You do not hold that item'); + }); +}); From 99532f74432b738d4672e8f1954e8d1dddcc6bc2 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 18:03:35 -0400 Subject: [PATCH 14/56] feat(inventory): add the inventory adapter for equip and unequip --- frontend/src/chains/ethereum/contracts.ts | 20 +- frontend/src/chains/ethereum/itemCoreAbi.json | 1101 +++++++++++++++++ frontend/src/petsContractParams.ts | 3 + shared/src/contexts/PetsConfigContext.tsx | 6 + shared/src/hooks/adapters/inventoryTypes.ts | 45 + .../src/hooks/adapters/useInventoryAdapter.ts | 117 ++ shared/src/hooks/index.ts | 5 + shared/src/hooks/inventory/useEquipItem.ts | 78 ++ .../tests/hooks/useInventoryAdapter.test.tsx | 148 +++ 9 files changed, 1522 insertions(+), 1 deletion(-) create mode 100644 frontend/src/chains/ethereum/itemCoreAbi.json create mode 100644 shared/src/hooks/adapters/inventoryTypes.ts create mode 100644 shared/src/hooks/adapters/useInventoryAdapter.ts create mode 100644 shared/src/hooks/inventory/useEquipItem.ts create mode 100644 shared/tests/hooks/useInventoryAdapter.test.tsx diff --git a/frontend/src/chains/ethereum/contracts.ts b/frontend/src/chains/ethereum/contracts.ts index 55655dfd..b099d85f 100644 --- a/frontend/src/chains/ethereum/contracts.ts +++ b/frontend/src/chains/ethereum/contracts.ts @@ -2,12 +2,16 @@ import type { Abi } from 'viem'; import petCoreAbi from '@chains/ethereum/petCoreAbi.json'; import gameLogicAbi from '@chains/ethereum/gameLogicAbi.json'; import gameConfigAbi from '@chains/ethereum/gameConfigAbi.json'; +import itemCoreAbi from '@chains/ethereum/itemCoreAbi.json'; /** - * v2 EVM contract surface. The monolithic v1 contract is split into three units: + * v2 EVM contract surface. The monolithic v1 contract is split into three units, with a + * fourth added for inventory: * - PetCore (proxy) — ERC-721 storage, mint, rename, level/XP, cooldowns, marriage. * - GameLogic (proxy) — async breed/mint (request → settle) + entropy wiring. * - GameConfig — tunable fees / cooldowns / XP-curve / skill params (read for UI). + * - ItemCore (proxy) — ERC-1155 inventory: balances, and the equip/unequip the player + * signs themselves (roadmap §4). Optional; see below. * * CombatSim is deliberately absent: battles are resolved by the backend and replayed * from the signed receipt (§L Phase 6), so no client ever calls the on-chain sim. @@ -15,6 +19,8 @@ import gameConfigAbi from '@chains/ethereum/gameConfigAbi.json'; * Addresses come from env (per-deployment) and fall back to the current * Sepolia (chain 11155111) deployment so local dev works out of the box. * See `contracts/ethereum/ignition/deployments/chain-11155111/deployed_addresses.json`. + * ItemCore is the exception: it has no Sepolia deployment to fall back to, so it is + * undefined until its env var is set. */ const SEPOLIA_PETCORE = '0xD94B02fC6238AcE5c0Fd767bFf8f5A1FCD9B59DB'; const SEPOLIA_GAMELOGIC = '0x87E3E1e3EB22eC45fB99715BdF91911697997Be4'; @@ -40,8 +46,20 @@ const gameConfigContract: EvmContract = { abi: gameConfigAbi.abi as Abi, }; +/** + * ItemCore (roadmap §4). Unlike the three above it has no fallback address, because it has + * no Sepolia deployment to fall back to — it is new. Undefined without the env var, which + * the inventory adapter reads as "this deployment cannot equip" and surfaces as a disabled + * control rather than a button that reverts. + */ +const itemCoreAddress = import.meta.env.VITE_ITEMCORE_ADDRESS as `0x${string}` | undefined; +const itemCoreContract: EvmContract | undefined = itemCoreAddress + ? { address: itemCoreAddress, abi: itemCoreAbi.abi as Abi } + : undefined; + export const evmContracts = { petCore: petCoreContract, gameLogic: gameLogicContract, gameConfig: gameConfigContract, + itemCore: itemCoreContract, } as const; diff --git a/frontend/src/chains/ethereum/itemCoreAbi.json b/frontend/src/chains/ethereum/itemCoreAbi.json new file mode 100644 index 00000000..2184128d --- /dev/null +++ b/frontend/src/chains/ethereum/itemCoreAbi.json @@ -0,0 +1,1101 @@ +{ + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "previousAdmin", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "AdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "beacon", + "type": "address" + } + ], + "name": "BeaconUpgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "CallerAuthorized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "CallerRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "slot", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "ItemEquipped", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + } + ], + "name": "ItemSlotCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "slot", + "type": "uint8" + } + ], + "name": "ItemSlotRegistered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint8", + "name": "slot", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "ItemUnequipped", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "uri", + "type": "string" + } + ], + "name": "ItemUriUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "quantity", + "type": "uint256" + } + ], + "name": "ItemsBurned", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "quantity", + "type": "uint256" + } + ], + "name": "ItemsMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "ids", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + } + ], + "name": "TransferBatch", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "id", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "TransferSingle", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "value", + "type": "string" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "id", + "type": "uint256" + } + ], + "name": "URI", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ITEM_URI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "SLOT_ARMOR", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "SLOT_COUNT", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "SLOT_TRINKET", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "SLOT_WEAPON", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "authorizeCaller", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "authorizedCallers", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint256", + "name": "id", + "type": "uint256" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "accounts", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "ids", + "type": "uint256[]" + } + ], + "name": "balanceOfBatch", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "quantity", + "type": "uint256" + } + ], + "name": "burnFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + } + ], + "name": "clearItemSlot", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "slot", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + } + ], + "name": "equip", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "equipmentOf", + "outputs": [ + { + "internalType": "uint256[3]", + "name": "items", + "type": "uint256[3]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "slot", + "type": "uint8" + } + ], + "name": "equippedItem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "petCore_", + "type": "address" + }, + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "quantity", + "type": "uint256" + } + ], + "name": "mintTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC1155BatchReceived", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC1155Received", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "petCore", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "slot", + "type": "uint8" + } + ], + "name": "registerItemSlot", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "revokeCaller", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "ids", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "safeBatchTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "id", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "petCore_", + "type": "address" + } + ], + "name": "setPetCore", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "newUri", + "type": "string" + } + ], + "name": "setUri", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "itemType", + "type": "uint256" + } + ], + "name": "slotOf", + "outputs": [ + { + "internalType": "bool", + "name": "isEquipment", + "type": "bool" + }, + { + "internalType": "uint8", + "name": "slot", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "slot", + "type": "uint8" + } + ], + "name": "unequip", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + } + ], + "name": "upgradeTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "uri", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} diff --git a/frontend/src/petsContractParams.ts b/frontend/src/petsContractParams.ts index 84a9ac7f..5f600893 100644 --- a/frontend/src/petsContractParams.ts +++ b/frontend/src/petsContractParams.ts @@ -10,6 +10,9 @@ export const petsContractParams: PetsEvmConfig = { petCore: evmContracts.petCore, gameLogic: evmContracts.gameLogic, gameConfig: evmContracts.gameConfig, + // Undefined unless VITE_ITEMCORE_ADDRESS is set: no deployment to fall back to yet, so + // equipping stays unavailable rather than pointing at a wrong address. + itemCore: evmContracts.itemCore, enabled: true, chainId: evmChainId, }; diff --git a/shared/src/contexts/PetsConfigContext.tsx b/shared/src/contexts/PetsConfigContext.tsx index 2343c397..1e0944ca 100644 --- a/shared/src/contexts/PetsConfigContext.tsx +++ b/shared/src/contexts/PetsConfigContext.tsx @@ -19,6 +19,12 @@ export interface PetsEvmConfig { petCore: EvmContractRef; gameLogic: EvmContractRef; gameConfig?: EvmContractRef; + /** + * ItemCore (roadmap §4). Optional, like GameConfig: a deployment without it still + * runs, and only equipping goes unavailable. Reads and consumables go through the + * backend, so the app degrades to "gear is read-only" rather than to a blank screen. + */ + itemCore?: EvmContractRef; enabled?: boolean; /** EVM chain ID the contracts are deployed on. Passed to read hooks so they * use the right RPC regardless of which chain the wallet is connected to. */ diff --git a/shared/src/hooks/adapters/inventoryTypes.ts b/shared/src/hooks/adapters/inventoryTypes.ts new file mode 100644 index 00000000..4657f895 --- /dev/null +++ b/shared/src/hooks/adapters/inventoryTypes.ts @@ -0,0 +1,45 @@ +import type { AdapterMutation } from './types'; + +/** + * The chain-blind surface for inventory *writes that the player signs* (roadmap §4). + * + * A separate interface from `ChainAdapter`, not an extension of it. `AGENTS.md` forbids + * growing that one, and §4 names this case: new domains reuse the pattern (thin interface, + * per-chain implementation, a `useXAdapter()` that picks the active one) rather than the + * interface itself. The practical reason is that `ChainAdapter` is about pets, and a + * consumer holding one should not have to know whether items exist. + * + * Only equip and unequip live here, and the boundary is not arbitrary. `ItemCore.equip` + * requires `msg.sender` to be the pet's owner, so those two can only ever be sent by the + * player's own wallet. Everything else — spending a consumable, claiming a drop — is + * settled by the backend's authorized wallet and reaches the server over REST, which is + * why `useUseItem` is a plain mutation and these are wallet prompts. + */ + +export interface EquipArgs { + /** Pet id as a decimal string. */ + petId: string; + /** Equip slot 0-2 (ItemCore.SLOT_*). */ + slot: number; + /** ERC-1155 token id as a decimal string. */ + itemType: string; +} + +export interface UnequipArgs { + petId: string; + slot: number; +} + +export interface InventoryAdapter { + kind: 'evm' | 'solana' | 'none'; + /** + * Whether this chain can equip at all. + * + * False on Solana, which has no item contract yet (§4 is EVM-first), and false on EVM + * when `itemCore` is unconfigured. A UI reads this to disable the control with a reason + * rather than offering a button that throws. + */ + canEquip: boolean; + equip: AdapterMutation; + unequip: AdapterMutation; +} diff --git a/shared/src/hooks/adapters/useInventoryAdapter.ts b/shared/src/hooks/adapters/useInventoryAdapter.ts new file mode 100644 index 00000000..6d072b10 --- /dev/null +++ b/shared/src/hooks/adapters/useInventoryAdapter.ts @@ -0,0 +1,117 @@ +import { useWaitForTransactionReceipt, useWriteContract } from 'wagmi'; + +import { usePetsConfig } from '../../contexts/PetsConfigContext'; +import { useActiveChain } from '../session/useActiveChain'; +import type { AdapterMutation, TxLifecycle, TxPhase } from './types'; +import type { EquipArgs, InventoryAdapter, UnequipArgs } from './inventoryTypes'; + +/** + * The active chain's inventory adapter (roadmap §4). + * + * Mirrors `useChainAdapter`'s shape: both branches are evaluated every render (rules of + * hooks) and the inactive one simply refuses to write. There is no Solana implementation + * to mount, because that chain has no item contract — §4 is EVM-first — so the Solana case + * is the same disabled adapter as "no wallet connected". + */ + +type WriteState = { + writeContractAsync: (args: never) => Promise<`0x${string}`>; + data?: `0x${string}`; + isPending: boolean; + error: unknown; + reset: () => void; +}; +type ReceiptState = { isSuccess: boolean; isError: boolean; error: unknown }; + +/** Same projection `useEvmAdapter` uses, so both adapters report a transaction alike. */ +const toLifecycle = (w: WriteState, r: ReceiptState): TxLifecycle => { + const writeError = w.error as Error | null; + const receiptError = r.isError ? (r.error as Error | null) : null; + const error = writeError ?? receiptError; + let phase: TxPhase = 'idle'; + if (error) phase = 'error'; + else if (r.isSuccess) phase = 'success'; + else if (w.data) phase = 'confirming'; + else if (w.isPending) phase = 'awaiting-wallet'; + return { phase, hash: w.data, error, reset: w.reset }; +}; + +const isInFlight = (w: WriteState, r: ReceiptState): boolean => + w.isPending || (!!w.data && !r.isSuccess && !r.isError); + +const IDLE_LIFECYCLE: TxLifecycle = { phase: 'idle', error: null, reset: () => {} }; + +/** The adapter a chain with no item contract presents: honest, and never throws silently. */ +const disabledAdapter = (kind: InventoryAdapter['kind'], reason: string): InventoryAdapter => ({ + kind, + canEquip: false, + equip: { + mutateAsync: () => Promise.reject(new Error(reason)), + lifecycle: IDLE_LIFECYCLE, + isPending: false, + }, + unequip: { + mutateAsync: () => Promise.reject(new Error(reason)), + lifecycle: IDLE_LIFECYCLE, + isPending: false, + }, +}); + +export const useInventoryAdapter = (): InventoryAdapter => { + const chain = useActiveChain(); + const { evm } = usePetsConfig(); + + const itemCoreAddress = evm?.itemCore?.address; + const itemCoreAbi = evm?.itemCore?.abi ?? []; + const canEquip = chain.kind === 'evm' && Boolean(itemCoreAddress); + + // One write hook per action, so an equip in flight does not blank an unequip's error. + const equipW = useWriteContract(); + const unequipW = useWriteContract(); + const equipR = useWaitForTransactionReceipt({ hash: equipW.data, query: { enabled: !!equipW.data } }); + const unequipR = useWaitForTransactionReceipt({ hash: unequipW.data, query: { enabled: !!unequipW.data } }); + + const equip: AdapterMutation = { + async mutateAsync({ petId, slot, itemType }) { + if (!canEquip) throw new Error('ItemCore is not configured on this deployment'); + await equipW.writeContractAsync({ + address: itemCoreAddress, + abi: itemCoreAbi, + functionName: 'equip', + // Pet id and item type are uint256 on chain and decimal strings here, + // because both can exceed what a JS number holds. The slot cannot. + args: [BigInt(petId), slot, BigInt(itemType)], + chainId: evm?.chainId, + } as unknown as Parameters[0]); + }, + lifecycle: toLifecycle(equipW as WriteState, equipR), + isPending: isInFlight(equipW as WriteState, equipR), + }; + + const unequip: AdapterMutation = { + async mutateAsync({ petId, slot }) { + if (!canEquip) throw new Error('ItemCore is not configured on this deployment'); + await unequipW.writeContractAsync({ + address: itemCoreAddress, + abi: itemCoreAbi, + functionName: 'unequip', + args: [BigInt(petId), slot], + chainId: evm?.chainId, + } as unknown as Parameters[0]); + }, + lifecycle: toLifecycle(unequipW as WriteState, unequipR), + isPending: isInFlight(unequipW as WriteState, unequipR), + }; + + if (chain.kind === 'solana') { + // Not a gap to fill later so much as the current scope: §4 validates the item and + // equip model on EVM before porting it, and an SPL Token-2022 mint per item type is + // a different shape from an ERC-1155 id. + return disabledAdapter('solana', 'Items are not available on Solana yet'); + } + if (!canEquip) { + return disabledAdapter(chain.kind === 'evm' ? 'evm' : 'none', 'ItemCore is not configured on this deployment'); + } + + return { kind: 'evm', canEquip, equip, unequip }; +}; diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 6a84e6d8..547f91c2 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -159,6 +159,11 @@ export { type UseItemResult, type UseUseItemResult, } from './inventory/useUseItem'; +// Equipping is a chain write the player signs, so it goes through its own adapter rather +// than ChainAdapter: AGENTS.md forbids growing that interface, and §4 names this case. +export { useEquipItem, type UseEquipItemOptions, type UseEquipItemResult } from './inventory/useEquipItem'; +export { useInventoryAdapter } from './adapters/useInventoryAdapter'; +export type { EquipArgs, InventoryAdapter, UnequipArgs } from './adapters/inventoryTypes'; export { describeItemEffect, ITEM_CATEGORIES, diff --git a/shared/src/hooks/inventory/useEquipItem.ts b/shared/src/hooks/inventory/useEquipItem.ts new file mode 100644 index 00000000..f7901936 --- /dev/null +++ b/shared/src/hooks/inventory/useEquipItem.ts @@ -0,0 +1,78 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useApiClient } from '../../contexts/ApiClientContext'; +import { useInventoryAdapter } from '../adapters/useInventoryAdapter'; +import type { TxLifecycle } from '../adapters/types'; +import type { PetChain } from '../../types/pet'; +import { inventoryQueryKey } from './useInventory'; +import { petEquipmentQueryKey } from './usePetEquipment'; + +/** + * Equipping and unequipping (roadmap §4). + * + * The player signs these, unlike spending a consumable: `ItemCore.equip` requires + * `msg.sender` to be the pet's owner, so the backend physically cannot send one. That is + * the property making gear in a battle snapshot checkable against chain state by an + * outsider, so it is worth the wallet prompt. + * + * What this adds over calling the adapter directly is knowing what to invalidate. Equipping + * escrows the token into the contract, so it moves the pet's slots *and* the wallet's + * balance, and a call site that refreshed only the first would leave the bag showing an + * item that is no longer there. + */ + +export interface UseEquipItemOptions { + chain: PetChain | null; + /** Pet whose slots to refresh once the transaction lands. */ + petId: string | null; +} + +export interface UseEquipItemResult { + /** False when the chain has no item contract; render a reason, not a dead button. */ + canEquip: boolean; + equip(slot: number, itemType: string): Promise; + unequip(slot: number): Promise; + equipLifecycle: TxLifecycle; + unequipLifecycle: TxLifecycle; + isPending: boolean; +} + +export const useEquipItem = ({ chain, petId }: UseEquipItemOptions): UseEquipItemResult => { + const adapter = useInventoryAdapter(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + const baseURL = apiClient.defaults.baseURL ?? ''; + + /** + * Refreshes both views after a confirmed transaction. + * + * Worth being honest about the timing: these read the indexed projection, not the + * chain, so the new state only appears once indexer-go has seen the event and written + * it. Invalidating here starts that catch-up rather than completing it, and a UI should + * expect one poll interval of lag rather than an instant swap. Reading the contract + * directly would remove the lag and reintroduce two sources of truth for what a pet is + * wearing, which is the thing the indexed table exists to prevent. + */ + const refresh = async (): Promise => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: petEquipmentQueryKey(baseURL, chain, petId) }), + queryClient.invalidateQueries({ queryKey: inventoryQueryKey(baseURL, chain) }), + ]); + }; + + return { + canEquip: adapter.canEquip, + equip: async (slot, itemType) => { + if (!petId) throw new Error('No pet selected'); + await adapter.equip.mutateAsync({ petId, slot, itemType }); + await refresh(); + }, + unequip: async (slot) => { + if (!petId) throw new Error('No pet selected'); + await adapter.unequip.mutateAsync({ petId, slot }); + await refresh(); + }, + equipLifecycle: adapter.equip.lifecycle, + unequipLifecycle: adapter.unequip.lifecycle, + isPending: adapter.equip.isPending || adapter.unequip.isPending, + }; +}; diff --git a/shared/tests/hooks/useInventoryAdapter.test.tsx b/shared/tests/hooks/useInventoryAdapter.test.tsx new file mode 100644 index 00000000..c5690b84 --- /dev/null +++ b/shared/tests/hooks/useInventoryAdapter.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const writeContractAsync = vi.fn(); +const writeState = { writeContractAsync, data: undefined as string | undefined, isPending: false, error: null, reset: vi.fn() }; +const receiptState = { isSuccess: false, isError: false, error: null }; + +vi.mock('wagmi', () => ({ + useWriteContract: () => writeState, + useWaitForTransactionReceipt: () => receiptState, +})); + +const chain = { kind: 'evm' as 'evm' | 'solana' | 'none' }; +vi.mock('../../src/hooks/session/useActiveChain', () => ({ useActiveChain: () => chain })); + +const config = { + evm: { + petCore: { address: '0xpet', abi: [] }, + gameLogic: { address: '0xlogic', abi: [] }, + itemCore: { address: '0xitem' as string | undefined, abi: [] }, + chainId: 31337, + } as Record | null, +}; +vi.mock('../../src/contexts/PetsConfigContext', () => ({ usePetsConfig: () => config })); + +const apiClient = { post: vi.fn(), defaults: { baseURL: 'https://api.test' } }; +vi.mock('../../src/contexts/ApiClientContext', () => ({ useApiClient: () => apiClient })); + +import { useInventoryAdapter } from '../../src/hooks/adapters/useInventoryAdapter'; +import { useEquipItem } from '../../src/hooks/inventory/useEquipItem'; + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return {children}; +}; + +beforeEach(() => { + vi.clearAllMocks(); + chain.kind = 'evm'; + writeState.data = undefined; + writeState.isPending = false; + writeState.error = null; + receiptState.isSuccess = false; + receiptState.isError = false; + (config.evm as Record).itemCore = { address: '0xitem', abi: [] }; + writeContractAsync.mockResolvedValue('0xhash'); +}); + +describe('useInventoryAdapter', () => { + // Pet id and item type are uint256 on chain, so they cross as bigints; the slot is a + // uint8 and stays a number. + it('sends equip with the ids widened to bigint and the slot left a number', async () => { + const { result } = renderHook(() => useInventoryAdapter(), { wrapper }); + + await result.current.equip.mutateAsync({ petId: '7', slot: 0, itemType: '100' }); + + expect(writeContractAsync).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xitem', + functionName: 'equip', + args: [7n, 0, 100n], + chainId: 31337, + }), + ); + }); + + it('sends unequip with just the pet and slot', async () => { + const { result } = renderHook(() => useInventoryAdapter(), { wrapper }); + + await result.current.unequip.mutateAsync({ petId: '7', slot: 2 }); + + expect(writeContractAsync).toHaveBeenCalledWith( + expect.objectContaining({ functionName: 'unequip', args: [7n, 2] }), + ); + }); + + // Solana has no item contract: §4 validates the model on EVM before porting, and an + // SPL Token-2022 mint per type is a different shape from an ERC-1155 id. + it('reports itself disabled on Solana rather than offering a button that throws', async () => { + chain.kind = 'solana'; + const { result } = renderHook(() => useInventoryAdapter(), { wrapper }); + + expect(result.current.kind).toBe('solana'); + expect(result.current.canEquip).toBe(false); + await expect(result.current.equip.mutateAsync({ petId: '7', slot: 0, itemType: '1' })).rejects.toThrow( + /not available on Solana/, + ); + expect(writeContractAsync).not.toHaveBeenCalled(); + }); + + // Optional config, like GameConfig: a deployment without ItemCore still runs, and only + // equipping goes unavailable. + it('reports itself disabled when ItemCore is unconfigured', async () => { + (config.evm as Record).itemCore = undefined; + const { result } = renderHook(() => useInventoryAdapter(), { wrapper }); + + expect(result.current.canEquip).toBe(false); + await expect(result.current.equip.mutateAsync({ petId: '7', slot: 0, itemType: '1' })).rejects.toThrow( + /not configured/, + ); + }); + + it('projects the write and receipt state into one lifecycle', () => { + writeState.isPending = true; + const { result } = renderHook(() => useInventoryAdapter(), { wrapper }); + expect(result.current.equip.lifecycle.phase).toBe('awaiting-wallet'); + expect(result.current.equip.isPending).toBe(true); + }); + + it('reports success only once the receipt lands, not when the hash appears', () => { + writeState.data = '0xhash'; + const { result, rerender } = renderHook(() => useInventoryAdapter(), { wrapper }); + expect(result.current.equip.lifecycle.phase).toBe('confirming'); + + receiptState.isSuccess = true; + rerender(); + expect(result.current.equip.lifecycle.phase).toBe('success'); + }); +}); + +describe('useEquipItem', () => { + // Equipping escrows the token, so it moves the pet's slots and the wallet's balance. + // Refreshing only the first would leave the bag showing an item that is no longer there. + it('invalidates both the pet equipment and the inventory after a confirmed equip', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidate = vi.spyOn(client, 'invalidateQueries'); + const localWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useEquipItem({ chain: 'evm', petId: '7' }), { wrapper: localWrapper }); + await result.current.equip(0, '100'); + + await waitFor(() => expect(invalidate).toHaveBeenCalledTimes(2)); + const keys = invalidate.mock.calls.map((call) => (call[0] as { queryKey: unknown[] }).queryKey[0]); + expect(new Set(keys)).toEqual(new Set(['petEquipment', 'inventory'])); + }); + + it('refuses without a selected pet rather than sending a transaction', async () => { + const { result } = renderHook(() => useEquipItem({ chain: 'evm', petId: null }), { wrapper }); + + await expect(result.current.equip(0, '100')).rejects.toThrow('No pet selected'); + expect(writeContractAsync).not.toHaveBeenCalled(); + }); +}); From d094c2a420c7b2d58690ab5fb707ba6ec21a007f Mon Sep 17 00:00:00 2001 From: heyradcode Date: Fri, 7 Aug 2026 18:48:39 -0400 Subject: [PATCH 15/56] feat(inventory): add the inventory page --- backend/src/features/inventory/index.ts | 2 + .../features/inventory/inventory.service.ts | 46 ++++ backend/src/graphql/resolvers.ts | 30 ++- backend/src/graphql/schema.ts | 29 ++ .../src/repositories/inventory.repository.ts | 23 ++ backend/tests/graphql/schema.test.ts | 3 +- .../src/components/inventory/index.module.css | 245 +++++++++++++++++ frontend/src/components/inventory/index.tsx | 252 ++++++++++++++++++ .../components/layout/sidebar/nav-items.ts | 3 +- frontend/src/constants/interactionRoutes.ts | 3 + frontend/src/pages/inventory/index.tsx | 7 + frontend/src/router/app-routes/index.tsx | 2 + shared/src/hooks/index.ts | 18 +- shared/src/hooks/inventory/usePendingItems.ts | 123 +++++++++ .../{useUseItem.ts => useSpendItem.ts} | 21 +- shared/tests/hooks/useInventory.test.tsx | 12 +- 16 files changed, 791 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/inventory/index.module.css create mode 100644 frontend/src/components/inventory/index.tsx create mode 100644 frontend/src/pages/inventory/index.tsx create mode 100644 shared/src/hooks/inventory/usePendingItems.ts rename shared/src/hooks/inventory/{useUseItem.ts => useSpendItem.ts} (75%) diff --git a/backend/src/features/inventory/index.ts b/backend/src/features/inventory/index.ts index fd89ae40..1826a430 100644 --- a/backend/src/features/inventory/index.ts +++ b/backend/src/features/inventory/index.ts @@ -6,9 +6,11 @@ export { getCatalog, getEquipmentForPets, getInventory, + getPendingItems, getPetEquipment, type EquippedItem, type InventoryEntry, + type PendingItem, type ItemView, } from './inventory.service'; export { diff --git a/backend/src/features/inventory/inventory.service.ts b/backend/src/features/inventory/inventory.service.ts index 8c3b4c9d..15596f51 100644 --- a/backend/src/features/inventory/inventory.service.ts +++ b/backend/src/features/inventory/inventory.service.ts @@ -6,6 +6,7 @@ import { findDefinitions, findEquipment, findEquipmentForPets, + findUnclaimedEntitlements, type ItemDefinitionRow, } from '@repositories/inventory.repository'; @@ -52,6 +53,51 @@ export interface EquippedItem { item: ItemView; } +/** An item a wallet has earned but not yet minted. */ +export interface PendingItem { + entitlementId: string; + item: ItemView; + quantity: number; + /** 'battle_drop' | 'admin_grant'. */ + source: string; + /** The battle id for a drop, so a UI can say which fight paid it. */ + sourceRef: string; + createdAt: string; +} + +/** + * What a wallet has earned but not claimed. + * + * Its own read rather than part of `getInventory`, because these are not items yet: they + * are a promise of one, and nothing on chain reflects them until a claim mints. Folding + * them into the bag would show a player a stack they cannot spend. + */ +export async function getPendingItems(chain: string, owner: string): Promise { + const rows = await findUnclaimedEntitlements(chain, normalizeAccount(owner)); + if (rows.length === 0) { + return []; + } + + const catalog = await definitionsByType(rows.map((row) => row.itemType)); + const pending: PendingItem[] = []; + for (const row of rows) { + const definition = catalog.get(row.itemType); + if (!definition) { + console.warn(`[inventory] entitlement ${row.id} names uncatalogued item type ${row.itemType}; hidden`); + continue; + } + pending.push({ + entitlementId: row.id, + item: definition, + quantity: row.quantity, + source: row.source, + sourceRef: row.sourceRef, + createdAt: row.createdAt.toISOString(), + }); + } + return pending; +} + export async function getCatalog(): Promise { return (await findAllDefinitions()).map(toItemView); } diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 461a7b69..420e53b5 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -6,7 +6,7 @@ import { findPlayerRank, } from '@repositories/leaderboard.repository'; import { tryGrpcEstimateWin } from '@grpc-client/estimateWin'; -import { getCatalog, getInventory, getPetEquipment, type ItemView } from '@features/inventory'; +import { getCatalog, getInventory, getPendingItems, getPetEquipment, type ItemView } from '@features/inventory'; import { isSupportedChain, SUPPORTED_CHAINS } from '@typings/chain'; const DEFAULT_PAGE_SIZE = 20; @@ -69,12 +69,6 @@ export interface GraphQLContext { caller: string; } -/** - * Project a RosterPet to the GraphQL `OpponentPet` shape: rename `petId` → `id` - * and coerce the bigint unix-seconds cooldowns to Float (GraphQL has no bigint). - * Shared by the opponents list and the single-pet detail read so both stay in - * lockstep. - */ /** * Project an `ItemView` to the GraphQL `ItemDefinition` shape. * @@ -88,6 +82,12 @@ function toItemDefinition(item: ItemView) { return { ...item, effect: item.effect ? JSON.stringify(item.effect) : null }; } +/** + * Project a RosterPet to the GraphQL `OpponentPet` shape: rename `petId` → `id` + * and coerce the bigint unix-seconds cooldowns to Float (GraphQL has no bigint). + * Shared by the opponents list and the single-pet detail read so both stay in + * lockstep. + */ function toOpponentPet({ petId: id, readyAt, breedReadyAt, trainReadyAt, ...rest }: RosterPet) { return { id, @@ -261,6 +261,22 @@ export const rootValue = { })); }, + pendingItems: async (args: { chain: string }, context: GraphQLContext) => { + if (!isSupportedChain(args.chain)) { + throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); + } + + // Same rule as `inventory`: an unauthenticated caller has nothing waiting rather + // than an error, and the owner is never an argument. + if (!context.caller) { + return []; + } + return (await getPendingItems(args.chain, context.caller)).map((pending) => ({ + ...pending, + item: toItemDefinition(pending.item), + })); + }, + petEquipment: async (args: PetEquipmentArgs) => { if (!isSupportedChain(args.chain)) { throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 7be6368f..b17a0a08 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -162,6 +162,26 @@ export const schema = buildSchema(` item: ItemDefinition! } + """ + An item a wallet has earned but not yet minted: a battle drop, or an admin grant. + + Not an item yet, which is why it is its own type rather than an entry in the bag. + Nothing on chain reflects it until the claim mints, so folding these into the inventory + read would show a player a stack they cannot spend. + """ + type PendingItem { + "Pass this to POST /api/inventory/entitlements/:id/claim." + entitlementId: String! + item: ItemDefinition! + quantity: Int! + "'battle_drop' | 'admin_grant'." + source: String! + "The battle id for a drop, so a client can say which fight paid it." + sourceRef: String! + "ISO 8601." + createdAt: String! + } + type Query { opponents( chain: String! @@ -285,5 +305,14 @@ export const schema = buildSchema(` checkable without making it more private. """ petEquipment(chain: String!, petId: String!): [EquippedItem!]! + + """ + The caller's unclaimed items, newest first. + + Owner comes from the session, as with the inventory read. Claimed entitlements are + omitted: by then the item is in the bag, and listing both would show one drop twice + on a screen whose job is "here is what is waiting". + """ + pendingItems(chain: String!): [PendingItem!]! } `); diff --git a/backend/src/repositories/inventory.repository.ts b/backend/src/repositories/inventory.repository.ts index 617dad83..4273f7a8 100644 --- a/backend/src/repositories/inventory.repository.ts +++ b/backend/src/repositories/inventory.repository.ts @@ -68,6 +68,29 @@ export function findBalance(chain: string, owner: string, itemType: string): Pro }); } +export interface EntitlementRow { + id: string; + itemType: string; + quantity: number; + source: string; + sourceRef: string; + createdAt: Date; +} + +/** + * A wallet's unclaimed entitlements, newest first. + * + * Unclaimed only. A claimed one is just an item in the bag by then, and listing both would + * make the same drop appear twice on a screen whose whole job is "here is what is waiting". + */ +export function findUnclaimedEntitlements(chain: string, owner: string): Promise { + return prisma.itemEntitlement.findMany({ + where: { chain, owner, claimedAt: null }, + select: { id: true, itemType: true, quantity: true, source: true, sourceRef: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }); +} + /** One pet's filled slots. Item type "0" means empty, so those are dropped. */ export function findEquipment(chain: string, petId: string): Promise { return prisma.petEquipment.findMany({ diff --git a/backend/tests/graphql/schema.test.ts b/backend/tests/graphql/schema.test.ts index 58b88f28..662e0947 100644 --- a/backend/tests/graphql/schema.test.ts +++ b/backend/tests/graphql/schema.test.ts @@ -24,7 +24,8 @@ describe('GraphQL schema — Query surface', () => { it('exposes the pet reads, both leaderboards, battleProgress, winEstimate, and the inventory reads', () => { expect(Object.keys(query).sort()).toEqual([ 'allPets', 'battleProgress', 'inventory', 'itemCatalog', 'leaderboard', 'opponents', - 'pet', 'petEquipment', 'playerLeaderboard', 'playerRank', 'searchPets', 'winEstimate', + 'pendingItems', 'pet', 'petEquipment', 'playerLeaderboard', 'playerRank', 'searchPets', + 'winEstimate', ]); }); diff --git a/frontend/src/components/inventory/index.module.css b/frontend/src/components/inventory/index.module.css new file mode 100644 index 00000000..cc739fcd --- /dev/null +++ b/frontend/src/components/inventory/index.module.css @@ -0,0 +1,245 @@ +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + + Inventory: a bag, grouped by what you can do with a thing. + + The grouping is the design. Categories here are not a taxonomy laid over the items, they + are the set of available actions: a consumable has a button, equipment goes on a pet + somewhere else, a collectible does nothing. Sorted together, the screen's only real + question — what can I use right now — becomes the hardest one to answer. + + Rarity is the one colour that carries meaning, and it comes from the same + `getRarityColor` the pet cards use, exposed per card as `--rarity`. Amber is the page's + own accent, matching the sidebar entry, and never competes with a rarity: the accent + belongs to the chrome, the rarity to the contents. */ + +/* Ends at the bottom of the window rather than at the bottom of its rows, so a long bag + scrolls inside the panel instead of pushing the page. `height: 100%` rather than a flex + rule, for the reason the leaderboard's stylesheet spells out: the shell's content slot + is a scrollable block, so a `flex` declaration on the panel is inert. */ +.page { + height: 100%; +} + +.sectionTitle { + margin: 18px 0 10px; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(251 191 36 / 75%); +} + +.grid { + display: grid; + /* Auto-fill rather than a fixed column count: the bag is as wide as the window allows, + and a fixed grid would leave a single potion stranded in a narrow column. */ + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 12px; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 14px 14px 18px; + border: 1px solid rgb(255 255 255 / 8%); + border-radius: 12px; + background: rgb(255 255 255 / 3%); + overflow: hidden; +} + +/* The rarity reads as a stripe down the edge rather than as a border around the whole + card. A full border on five tiers turns a grid into a colour chart; a stripe stays + legible when nine of them sit side by side. */ +.card::before { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--rarity, rgb(255 255 255 / 20%)); +} + +.cardHead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.cardName { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} + +.quantity { + font-variant-numeric: tabular-nums; + font-size: 0.85rem; + opacity: 0.7; + flex-shrink: 0; +} + +.rarity { + margin: 0; + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--rarity, inherit); +} + +.effect { + margin: 2px 0 0; + font-size: 0.82rem; + color: rgb(110 231 183 / 90%); +} + +.description { + margin: 4px 0 0; + font-size: 0.8rem; + line-height: 1.4; + opacity: 0.6; +} + +.cardAction { + margin-top: 10px; +} + +.use { + padding: 5px 14px; + border-radius: 999px; + border: 1px solid rgb(251 191 36 / 40%); + background: rgb(251 191 36 / 10%); + color: inherit; + font-size: 0.8rem; + cursor: pointer; +} + +.use:hover:not(:disabled) { + background: rgb(251 191 36 / 20%); +} + +.use:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.disabled { + cursor: not-allowed; +} + +.hint { + font-size: 0.75rem; + opacity: 0.45; +} + +/* ─── pending claims ────────────────────────────────────────────────────────── + Above the bag and visually distinct from it, because these are not items yet. + Claiming is what mints them, and until it lands there is nothing on chain to spend. */ + +.pending { + padding: 12px 14px; + border: 1px dashed rgb(251 191 36 / 35%); + border-radius: 12px; + background: rgb(251 191 36 / 6%); +} + +.pending .sectionTitle { + margin-top: 0; +} + +.pendingList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.pendingRow { + display: flex; + align-items: center; + gap: 10px; +} + +.pendingDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.pendingName { + font-size: 0.88rem; +} + +.pendingSource { + font-size: 0.72rem; + opacity: 0.5; + /* Pushes the button to the far edge without a spacer element. */ + margin-right: auto; +} + +.claim { + padding: 4px 14px; + border-radius: 999px; + border: 1px solid rgb(251 191 36 / 50%); + background: rgb(251 191 36 / 15%); + color: inherit; + font-size: 0.78rem; + cursor: pointer; +} + +.claim:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ─── chrome ─────────────────────────────────────────────────────────────────── */ + +.petPicker { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + font-size: 0.82rem; +} + +.petPickerLabel { + opacity: 0.6; +} + +.petPicker select { + padding: 4px 10px; + border-radius: 8px; + border: 1px solid rgb(255 255 255 / 15%); + background: rgb(0 0 0 / 30%); + color: inherit; + font: inherit; +} + +.refresh { + padding: 4px 12px; + border-radius: 999px; + border: 1px solid rgb(255 255 255 / 15%); + background: transparent; + color: inherit; + font-size: 0.78rem; + cursor: pointer; +} + +.muted { + margin-top: 24px; + opacity: 0.6; + font-size: 0.9rem; +} + +.error { + margin: 12px 0 0; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid rgb(248 113 113 / 35%); + background: rgb(248 113 113 / 10%); + font-size: 0.85rem; +} diff --git a/frontend/src/components/inventory/index.tsx b/frontend/src/components/inventory/index.tsx new file mode 100644 index 00000000..31d50709 --- /dev/null +++ b/frontend/src/components/inventory/index.tsx @@ -0,0 +1,252 @@ +import React, { useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { useNavigate } from 'react-router-dom'; +import { + describeItemEffect, + getRarityColor, + getRarityName, + SLOT_NAMES, + useChainCapabilities, + useInventory, + usePendingItems, + useSpendItem, + usePetList, + type ItemDefinition, +} from '@shared/core'; + +import DashboardPanel from '@components/common/dashboard-panel'; +import SessionGate from '@components/common/session-gate'; +import { DASHBOARD_HOME } from '@constants/interactionRoutes'; +import { Tones } from '@constants/tones'; +import styles from './index.module.css'; + +/** + * The bag (roadmap §4). + * + * Grouped by category rather than shown as one flat grid, because the categories are not + * a taxonomy imposed on the items — they are what a player can *do* with one. A consumable + * has a button, a piece of equipment goes on a pet somewhere else, and a collectible does + * nothing at all. Sorting those together would make the screen's only question ("what can + * I use?") the hardest one to answer. + * + * Rarity colour comes from `getRarityColor`, the same function the pet cards use, so the + * five tiers mean one thing across the app rather than two. + */ + +/** Display order: what you can act on first, what merely accumulates last. */ +const CATEGORY_ORDER = ['consumable', 'equipment', 'collectible', 'material'] as const; + +const CATEGORY_LABELS: Record = { + consumable: 'Consumables', + equipment: 'Equipment', + collectible: 'Collectibles', + material: 'Materials', +}; + +/** A short line saying what an item does, or where it goes. */ +function itemSubtitle(item: ItemDefinition): string | null { + if (item.category === 'equipment' && item.slot != null) { + const slot = SLOT_NAMES[item.slot]; + const bonus = describeItemEffect(item.effect); + return bonus ? `${slot ?? 'gear'} · ${bonus}` : (slot ?? null); + } + return describeItemEffect(item.effect); +} + +const ItemCard: React.FC<{ + item: ItemDefinition; + quantity: string; + action?: React.ReactNode; +}> = ({ item, quantity, action }) => { + const subtitle = itemSubtitle(item); + return ( +
+
+

{item.name}

+ {/* Rendered even at one, so a stack of one and a stack of nine read the + same shape rather than the badge appearing to mean something. */} + ×{quantity} +
+

{getRarityName(item.rarity)}

+ {subtitle ?

{subtitle}

: null} +

{item.description}

+ {action ?
{action}
: null} +
+ ); +}; + +const Inventory: React.FC = () => { + const navigate = useNavigate(); + const back = () => navigate(DASHBOARD_HOME); + // `activeKind` is the PetChain value (null when disconnected); `kind` is the adapter's + // own discriminator and is not what these queries take. + const { activeKind: chain } = useChainCapabilities(); + + const { entries, isLoading, error, refetch } = useInventory({ chain }); + const { pending, claim, claimingId, claimError } = usePendingItems(chain); + const { pets } = usePetList(); + const { spend, isPending: isSpending, error: spendError } = useSpendItem(); + + /** + * Which pet a consumable applies to. + * + * A single selection for the whole screen rather than one per card. Using an item is + * "give this to that pet", and asking again on every card would make a one-click action + * a two-step one every time. + */ + const [petId, setPetId] = useState(null); + const selectedPet = petId ?? (pets[0] ? String(pets[0].id) : null); + + const grouped = useMemo(() => { + const byCategory = new Map(); + for (const entry of entries) { + const bucket = byCategory.get(entry.item.category); + if (bucket) { + bucket.push(entry); + } else { + byCategory.set(entry.item.category, [entry]); + } + } + return CATEGORY_ORDER.map((category) => ({ + category, + label: CATEGORY_LABELS[category] ?? category, + items: byCategory.get(category) ?? [], + })).filter((group) => group.items.length > 0); + }, [entries]); + + const failure = (error ?? spendError ?? claimError) as Error | null; + + return ( + + + Refresh + + } + > + {failure ? ( +

+ {failure.message} +

+ ) : null} + + {pending.length > 0 ? ( +
+

+ Waiting to be claimed +

+ {/* Its own strip above the bag, because these are not items yet: + claiming is what mints them, and until then there is nothing on + chain to spend. */} +
    + {pending.map((entry) => ( +
  • + + + {entry.item.name} ×{entry.quantity} + + + {entry.source === 'battle_drop' ? 'Battle drop' : 'Granted'} + + +
  • + ))} +
+
+ ) : null} + + {isLoading ? ( +

Loading your items…

+ ) : grouped.length === 0 ? ( +

+ Nothing yet. Items drop from battles, so fight something. +

+ ) : ( + <> + {pets.length > 0 ? ( + + ) : null} + + {grouped.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((entry) => ( + + void spend({ + chain, + petId: selectedPet!, + itemType: entry.item.itemType, + }) + } + > + {isSpending ? 'Using…' : 'Use'} + + ) : entry.item.category === 'equipment' ? ( + // Equipping is a wallet signature and + // happens on the pet, not here. + Equip from a pet + ) : null + } + /> + ))} +
+
+ ))} + + )} +
+
+ ); +}; + +export default Inventory; diff --git a/frontend/src/components/layout/sidebar/nav-items.ts b/frontend/src/components/layout/sidebar/nav-items.ts index f49b22cb..25e9e5d4 100644 --- a/frontend/src/components/layout/sidebar/nav-items.ts +++ b/frontend/src/components/layout/sidebar/nav-items.ts @@ -3,6 +3,7 @@ import { BATTLE_PATH, BREED_PATH, DASHBOARD_HOME, + INVENTORY_PATH, LEADERBOARD_PATH, LEVELUP_PATH, MESSAGES_PATH, @@ -48,7 +49,7 @@ export const NAV_ITEMS: readonly NavItem[] = [ { id: 'breed', label: 'Breeding Lab', iconSrc: breedIcon, tone: 'amber', path: BREED_PATH }, { id: 'levelup', label: 'Level Up', iconSrc: levelupIcon, tone: 'violet', path: LEVELUP_PATH }, { id: 'train', label: 'Training Ground', iconSrc: trainIcon, tone: 'cyan', path: TRAIN_PATH }, - { id: 'items', label: 'Inventory', iconSrc: itemsIcon, tone: 'amber', deferred: true }, + { id: 'items', label: 'Inventory', iconSrc: itemsIcon, tone: 'amber', path: INVENTORY_PATH }, { id: 'shard', label: 'Shard Forge', iconSrc: shardIcon, tone: 'cyan', deferred: true }, { id: 'marriage', label: 'Marriage', iconSrc: marriageIcon, tone: 'magenta', path: MARRIAGE_PATH }, { id: 'rename', label: 'Rename Pet', iconSrc: renameIcon, tone: 'cyan', path: RENAME_PATH }, diff --git a/frontend/src/constants/interactionRoutes.ts b/frontend/src/constants/interactionRoutes.ts index 5da0a45d..8339216f 100644 --- a/frontend/src/constants/interactionRoutes.ts +++ b/frontend/src/constants/interactionRoutes.ts @@ -67,5 +67,8 @@ export const DEFENSE_PATH = '/defense'; /** Read-only view, not an interaction — no pet selection, nothing to sign. */ export const LEADERBOARD_PATH = '/leaderboard'; +/** Standalone inventory screen (roadmap §4). */ +export const INVENTORY_PATH = '/inventory'; + /** Private chat with married-pet counterparts. Also not an interaction. */ export const MESSAGES_PATH = '/messages'; diff --git a/frontend/src/pages/inventory/index.tsx b/frontend/src/pages/inventory/index.tsx new file mode 100644 index 00000000..be2f203f --- /dev/null +++ b/frontend/src/pages/inventory/index.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import Inventory from '@components/inventory'; + +/** Top-level `/inventory` page — the player's items, and anything waiting to be claimed. */ +const InventoryPage: React.FC = () => ; + +export default InventoryPage; diff --git a/frontend/src/router/app-routes/index.tsx b/frontend/src/router/app-routes/index.tsx index b4f7b7cd..7e29506c 100644 --- a/frontend/src/router/app-routes/index.tsx +++ b/frontend/src/router/app-routes/index.tsx @@ -16,6 +16,7 @@ const RenamePage = lazy(() => import('@pages/rename')); const DefensePage = lazy(() => import('@pages/defense')); const LeaderboardPage = lazy(() => import('@pages/leaderboard')); const ChatPage = lazy(() => import('@pages/chat')); +const InventoryPage = lazy(() => import('@pages/inventory')); // SCRATCH — remove after visual verification. const BattleOverlayPreview = lazy(() => import('@pages/__preview/battle-overlay-preview')); @@ -44,6 +45,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 547f91c2..9d9becc2 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -154,14 +154,22 @@ export { type UsePetEquipmentResult, } from './inventory/usePetEquipment'; export { - useUseItem, - type UseItemArgs, - type UseItemResult, - type UseUseItemResult, -} from './inventory/useUseItem'; + useSpendItem, + type SpendItemArgs, + type SpendItemResult, + type UseSpendItemResult, +} from './inventory/useSpendItem'; // Equipping is a chain write the player signs, so it goes through its own adapter rather // than ChainAdapter: AGENTS.md forbids growing that interface, and §4 names this case. export { useEquipItem, type UseEquipItemOptions, type UseEquipItemResult } from './inventory/useEquipItem'; +// Earned but unminted items, plus the claim that mints them. Separate from the bag: an +// entitlement is a promise of an item, and nothing on chain reflects it until it is claimed. +export { + usePendingItems, + pendingItemsQueryKey, + type PendingItem, + type UsePendingItemsResult, +} from './inventory/usePendingItems'; export { useInventoryAdapter } from './adapters/useInventoryAdapter'; export type { EquipArgs, InventoryAdapter, UnequipArgs } from './adapters/inventoryTypes'; export { diff --git a/shared/src/hooks/inventory/usePendingItems.ts b/shared/src/hooks/inventory/usePendingItems.ts new file mode 100644 index 00000000..ca6a69f4 --- /dev/null +++ b/shared/src/hooks/inventory/usePendingItems.ts @@ -0,0 +1,123 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useApiClient } from '../../contexts/ApiClientContext'; +import { useAuth } from '../../contexts/AuthContext'; +import type { PetChain } from '../../types/pet'; +import type { ItemDefinition } from '../../types/item'; +import { inventoryQueryKey, toItemDefinition } from './useInventory'; + +/** + * Items earned but not yet minted, and the claim that mints them (roadmap §4). + * + * Kept apart from the bag because these are not items yet: nothing on chain reflects one + * until the claim lands, so showing them together would offer a player a stack they cannot + * spend. The two-step exists because minting costs gas, and a battle should not wait on a + * transaction to finish settling. + */ + +const PENDING_QUERY = ` + query PendingItems($chain: String!) { + pendingItems(chain: $chain) { + entitlementId + item { itemType key category slot rarity effect name description } + quantity + source + sourceRef + createdAt + } + } +`; + +interface WireItem extends Omit { + effect: string | null; +} + +interface WirePending { + entitlementId: string; + item: WireItem; + quantity: number; + source: string; + sourceRef: string; + createdAt: string; +} + +interface GraphQLResponse { + data?: { pendingItems: WirePending[] }; + errors?: { message: string }[]; +} + +export interface PendingItem { + entitlementId: string; + item: ItemDefinition; + quantity: number; + /** 'battle_drop' | 'admin_grant'. */ + source: string; + /** The battle id for a drop, so a UI can say which fight paid it. */ + sourceRef: string; + createdAt: string; +} + +export interface UsePendingItemsResult { + pending: PendingItem[]; + isLoading: boolean; + error: Error | null; + /** Mints one entitlement. Resolves once the transaction has landed. */ + claim(entitlementId: string): Promise; + /** The entitlement currently being claimed, so a row can show its own spinner. */ + claimingId: string | null; + claimError: Error | null; +} + +export function pendingItemsQueryKey(baseURL: string, chain: PetChain | null): unknown[] { + return ['pendingItems', baseURL, chain]; +} + +export const usePendingItems = (chain: PetChain | null): UsePendingItemsResult => { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const { isAuthenticated } = useAuth(); + const baseURL = apiClient.defaults.baseURL ?? ''; + + const query = useQuery({ + queryKey: pendingItemsQueryKey(baseURL, chain), + enabled: chain != null && isAuthenticated, + queryFn: async () => { + const { data } = await apiClient.post('/graphql', { + query: PENDING_QUERY, + variables: { chain }, + }); + + if (data.errors?.length) { + throw new Error(data.errors.map((e) => e.message).join('; ')); + } + + return (data.data?.pendingItems ?? []).map((entry) => ({ + ...entry, + item: toItemDefinition(entry.item), + })); + }, + }); + + const mutation = useMutation({ + mutationFn: async (entitlementId: string) => { + await apiClient.post(`/api/inventory/entitlements/${entitlementId}/claim`, {}); + }, + // Both lists move: the entitlement leaves this one and the item joins the bag. The + // bag will lag by an indexer poll, since the mint has to be seen before the balance + // exists — invalidating starts that catch-up rather than completing it. + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: pendingItemsQueryKey(baseURL, chain) }); + void queryClient.invalidateQueries({ queryKey: inventoryQueryKey(baseURL, chain) }); + }, + }); + + return { + pending: query.data ?? [], + isLoading: query.isLoading, + error: query.error as Error | null, + claim: async (entitlementId) => { + await mutation.mutateAsync(entitlementId); + }, + claimingId: mutation.isPending ? (mutation.variables ?? null) : null, + claimError: mutation.error as Error | null, + }; +}; diff --git a/shared/src/hooks/inventory/useUseItem.ts b/shared/src/hooks/inventory/useSpendItem.ts similarity index 75% rename from shared/src/hooks/inventory/useUseItem.ts rename to shared/src/hooks/inventory/useSpendItem.ts index 3131a31a..da3b765b 100644 --- a/shared/src/hooks/inventory/useUseItem.ts +++ b/shared/src/hooks/inventory/useSpendItem.ts @@ -11,7 +11,7 @@ import { inventoryQueryKey } from './useInventory'; * the whole reason a consumable is one click and an equip is a wallet prompt. */ -export interface UseItemArgs { +export interface SpendItemArgs { chain: PetChain; /** Pet id as a decimal string. */ petId: string; @@ -20,7 +20,7 @@ export interface UseItemArgs { } /** What the server reports back: the burn, and the pet's progression after the effect. */ -export interface UseItemResult { +export interface SpendItemResult { burnTxHash: string; level: number; xp: number; @@ -29,21 +29,26 @@ export interface UseItemResult { leveledUp: boolean; } -export interface UseUseItemResult { - useItem(args: UseItemArgs): Promise; +export interface UseSpendItemResult { + /** + * Named `spend` rather than `useItem`: a returned function whose name starts with + * `use` reads as a hook, and eslint's rules-of-hooks rejects calling one from an event + * handler, which is the only place this is ever called from. + */ + spend(args: SpendItemArgs): Promise; isPending: boolean; error: Error | null; reset(): void; } -export const useUseItem = (): UseUseItemResult => { +export const useSpendItem = (): UseSpendItemResult => { const apiClient = useApiClient(); const queryClient = useQueryClient(); const baseURL = apiClient.defaults.baseURL ?? ''; const mutation = useMutation({ - mutationFn: async (args: UseItemArgs) => { - const { data } = await apiClient.post('/api/inventory/use', args); + mutationFn: async (args: SpendItemArgs) => { + const { data } = await apiClient.post('/api/inventory/use', args); return data; }, // Invalidated rather than patched, and never optimistically. The burn is a @@ -57,7 +62,7 @@ export const useUseItem = (): UseUseItemResult => { }); return { - useItem: mutation.mutateAsync, + spend: mutation.mutateAsync, isPending: mutation.isPending, error: mutation.error as Error | null, reset: mutation.reset, diff --git a/shared/tests/hooks/useInventory.test.tsx b/shared/tests/hooks/useInventory.test.tsx index d23e8708..38afa6c0 100644 --- a/shared/tests/hooks/useInventory.test.tsx +++ b/shared/tests/hooks/useInventory.test.tsx @@ -13,7 +13,7 @@ vi.mock('../../src/contexts/AuthContext', () => ({ useAuth: () => auth })); import { useInventory } from '../../src/hooks/inventory/useInventory'; import { usePetEquipment } from '../../src/hooks/inventory/usePetEquipment'; import { useItemCatalog } from '../../src/hooks/inventory/useItemCatalog'; -import { useUseItem } from '../../src/hooks/inventory/useUseItem'; +import { useSpendItem } from '../../src/hooks/inventory/useSpendItem'; /** The wire shape: `effect` is a JSON string, as the server sends it. */ const POTION = { @@ -165,12 +165,12 @@ describe('useItemCatalog', () => { }); }); -describe('useUseItem', () => { +describe('useSpendItem', () => { it('posts to the REST route, since the backend burns rather than the player signing', async () => { post.mockResolvedValue({ data: { burnTxHash: '0xburn', level: 5, xp: 0, readyAt: 0, leveledUp: true } }); - const { result } = renderHook(() => useUseItem(), { wrapper }); + const { result } = renderHook(() => useSpendItem(), { wrapper }); - const outcome = await result.current.useItem({ chain: 'evm', petId: '7', itemType: '100' }); + const outcome = await result.current.spend({ chain: 'evm', petId: '7', itemType: '100' }); expect(post).toHaveBeenCalledWith('/api/inventory/use', { chain: 'evm', petId: '7', itemType: '100' }); expect(outcome.leveledUp).toBe(true); @@ -181,10 +181,10 @@ describe('useUseItem', () => { // that can still fail. it('surfaces a rejected spend rather than reporting success', async () => { post.mockRejectedValue(new Error('You do not hold that item')); - const { result } = renderHook(() => useUseItem(), { wrapper }); + const { result } = renderHook(() => useSpendItem(), { wrapper }); await expect( - result.current.useItem({ chain: 'evm', petId: '7', itemType: '100' }), + result.current.spend({ chain: 'evm', petId: '7', itemType: '100' }), ).rejects.toThrow('You do not hold that item'); }); }); From e81a42be89491cc91504df59220f17982cc31e63 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 00:42:05 -0400 Subject: [PATCH 16/56] feat(inventory): add the equip panel --- .../src/components/inventory/index.module.css | 16 +- frontend/src/components/inventory/index.tsx | 18 +- .../panels/_shared/pet-showcase.tsx | 6 +- .../panels/equip/index.module.css | 96 +++++++ .../pet/interactions/panels/equip/index.tsx | 270 ++++++++++++++++++ frontend/src/constants/interactionRoutes.ts | 9 +- frontend/src/pages/equip/index.tsx | 12 + frontend/src/router/app-routes/index.tsx | 2 + 8 files changed, 419 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/pet/interactions/panels/equip/index.module.css create mode 100644 frontend/src/components/pet/interactions/panels/equip/index.tsx create mode 100644 frontend/src/pages/equip/index.tsx diff --git a/frontend/src/components/inventory/index.module.css b/frontend/src/components/inventory/index.module.css index cc739fcd..a0796377 100644 --- a/frontend/src/components/inventory/index.module.css +++ b/frontend/src/components/inventory/index.module.css @@ -128,9 +128,19 @@ cursor: not-allowed; } -.hint { - font-size: 0.75rem; - opacity: 0.45; +.hintLink { + padding: 0; + border: 0; + background: none; + color: rgb(251 191 36 / 80%); + font: inherit; + font-size: 0.78rem; + cursor: pointer; +} + +.hintLink:hover { + color: rgb(251 191 36); + text-decoration: underline; } /* ─── pending claims ────────────────────────────────────────────────────────── diff --git a/frontend/src/components/inventory/index.tsx b/frontend/src/components/inventory/index.tsx index 31d50709..89f0f41a 100644 --- a/frontend/src/components/inventory/index.tsx +++ b/frontend/src/components/inventory/index.tsx @@ -16,7 +16,7 @@ import { import DashboardPanel from '@components/common/dashboard-panel'; import SessionGate from '@components/common/session-gate'; -import { DASHBOARD_HOME } from '@constants/interactionRoutes'; +import { DASHBOARD_HOME, EQUIP_PATH } from '@constants/interactionRoutes'; import { Tones } from '@constants/tones'; import styles from './index.module.css'; @@ -232,9 +232,19 @@ const Inventory: React.FC = () => { {isSpending ? 'Using…' : 'Use'} ) : entry.item.category === 'equipment' ? ( - // Equipping is a wallet signature and - // happens on the pet, not here. - Equip from a pet + // Equipping is a wallet signature against + // one pet, so it belongs on the pet rather + // than in the bag. A link, not a note: the + // player is holding gear and wants to use + // it, and telling them where without + // taking them there is a dead end. + ) : null } /> diff --git a/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.tsx b/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.tsx index e97f44d0..7c10513d 100644 --- a/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.tsx +++ b/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.tsx @@ -2,11 +2,13 @@ import React from 'react'; import styles from './pet-showcase.module.css'; -export type ShowcaseAccent = 'violet' | 'cyan'; +export type ShowcaseAccent = 'violet' | 'cyan' | 'amber'; +/** Matches the `Tones` palette, so a panel's hero agrees with its sidebar entry. */ const ACCENT_RGB: Record = { violet: '181 140 255', cyan: '125 214 255', + amber: '251 191 36', }; export type PetShowcaseProps = { @@ -18,7 +20,7 @@ export type PetShowcaseProps = { }; /** - * Avatar hero shared by the level-up and rename panels: a floating avatar + * Avatar hero shared by the level-up, rename and equip panels: a floating avatar * tinted by `accent`. Panel-specific details (level transition, live name * preview, requirements) are passed as children and rendered beneath the hero. */ diff --git a/frontend/src/components/pet/interactions/panels/equip/index.module.css b/frontend/src/components/pet/interactions/panels/equip/index.module.css new file mode 100644 index 00000000..121dd516 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/equip/index.module.css @@ -0,0 +1,96 @@ +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Equip panel: one row per slot, below the shared ring-avatar hero. Shared chrome + (.interface/.picker/.field) stays global in interactions.css and is referenced as + plain strings from the tsx. + + All three slots are always drawn, empty or not, so the rows form a fixed shape a player + can scan rather than a list that grows as they gear up. An empty slot is information; + a missing one reads as a pet that has no slots. */ + +.petName { + font-family: var(--cp-title-font); + font-size: 18px; + font-weight: 800; + letter-spacing: 1.5px; + color: var(--cp-amber, #fbbf24); + text-shadow: 0 0 18px rgb(251 191 36 / 55%), 0 0 36px rgb(251 191 36 / 25%); + line-height: 1.2; +} + +.sub { + font-size: 11px; + color: rgb(195 210 255 / 45%); +} + +.slots { + display: flex; + flex-direction: column; + gap: 10px; + margin: 16px 0 0; + padding: 0; + list-style: none; +} + +/* Label, contents, action — in that order and at fixed widths, so the three rows line up + as a column rather than each sizing itself to its own item name. */ +.slot { + display: grid; + grid-template-columns: 72px 1fr auto; + align-items: center; + gap: 10px; +} + +.slotLabel { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(195 210 255 / 55%); +} + +.equipped { + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px 10px; + border-radius: 8px; + /* The rarity is a left edge here for the same reason it is on the bag's cards: a full + border on five tiers turns a stack of rows into a colour chart. */ + border-left: 3px solid var(--rarity, rgb(255 255 255 / 20%)); + background: rgb(255 255 255 / 4%); + min-width: 0; +} + +.equippedName { + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.equippedEffect { + font-size: 11px; + color: rgb(110 231 183 / 85%); +} + +.select { + width: 100%; + min-width: 0; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid rgb(255 255 255 / 15%); + background: rgb(0 0 0 / 30%); + color: inherit; + font: inherit; + font-size: 13px; +} + +.select:disabled { + opacity: 0.45; +} + +.note { + margin: 10px 0 0; + font-size: 12px; + color: rgb(195 210 255 / 50%); +} diff --git a/frontend/src/components/pet/interactions/panels/equip/index.tsx b/frontend/src/components/pet/interactions/panels/equip/index.tsx new file mode 100644 index 00000000..0d533492 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/equip/index.tsx @@ -0,0 +1,270 @@ +import React, { useMemo, useState } from 'react'; +import { + describeItemEffect, + getPetClass, + getRarityColor, + SLOT, + useChainCapabilities, + useEquipItem, + useInventory, + usePetEquipment, + usePetList, + type ItemDefinition, +} from '@shared/core'; + +import TransactionStatus from '@components/common/transaction-status'; +import NeonButton from '@components/ui/neon-button'; +import Icon, { ShieldIcon } from '@components/ui/icon'; +import PetArt from '@components/pet/pet-art'; +import PetSelect from '@components/ui/pet-select'; +import { Tones } from '@constants/tones'; +import { useNotifyError } from '@hooks/useNotifyError'; +import { useTxErrorToast } from '@hooks/useTxErrorToast'; +import PetShowcase from '../_shared/pet-showcase'; +import styles from './index.module.css'; + +/** + * Gear a pet (roadmap §4). + * + * Composes the shared hooks directly and keeps its state local, rather than sitting on a + * controller hook. Per CLAUDE.md the test for a controller is a multi-step state machine + * the player watches — request, reveal, settle — and this has none: an equip is one + * transaction whose only intermediate state is the wallet, which `TransactionStatus` + * already renders. + * + * Three slots are always drawn, filled or not. An empty slot is information ("you could + * put something here"), and rendering only what is equipped would make a bare pet look + * like a pet with no slots. + */ + +const SLOTS = [ + { index: SLOT.weapon, label: 'Weapon' }, + { index: SLOT.armor, label: 'Armor' }, + { index: SLOT.trinket, label: 'Trinket' }, +] as const; + +export type EquipPanelProps = { + isStandaloneView?: boolean; +}; + +const EquipPanel: React.FC = ({ isStandaloneView = true }) => { + const { activeKind: chain, isConnected } = useChainCapabilities(); + const { pets } = usePetList(); + const notifyError = useNotifyError(); + + const [selectedPet, setSelectedPet] = useState(''); + /** Which item is chosen per slot, before the player commits it. */ + const [choice, setChoice] = useState>({}); + + const petId = selectedPet || null; + const { entries } = useInventory({ chain }); + const { bySlot, isLoading: slotsLoading, refetch: refetchSlots } = usePetEquipment({ chain, petId }); + const { canEquip, equip, unequip, equipLifecycle, unequipLifecycle, isPending } = useEquipItem({ + chain, + petId, + }); + + useTxErrorToast((equipLifecycle.error ?? unequipLifecycle.error) as Error | null); + + /** + * Held equipment, bucketed by the slot it goes in. + * + * Filtered from the bag rather than fetched separately: the inventory read is already + * on screen, and an item's slot is part of its definition, so a second query would ask + * the server for something the client can answer. + */ + const bySlotChoices = useMemo(() => { + const buckets = new Map(); + for (const entry of entries) { + if (entry.item.category !== 'equipment' || entry.item.slot == null) continue; + const bucket = buckets.get(entry.item.slot); + if (bucket) bucket.push(entry.item); + else buckets.set(entry.item.slot, [entry.item]); + } + return buckets; + }, [entries]); + + const selectedPetObj = pets.find((pet) => String(pet.id) === selectedPet) ?? null; + + const handleEquip = async (slot: number) => { + const itemType = choice[slot]; + if (!isConnected) { + notifyError('Please connect your wallet first', undefined, 'equip-validation'); + return; + } + if (!selectedPet || !itemType) { + notifyError('Pick a pet and an item first', undefined, 'equip-validation'); + return; + } + try { + await equip(slot, itemType); + setChoice((current) => ({ ...current, [slot]: '' })); + refetchSlots(); + } catch (err) { + console.error('[equip]', err); + } + }; + + const handleUnequip = async (slot: number) => { + try { + await unequip(slot); + refetchSlots(); + } catch (err) { + console.error('[equip]', err); + } + }; + + return ( + <> +
+ {!isStandaloneView && ( + <> +

+ + Equipment +

+

Fit your pet with gear. Stats apply in backend battles.

+ + )} + +
+ {selectedPetObj ? ( + } accent="amber"> +
{selectedPetObj.name}
+
+ {getPetClass(selectedPetObj.dna)} · Lv.{selectedPetObj.level} +
+
+ ) : ( + ?} accent="amber"> +
+ +
+
+ +
+
+ )} +
+ +
+
+ + ({ id: String(pet.id), pet }))} + value={selectedPet} + onChange={setSelectedPet} + placeholder="Select pet..." + disabled={pets.length === 0} + /> + {pets.length === 0 &&

You have no pets yet.

} +
+
+ + {!canEquip && ( + // Said out loud rather than shown as a dead control: on Solana there is + // no item contract yet, and on EVM the address may simply be unset. +

+ Equipping is not available on this deployment yet. +

+ )} + + {selectedPet && canEquip && ( +
    + {SLOTS.map(({ index, label }) => { + const equipped = bySlot.get(index); + const options = bySlotChoices.get(index) ?? []; + return ( +
  • + {label} + + {equipped ? ( + <> + + + {equipped.item.name} + + + {describeItemEffect(equipped.item.effect)} + + + void handleUnequip(index)} + disabled={isPending} + > + Unequip + + + ) : ( + <> + + void handleEquip(index)} + disabled={isPending || !choice[index]} + > + Equip + + + )} +
  • + ); + })} +
+ )} + + {selectedPet && canEquip && slotsLoading && ( +

Reading this pet’s gear…

+ )} + + {/* The lag is real and worth naming: these rows come from the indexed + projection, so a confirmed transaction shows up once the indexer has + seen it rather than the instant the wallet returns. */} + {isPending && ( +

+ Waiting for the change to be indexed — this can take a moment after + the transaction confirms. +

+ )} +
+ + + + ); +}; + +export default EquipPanel; diff --git a/frontend/src/constants/interactionRoutes.ts b/frontend/src/constants/interactionRoutes.ts index 8339216f..cdfae7be 100644 --- a/frontend/src/constants/interactionRoutes.ts +++ b/frontend/src/constants/interactionRoutes.ts @@ -17,7 +17,8 @@ export type InteractionAction = | 'train' | 'marriage' | 'changename' - | 'defense'; + | 'defense' + | 'equip'; export type StandaloneInteractionHeader = { Icon: ComponentType<{ size?: number | string }>; @@ -50,6 +51,11 @@ export const STANDALONE_INTERACTION_HEADERS: Record< label: 'Allow Challenges', sub: 'Let others battle your pets while you are away', }, + equip: { + Icon: ShieldIcon, + label: 'Equipment', + sub: 'Fit your pet with gear it carries into battle', + }, }; /** Dashboard home (idle gallery). */ @@ -63,6 +69,7 @@ export const TRAIN_PATH = '/train'; export const MARRIAGE_PATH = '/marriage'; export const RENAME_PATH = '/rename'; export const DEFENSE_PATH = '/defense'; +export const EQUIP_PATH = '/equip'; /** Read-only view, not an interaction — no pet selection, nothing to sign. */ export const LEADERBOARD_PATH = '/leaderboard'; diff --git a/frontend/src/pages/equip/index.tsx b/frontend/src/pages/equip/index.tsx new file mode 100644 index 00000000..127fc845 --- /dev/null +++ b/frontend/src/pages/equip/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import InteractionStandalone from '@components/pet/interactions/standalone'; +import EquipPanel from '@components/pet/interactions/panels/equip'; + +/** Top-level `/equip` page — the equipment panel (standalone UI). */ +const EquipPage: React.FC = () => ( + + + +); + +export default EquipPage; diff --git a/frontend/src/router/app-routes/index.tsx b/frontend/src/router/app-routes/index.tsx index 7e29506c..1ba45cdf 100644 --- a/frontend/src/router/app-routes/index.tsx +++ b/frontend/src/router/app-routes/index.tsx @@ -17,6 +17,7 @@ const DefensePage = lazy(() => import('@pages/defense')); const LeaderboardPage = lazy(() => import('@pages/leaderboard')); const ChatPage = lazy(() => import('@pages/chat')); const InventoryPage = lazy(() => import('@pages/inventory')); +const EquipPage = lazy(() => import('@pages/equip')); // SCRATCH — remove after visual verification. const BattleOverlayPreview = lazy(() => import('@pages/__preview/battle-overlay-preview')); @@ -46,6 +47,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> From 10804479badef0233454b9ddb2f0f76ec3807c14 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 00:49:05 -0400 Subject: [PATCH 17/56] feat(protocol): add equipment to the battle snapshot --- protocol/src/domain/deployment.ts | 10 +- protocol/src/domain/schemaVersions.ts | 7 +- protocol/src/snapshot/hash.ts | 34 ++++- protocol/src/snapshot/index.ts | 2 + protocol/src/snapshot/types.ts | 127 +++++++++++++++- protocol/tests/snapshot/equipment.test.ts | 171 ++++++++++++++++++++++ 6 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 protocol/tests/snapshot/equipment.test.ts diff --git a/protocol/src/domain/deployment.ts b/protocol/src/domain/deployment.ts index c2487f8e..4fc1a64b 100644 --- a/protocol/src/domain/deployment.ts +++ b/protocol/src/domain/deployment.ts @@ -75,8 +75,16 @@ export function writeHeader( writer: CanonicalWriter, kind: SchemaKind, domain: ProtocolDomain, + /** + * Layout to write, defaulting to this build's current one. + * + * Passed explicitly only when re-encoding a historical object: reproducing the bytes + * it was hashed under means writing the version it was written at, not the version + * this build happens to be on. Encoding an old object at a new version would change + * its digest and invalidate every signature over it. + */ + version: number = currentSchemaVersion(kind), ): CanonicalWriter { - const version = currentSchemaVersion(kind); assertSupportedSchemaVersion(kind, version); const checked = assertProtocolDomain(domain); return writer.u16(version).text(checked.chainId).text(checked.deploymentId); diff --git a/protocol/src/domain/schemaVersions.ts b/protocol/src/domain/schemaVersions.ts index e6fa8cc7..85e37c23 100644 --- a/protocol/src/domain/schemaVersions.ts +++ b/protocol/src/domain/schemaVersions.ts @@ -19,7 +19,8 @@ export const SCHEMA_VERSIONS = { intent: 1, defenseAuthorization: 1, - snapshot: 1, + /** 2 adds per-pet equipment (roadmap §4). Version 1 snapshots carry none. */ + snapshot: 2, ruleset: 1, commitment: 1, receipt: 1, @@ -35,7 +36,9 @@ export type SchemaKind = keyof typeof SCHEMA_VERSIONS; const SUPPORTED_VERSIONS: Record = { intent: [1], defenseAuthorization: [1], - snapshot: [1], + // 1 stays supported: every receipt signed before equipment existed names a v1 + // snapshot, and those have to keep verifying forever (§H). + snapshot: [1, 2], ruleset: [1], commitment: [1], receipt: [1], diff --git a/protocol/src/snapshot/hash.ts b/protocol/src/snapshot/hash.ts index 800684d6..3ecea983 100644 --- a/protocol/src/snapshot/hash.ts +++ b/protocol/src/snapshot/hash.ts @@ -20,10 +20,11 @@ import { assertBattleSnapshot, type BattleSnapshot, type PetSnapshot } from './t */ export function encodeBattleSnapshot(snapshot: BattleSnapshot): Uint8Array { const checked = assertBattleSnapshot(snapshot); + const version = checked.schemaVersion ?? 1; const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.SNAPSHOT); - writeHeader(writer, 'snapshot', checked.domain); - writePet(writer, checked.attacker); - writePet(writer, checked.defender); + writeHeader(writer, 'snapshot', checked.domain, version); + writePet(writer, checked.attacker, version); + writePet(writer, checked.defender, version); return writer.u64(checked.takenAt).build(); } @@ -32,7 +33,15 @@ export function hashBattleSnapshot(snapshot: BattleSnapshot): Hex { return keccak256Hex(encodeBattleSnapshot(snapshot)); } -function writePet(writer: CanonicalWriter, pet: PetSnapshot): void { +/** + * One pet's fields, in the layout `version` defines. + * + * Version 1 stops at `sourceVersion`; version 2 appends the equipment list (roadmap §4). + * Everything before it is byte-identical across the two, so an ungeared v2 snapshot + * differs from the v1 of the same pet only by the version in the header and a zero-length + * array — which is the point: adding the field cannot change what an old fight hashed to. + */ +function writePet(writer: CanonicalWriter, pet: PetSnapshot, version: number): void { writer .u256(pet.petId) .account(pet.owner) @@ -45,4 +54,21 @@ function writePet(writer: CanonicalWriter, pet: PetSnapshot): void { .u32(pet.streak) .u64(pet.readyAt) .u64(pet.sourceVersion); + + if (version < 2) { + return; + } + + // Count-prefixed and in slot order, which `assertPetSnapshot` has already enforced. + // The item type is hashed alongside the resolved numbers so a verifier can hold us to + // both: the modifiers the fight used, and which item was supposed to have granted them. + writer.array(pet.equipment ?? [], (w, entry) => { + w.u8(entry.slot) + .u256(entry.itemType) + .u16(entry.hp) + .u16(entry.atk) + .u16(entry.def) + .u16(entry.int) + .u16(entry.mdef); + }); } diff --git a/protocol/src/snapshot/index.ts b/protocol/src/snapshot/index.ts index 890ae64a..fbbcd153 100644 --- a/protocol/src/snapshot/index.ts +++ b/protocol/src/snapshot/index.ts @@ -3,6 +3,8 @@ export { assertBattleSnapshot, assertPetSnapshot, type BattleSnapshot, + type EquipEntry, isBattleReady, type PetSnapshot, + SNAPSHOT_SCHEMA_VERSION, } from './types'; diff --git a/protocol/src/snapshot/types.ts b/protocol/src/snapshot/types.ts index 2dde4780..0cef1675 100644 --- a/protocol/src/snapshot/types.ts +++ b/protocol/src/snapshot/types.ts @@ -1,6 +1,35 @@ import { assertProtocolDomain, type ProtocolDomain } from '../domain/deployment'; +import { assertSupportedSchemaVersion, currentSchemaVersion } from '../domain/schemaVersions'; import { normalizeAccount } from '../encoding/bytes'; +/** + * One equipped item, frozen with the pet (roadmap §4, snapshot schema v2). + * + * Carries the **resolved** modifier rather than a reference to a catalog row, which is + * the whole point: unequipping after acceptance must not change a committed fight, for + * the same reason a level-up between acceptance and settlement must not. A replay needs + * nothing but these numbers. + * + * `itemType` rides along even though the engine never reads it. It is what lets an + * outsider cross-check the resolved numbers against the published catalog and against + * the chain's own equip state at `sourceVersion`, so a geared receipt is checkable rather + * than merely self-consistent (threat T13). + * + * Bonuses are non-negative and additive. The engine truncates to 16 bits with wraparound + * rather than clamping, so a negative modifier is one underflow from a pet with 65,000 HP. + */ +export interface EquipEntry { + /** Equip slot 0-2, matching ItemCore.SLOT_*. */ + slot: number; + /** ERC-1155 token id of the equipped item. */ + itemType: bigint; + hp: number; + atk: number; + def: number; + int: number; + mdef: number; +} + /** * One pet, frozen at acceptance. The "photo" from Part 1 of the architecture doc. * @@ -16,9 +45,8 @@ import { normalizeAccount } from '../encoding/bytes'; * in the snapshot, progression could only be recomputed by someone with access to * our tables, which is not replay. * - * Equipment is deliberately absent. Nothing equips anything yet, and inventing the - * field now would freeze a layout for a feature whose shape is undecided. Adding - * it is a `snapshot` schema-version bump, which is the honest cost. + * Equipment arrived in schema version 2 (roadmap §4), at the cost that field always + * carried. Version 1 snapshots have none and keep verifying unchanged. */ export interface PetSnapshot { petId: bigint; @@ -45,6 +73,14 @@ export interface PetSnapshot { * the fact rather than merely suspected (threat T10). */ sourceVersion: bigint; + /** + * What this pet had equipped at snapshot time, ordered by slot (schema v2+). + * + * Absent or empty means ungeared, which is what every v1 snapshot is. Slots must be + * strictly ascending and unique: the order is part of the encoding, and sorting + * silently here would hide an upstream bug that produced two weapons. + */ + equipment?: EquipEntry[]; } /** Both pets, frozen together. This is what `snapshotHash` covers. */ @@ -54,8 +90,25 @@ export interface BattleSnapshot { defender: PetSnapshot; /** Unix seconds the snapshot was taken, which is acceptance time. */ takenAt: number; + /** + * Which layout this snapshot was written under. Defaults to the current version. + * + * Stored on the object rather than assumed, because re-encoding a historical snapshot + * has to reproduce the bytes it was hashed under. A v1 snapshot re-encoded at v2 would + * get a different `snapshotHash` and every receipt naming it would fail to verify. + */ + schemaVersion?: number; } +/** + * The layout a new snapshot should declare. + * + * Exported so a producer names it deliberately rather than relying on a default: the + * default is 1, because that is what an absent field means on every snapshot written + * before the field existed. + */ +export const SNAPSHOT_SCHEMA_VERSION = currentSchemaVersion('snapshot'); + /** DNA is a 16-digit number on both chains (see `combat/dna.ts`). */ const MAX_DNA = 10n ** 16n; const MAX_U256 = 1n << 256n; @@ -92,19 +145,83 @@ export function assertPetSnapshot(pet: PetSnapshot, label: string): PetSnapshot if (typeof pet.sourceVersion !== 'bigint' || pet.sourceVersion < 0n || pet.sourceVersion >= 1n << 64n) { throw new Error(`${label}.sourceVersion must fit in 64 bits, got ${pet.sourceVersion}`); } - return { ...pet, owner: normalizeAccount(pet.owner) }; + const equipment = assertEquipment(pet.equipment, label); + return { ...pet, owner: normalizeAccount(pet.owner), ...(equipment.length > 0 && { equipment }) }; +} + +/** Highest slot index the protocol accepts, matching ItemCore's three gear slots. */ +const MAX_SLOT = 2; + +/** + * Validates a pet's equipment list. + * + * Slots must be strictly ascending, which does two jobs at once: it makes the encoding + * canonical without an implicit sort, and it rejects two items in one slot — a state the + * contract cannot produce, so accepting it would mean hashing a snapshot that no chain + * history can explain. + */ +function assertEquipment(equipment: EquipEntry[] | undefined, label: string): EquipEntry[] { + if (equipment === undefined) { + return []; + } + if (!Array.isArray(equipment)) { + throw new Error(`${label}.equipment must be an array`); + } + + let previousSlot = -1; + return equipment.map((entry, index) => { + const where = `${label}.equipment[${index}]`; + if (!Number.isSafeInteger(entry.slot) || entry.slot < 0 || entry.slot > MAX_SLOT) { + throw new Error(`${where}.slot must be 0-${MAX_SLOT}, got ${entry.slot}`); + } + if (entry.slot <= previousSlot) { + throw new Error( + `${where}.slot must be strictly ascending; got ${entry.slot} after ${previousSlot}`, + ); + } + previousSlot = entry.slot; + + if (typeof entry.itemType !== 'bigint' || entry.itemType <= 0n || entry.itemType >= MAX_U256) { + // Type 0 is ItemCore's empty-slot sentinel, so it is never a real equipped item. + throw new Error(`${where}.itemType is not a valid item type: ${entry.itemType}`); + } + + const bonuses = {} as Record<'hp' | 'atk' | 'def' | 'int' | 'mdef', number>; + for (const field of ['hp', 'atk', 'def', 'int', 'mdef'] as const) { + const value = entry[field]; + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff) { + throw new Error(`${where}.${field} must be 0-65535, got ${value}`); + } + bonuses[field] = value; + } + + return { slot: entry.slot, itemType: entry.itemType, ...bonuses }; + }); } /** Validates a battle snapshot, returning a normalized copy. */ export function assertBattleSnapshot(snapshot: BattleSnapshot): BattleSnapshot { const domain = assertProtocolDomain(snapshot.domain); + // Absent means 1, not "current". Every snapshot stored before this field existed is a + // version 1 snapshot, and there are already receipts naming them; defaulting to the + // build's current version would re-encode all of those under a layout they were never + // hashed under and invalidate every signature over them. A producer of new snapshots + // sets the version explicitly, and `SNAPSHOT_SCHEMA_VERSION` is what it should set. + const schemaVersion = snapshot.schemaVersion ?? 1; + assertSupportedSchemaVersion('snapshot', schemaVersion); + const attacker = assertPetSnapshot(snapshot.attacker, 'attacker'); const defender = assertPetSnapshot(snapshot.defender, 'defender'); if (attacker.petId === defender.petId) { throw new Error(`a pet cannot fight itself (petId ${attacker.petId})`); } + if (schemaVersion < 2 && (attacker.equipment?.length || defender.equipment?.length)) { + // Refused rather than dropped. Version 1 has nowhere to put equipment, so encoding + // this would silently hash a fight without the gear it was supposed to include. + throw new Error('snapshot schema version 1 cannot carry equipment; use version 2'); + } assertUnixSeconds(snapshot.takenAt, 'takenAt', 1); - return { domain, attacker, defender, takenAt: snapshot.takenAt }; + return { domain, attacker, defender, takenAt: snapshot.takenAt, schemaVersion }; } /** Whether a pet was off cooldown when the snapshot was taken. */ diff --git a/protocol/tests/snapshot/equipment.test.ts b/protocol/tests/snapshot/equipment.test.ts new file mode 100644 index 00000000..fac8a09b --- /dev/null +++ b/protocol/tests/snapshot/equipment.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertBattleSnapshot, + type BattleSnapshot, + type EquipEntry, + encodeBattleSnapshot, + hashBattleSnapshot, + type PetSnapshot, + SNAPSHOT_SCHEMA_VERSION, +} from '../../src/snapshot'; + +/** + * Snapshot schema v2: equipment (roadmap §4). + * + * Two properties carry the whole design and both are pinned here. A version 1 snapshot + * must hash exactly as it always did, or every receipt already signed stops verifying. + * And the resolved modifiers must be part of the digest, or unequipping after acceptance + * would change a committed fight. + */ + +const BLADE: EquipEntry = { slot: 0, itemType: 1n, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }; +const PLATE: EquipEntry = { slot: 1, itemType: 11n, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }; + +const ATTACKER: PetSnapshot = { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: 1861919000, + sourceVersion: 1861918000n, +}; + +const DEFENDER: PetSnapshot = { + ...ATTACKER, + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, +}; + +const BASE: BattleSnapshot = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + attacker: ATTACKER, + defender: DEFENDER, + takenAt: 1861918900, +}; + +describe('schema versioning', () => { + // The single most breakage-prone property in this change: an absent version means the + // snapshot was written before the field existed, which is version 1 by definition. + // Defaulting to the build's current version would re-encode every stored snapshot + // under a layout it was never hashed under. + it('treats an absent version as 1, not as the current one', () => { + expect(assertBattleSnapshot(BASE).schemaVersion).toBe(1); + expect(SNAPSHOT_SCHEMA_VERSION).toBe(2); + }); + + it('hashes a version 1 snapshot identically whether the version is implied or stated', () => { + expect(hashBattleSnapshot(BASE)).toBe(hashBattleSnapshot({ ...BASE, schemaVersion: 1 })); + }); + + // The version travels inside the hashed bytes, so the same pets under two layouts are + // two different digests rather than one ambiguous one. + it('gives an ungeared v2 snapshot a different hash from its v1 twin', () => { + expect(hashBattleSnapshot({ ...BASE, schemaVersion: 2 })).not.toBe(hashBattleSnapshot(BASE)); + }); + + it('refuses a version this build does not implement', () => { + expect(() => assertBattleSnapshot({ ...BASE, schemaVersion: 3 })).toThrow(/unsupported snapshot schema version/); + }); + + // Refused rather than silently dropped: encoding it at v1 would hash a fight without + // the gear it was supposed to include. + it('refuses equipment on a version 1 snapshot', () => { + expect(() => + assertBattleSnapshot({ ...BASE, schemaVersion: 1, attacker: { ...ATTACKER, equipment: [BLADE] } }), + ).toThrow(/cannot carry equipment/); + }); +}); + +describe('encoding equipment', () => { + const geared: BattleSnapshot = { + ...BASE, + schemaVersion: 2, + attacker: { ...ATTACKER, equipment: [BLADE, PLATE] }, + }; + + it('puts the resolved modifiers in the digest', () => { + const weaker: BattleSnapshot = { + ...geared, + attacker: { ...ATTACKER, equipment: [{ ...BLADE, atk: 3 }, PLATE] }, + }; + expect(hashBattleSnapshot(geared)).not.toBe(hashBattleSnapshot(weaker)); + }); + + // The item type is hashed alongside the numbers, so a verifier can hold the operator to + // both: the modifiers the fight used, and which item was meant to have granted them. + it('puts the item type in the digest even though the engine never reads it', () => { + const swapped: BattleSnapshot = { + ...geared, + attacker: { ...ATTACKER, equipment: [{ ...BLADE, itemType: 2n }, PLATE] }, + }; + expect(hashBattleSnapshot(geared)).not.toBe(hashBattleSnapshot(swapped)); + }); + + it('distinguishes which pet is wearing the gear', () => { + const onDefender: BattleSnapshot = { + ...BASE, + schemaVersion: 2, + defender: { ...DEFENDER, equipment: [BLADE, PLATE] }, + }; + expect(hashBattleSnapshot(geared)).not.toBe(hashBattleSnapshot(onDefender)); + }); + + it('encodes an empty list and an absent one the same way', () => { + const empty = hashBattleSnapshot({ ...BASE, schemaVersion: 2, attacker: { ...ATTACKER, equipment: [] } }); + expect(empty).toBe(hashBattleSnapshot({ ...BASE, schemaVersion: 2 })); + }); + + it('is deterministic', () => { + expect(encodeBattleSnapshot(geared)).toEqual(encodeBattleSnapshot(geared)); + }); +}); + +describe('equipment validation', () => { + const geared = (equipment: EquipEntry[]): BattleSnapshot => ({ + ...BASE, + schemaVersion: 2, + attacker: { ...ATTACKER, equipment }, + }); + + // Ascending order makes the encoding canonical without an implicit sort, and rejects + // two items in one slot — a state ItemCore cannot produce, so a snapshot claiming it + // could not be reconciled with any chain history. + it('refuses slots out of order', () => { + expect(() => assertBattleSnapshot(geared([PLATE, BLADE]))).toThrow(/strictly ascending/); + }); + + it('refuses two items in one slot', () => { + expect(() => assertBattleSnapshot(geared([BLADE, { ...BLADE, itemType: 2n }]))).toThrow(/strictly ascending/); + }); + + it('refuses a slot the contract does not have', () => { + expect(() => assertBattleSnapshot(geared([{ ...BLADE, slot: 3 }]))).toThrow(/slot must be 0-2/); + }); + + // Item type 0 is ItemCore's empty-slot sentinel, so it is never a real equipped item. + it('refuses item type 0', () => { + expect(() => assertBattleSnapshot(geared([{ ...BLADE, itemType: 0n }]))).toThrow(/not a valid item type/); + }); + + // The engine truncates to 16 bits with wraparound rather than clamping, so a negative + // modifier is one underflow away from a pet with 65,000 HP. + it('refuses a negative bonus', () => { + expect(() => assertBattleSnapshot(geared([{ ...BLADE, atk: -1 }]))).toThrow(/atk must be 0-65535/); + }); + + it('refuses a bonus that does not fit 16 bits', () => { + expect(() => assertBattleSnapshot(geared([{ ...BLADE, hp: 70000 }]))).toThrow(/hp must be 0-65535/); + }); + + it('keeps a valid list intact and in order', () => { + const checked = assertBattleSnapshot(geared([BLADE, PLATE])); + expect(checked.attacker.equipment).toEqual([BLADE, PLATE]); + }); +}); From dc41dd5e28afd86acf6cd0eee2daec58f679a2bb Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 01:04:02 -0400 Subject: [PATCH 18/56] feat(protocol): put the item catalog in the ruleset --- contracts/test-vectors/protocol-ruleset.json | 69 +++++++++- protocol/src/domain/schemaVersions.ts | 7 +- protocol/src/ruleset/bundle.ts | 77 +++++++++-- protocol/src/ruleset/hash.ts | 26 +++- protocol/src/ruleset/types.ts | 124 +++++++++++++++++- protocol/tests/receipt/vectors.test.ts | 24 +++- protocol/tests/ruleset/vectors.test.ts | 38 +++++- verifier/fixtures/corpus-tampered.json | 54 ++++---- verifier/fixtures/corpus.json | 54 ++++---- ...1bdc0e1187d014782dbbb72e10ca42a4ccbac.json | 19 +++ 10 files changed, 409 insertions(+), 83 deletions(-) create mode 100644 verifier/rulesets/0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac.json diff --git a/contracts/test-vectors/protocol-ruleset.json b/contracts/test-vectors/protocol-ruleset.json index 86393475..580e9276 100644 --- a/contracts/test-vectors/protocol-ruleset.json +++ b/contracts/test-vectors/protocol-ruleset.json @@ -3,7 +3,7 @@ "cases": [ { "name": "source-defaults", - "note": "The ruleset this build implements with GameConfig source defaults. Anchors every other case.", + "note": "Schema version 1 source defaults, under engine version 1. Kept exactly as recorded: version 1 rulesets have receipts signed against them and must keep hashing identically forever, so this pins the v1 encoder permanently.", "ruleset": { "version": 1, "engineId": "cryptopets-combat-ts", @@ -308,6 +308,73 @@ "maxLevel": 100 }, "expectedRulesetHash": "0x973e0ece8b3e48b96727ea5ec21e9dae39471f8ec5a358236b5c7c415215b2a6" + }, + { + "name": "source-defaults-v2", + "note": "The ruleset this build implements: schema version 2 with an empty item catalog (roadmap §4). Differs from the v1 case by the schema version, the engine version, and a zero-length catalog.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 2, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100, + "schemaVersion": 2, + "itemCatalog": [] + }, + "expectedRulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac" + }, + { + "name": "item-catalog", + "note": "A ruleset pricing two items. Must differ from source-defaults-v2: a rebalance has to move rulesetHash, which is what invalidates defence consent given under the old numbers (§D, §4).", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 2, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100, + "schemaVersion": 2, + "itemCatalog": [ + { + "itemType": "1", + "slot": 0, + "hp": 0, + "atk": 4, + "def": 0, + "int": 0, + "mdef": 0 + }, + { + "itemType": "11", + "slot": 1, + "hp": 30, + "atk": 0, + "def": 10, + "int": 0, + "mdef": 0 + } + ] + }, + "expectedRulesetHash": "0x9b4fe8ecacfecd7af6d6d39b82c62a38fb84f14a4228f94e95f2a3a97689ab38" } ] } diff --git a/protocol/src/domain/schemaVersions.ts b/protocol/src/domain/schemaVersions.ts index 85e37c23..6391fe85 100644 --- a/protocol/src/domain/schemaVersions.ts +++ b/protocol/src/domain/schemaVersions.ts @@ -21,7 +21,8 @@ export const SCHEMA_VERSIONS = { defenseAuthorization: 1, /** 2 adds per-pet equipment (roadmap §4). Version 1 snapshots carry none. */ snapshot: 2, - ruleset: 1, + /** 2 adds the combat-affecting item catalog (roadmap §4). */ + ruleset: 2, commitment: 1, receipt: 1, combatLog: 1, @@ -39,7 +40,9 @@ const SUPPORTED_VERSIONS: Record = { // 1 stays supported: every receipt signed before equipment existed names a v1 // snapshot, and those have to keep verifying forever (§H). snapshot: [1, 2], - ruleset: [1], + // 1 stays supported for the same reason: bundles published before equipment + // existed are named by receipts that must keep verifying. + ruleset: [1, 2], commitment: [1], receipt: [1], combatLog: [1], diff --git a/protocol/src/ruleset/bundle.ts b/protocol/src/ruleset/bundle.ts index 20ca299c..9df0fbfa 100644 --- a/protocol/src/ruleset/bundle.ts +++ b/protocol/src/ruleset/bundle.ts @@ -24,21 +24,47 @@ export function serializeRuleset(ruleset: Ruleset): string { for (const field of SKILL_CONFIG_FIELDS) { skillConfig[field] = checked.skillConfig[field]; } - return `${JSON.stringify( - { - version: checked.version, - engineId: checked.engineId, - engineVersion: checked.engineVersion, - maxRounds: checked.maxRounds, - maxLevel: checked.maxLevel, - skillConfig, - }, - null, - 2, - )}\n`; + const document: Record = { + version: checked.version, + engineId: checked.engineId, + engineVersion: checked.engineVersion, + maxRounds: checked.maxRounds, + maxLevel: checked.maxLevel, + skillConfig, + }; + + // Emitted only from version 2 on, so a bundle published before the item catalog + // existed serializes byte-identically to how it always did. Item types are decimal + // strings: a uint256 does not survive JSON's number type, and a bundle that lost + // precision on an id would name the wrong item. + if ((checked.schemaVersion ?? 1) >= 2) { + document.schemaVersion = checked.schemaVersion; + document.itemCatalog = (checked.itemCatalog ?? []).map((item) => ({ + itemType: item.itemType.toString(), + slot: item.slot, + hp: item.hp, + atk: item.atk, + def: item.def, + int: item.int, + mdef: item.mdef, + })); + } + + return `${JSON.stringify(document, null, 2)}\n`; } -const RULESET_KEYS = ['version', 'engineId', 'engineVersion', 'maxRounds', 'maxLevel', 'skillConfig'] as const; +const RULESET_KEYS = [ + 'version', + 'engineId', + 'engineVersion', + 'maxRounds', + 'maxLevel', + 'skillConfig', + 'itemCatalog', + 'schemaVersion', +] as const; + +const ITEM_KEYS = ['itemType', 'slot', 'hp', 'atk', 'def', 'int', 'mdef'] as const; /** * Parses a published bundle. @@ -74,7 +100,30 @@ export function parseRulesetBundle(json: string): Ruleset { } } - return assertRuleset(record as unknown as Ruleset); + // Item types come back as decimal strings; `assertRuleset` wants bigints, and doing the + // conversion here keeps the JSON transport detail out of the validator. + const parsedCatalog = Array.isArray(record.itemCatalog) + ? record.itemCatalog.map((entry, index) => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`ruleset bundle itemCatalog[${index}] is not an object`); + } + const item = entry as Record; + const unexpectedItemKeys = Object.keys(item).filter((key) => !ITEM_KEYS.includes(key as never)); + if (unexpectedItemKeys.length > 0) { + throw new Error( + `ruleset bundle itemCatalog[${index}] has unexpected keys: ${unexpectedItemKeys.join(', ')}`, + ); + } + if (typeof item.itemType !== 'string' || !/^[0-9]+$/.test(item.itemType)) { + throw new Error( + `ruleset bundle itemCatalog[${index}].itemType must be a decimal string, got ${JSON.stringify(item.itemType)}`, + ); + } + return { ...item, itemType: BigInt(item.itemType) }; + }) + : record.itemCatalog; + + return assertRuleset({ ...record, itemCatalog: parsedCatalog } as unknown as Ruleset); } /** diff --git a/protocol/src/ruleset/hash.ts b/protocol/src/ruleset/hash.ts index 7c473ed6..de931150 100644 --- a/protocol/src/ruleset/hash.ts +++ b/protocol/src/ruleset/hash.ts @@ -1,4 +1,3 @@ -import { currentSchemaVersion } from '../domain/schemaVersions'; import type { Hex } from '../encoding/bytes'; import { DOMAIN_TAGS } from '../encoding/domain'; import { keccak256Hex } from '../encoding/hash'; @@ -16,12 +15,15 @@ import { assertRuleset, type Ruleset, SKILL_CONFIG_FIELDS } from './types'; * deployment matters, the object referencing the ruleset already carries it. * * The schema version is written directly for the same reason: `writeHeader` bundles - * version with domain, and there is no domain here. + * version with domain, and there is no domain here. It comes from the ruleset rather than + * from this build, so re-encoding a bundle published before the item catalog existed + * reproduces the bytes it was hashed under. */ export function encodeRuleset(ruleset: Ruleset): Uint8Array { const checked = assertRuleset(ruleset); + const version = checked.schemaVersion ?? 1; const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.RULESET) - .u16(currentSchemaVersion('ruleset')) + .u16(version) .u32(checked.version) .text(checked.engineId) .u32(checked.engineVersion) @@ -30,6 +32,24 @@ export function encodeRuleset(ruleset: Ruleset): Uint8Array { for (const field of SKILL_CONFIG_FIELDS) { writer.u32(checked.skillConfig[field]); } + + if (version < 2) { + return writer.build(); + } + + // The catalog is inside the hash rather than beside it as a digest of its own. A + // separate `itemCatalogHash` field would be a second thing to keep in step for no + // gain: the bundle already publishes these rows, so hashing them directly is both the + // identity and the content. + writer.array(checked.itemCatalog ?? [], (w, item) => { + w.u256(item.itemType) + .u8(item.slot) + .u16(item.hp) + .u16(item.atk) + .u16(item.def) + .u16(item.int) + .u16(item.mdef); + }); return writer.build(); } diff --git a/protocol/src/ruleset/types.ts b/protocol/src/ruleset/types.ts index 9175a863..9c348d99 100644 --- a/protocol/src/ruleset/types.ts +++ b/protocol/src/ruleset/types.ts @@ -1,6 +1,30 @@ import { DEFAULT_SKILL_CONFIG, type SkillConfig } from '../combat/skills'; import { MAX_ROUNDS } from '../combat/sim'; import { DEFAULT_MAX_LEVEL } from '../combat/xp'; +import { assertSupportedSchemaVersion, currentSchemaVersion } from '../domain/schemaVersions'; + +/** + * What one item type does to a pet's attributes (roadmap §4). + * + * Part of the ruleset rather than a separate artifact, so a rebalance changes + * `rulesetHash` on its own. That is the mechanism §4 asks for: outstanding defence + * authorizations are bound to the hash, so re-pricing a sword invalidates consent given + * under the old numbers instead of silently re-interpreting it. + * + * Only equipment appears here. A potion or a badge cannot change a fight, and listing + * one would make adding a collectible invalidate every player's consent for nothing. + */ +export interface ItemModifier { + /** ERC-1155 token id. */ + itemType: bigint; + /** Equip slot 0-2, matching ItemCore.SLOT_*. */ + slot: number; + hp: number; + atk: number; + def: number; + int: number; + mdef: number; +} /** * A versioned, content-addressed statement of the rules a battle was fought under. @@ -11,7 +35,7 @@ import { DEFAULT_MAX_LEVEL } from '../combat/xp'; * receipt, and the named bundle is published so anyone can replay against exactly * those numbers years later (§F, §H). * - * Two kinds of thing live here, and the distinction matters: + * Three kinds of thing live here, and the distinctions matter: * * - **Parameters the engine reads at runtime**: the skill/balance config and the * level cap. These come from `GameConfig` on chain and are owner-tunable, so they @@ -20,6 +44,9 @@ import { DEFAULT_MAX_LEVEL } from '../combat/xp'; * declared constant. The engine's own code cannot hash itself, so a fight-math * change is recorded by bumping `engineVersion`. That bump is a manual step, and * skipping it is how two different implementations end up claiming one ruleset. + * - **Content that changes outcomes**: the item catalog (§4). Off-chain and + * owner-editable like the balance knobs, and in here for the same reason: a fight it + * could have changed has to name the version it ran under. */ export interface Ruleset { /** Monotonic ruleset version. The number a receipt reports. */ @@ -34,11 +61,33 @@ export interface Ruleset { skillConfig: SkillConfig; /** Level cap, sourced from `GameConfig.maxLevel`. */ maxLevel: number; + /** + * Every combat-affecting item, ordered by `itemType` (schema v2+). + * + * Absent or empty on a deployment with no equipment, which is what every version 1 + * ruleset is. The values here are the *declared* effects; a snapshot carries the + * modifiers actually applied, and a verifier compares the two. + */ + itemCatalog?: ItemModifier[]; + /** + * Which layout this ruleset was written under. Absent means 1. + * + * Same rule as the snapshot's, for the same reason: published bundles predate this + * field, and re-encoding one at the current version would change the `rulesetHash` + * every receipt naming it was signed against. + */ + schemaVersion?: number; } /** The engine this package implements. */ export const ENGINE_ID = 'cryptopets-combat-ts'; +/** + * The layout a new ruleset should declare. Absent means 1, for published bundles that + * predate the field. + */ +export const RULESET_SCHEMA_VERSION = currentSchemaVersion('ruleset'); + /** * Bumped when `src/combat/` changes what a fight or a progression delta produces. * @@ -46,8 +95,12 @@ export const ENGINE_ID = 'cryptopets-combat-ts'; * call that makes this dangerous: when in doubt, bump. A missed bump means two * implementations disagree while both claim the same `rulesetHash`, and the golden * vectors are the only thing that would notice. + * + * 2: the engine reads equipment modifiers (roadmap §4). An ungeared fight resolves + * identically, which the unchanged golden vectors prove, but the engine is no longer the + * same function of its inputs, so the version moves. */ -export const ENGINE_VERSION = 1; +export const ENGINE_VERSION = 2; /** * The ruleset this build implements with source defaults. @@ -64,6 +117,12 @@ export const SOURCE_DEFAULT_RULESET: Ruleset = { maxRounds: MAX_ROUNDS, skillConfig: DEFAULT_SKILL_CONFIG, maxLevel: DEFAULT_MAX_LEVEL, + // Empty, and it has to be: the item catalog is content owned by a PolyForm package, + // and this one is MIT and cannot import it. A deployment builds its ruleset by reading + // its own catalog, exactly as it already reads skillConfig from GameConfig rather than + // trusting the constant beside it. + itemCatalog: [], + schemaVersion: RULESET_SCHEMA_VERSION, }; /** Field order for `skillConfig`, which is also its canonical encoding order. */ @@ -100,6 +159,10 @@ const SAFE_ENGINE_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{0,63}$/; /** Validates an untrusted ruleset, returning a normalized copy. */ export function assertRuleset(ruleset: Ruleset): Ruleset { + // Absent means 1, matching the snapshot's rule and for the same reason: bundles + // published before the item catalog existed are named by receipts already signed. + const schemaVersion = ruleset.schemaVersion ?? 1; + assertSupportedSchemaVersion('ruleset', schemaVersion); assertPositiveInt(ruleset.version, 'version'); assertPositiveInt(ruleset.engineVersion, 'engineVersion'); assertPositiveInt(ruleset.maxRounds, 'maxRounds'); @@ -127,6 +190,13 @@ export function assertRuleset(ruleset: Ruleset): Ruleset { skillConfig[field] = value; } + const itemCatalog = assertItemCatalog(ruleset.itemCatalog); + if (schemaVersion < 2 && itemCatalog.length > 0) { + // Refused rather than dropped: version 1 has nowhere to put the catalog, so + // encoding this would publish rules that omit the gear they were meant to price. + throw new Error('ruleset schema version 1 cannot carry an item catalog; use version 2'); + } + return { version: ruleset.version, engineId: ruleset.engineId, @@ -134,9 +204,59 @@ export function assertRuleset(ruleset: Ruleset): Ruleset { maxRounds: ruleset.maxRounds, skillConfig, maxLevel: ruleset.maxLevel, + itemCatalog, + schemaVersion, }; } +/** Highest slot index the protocol accepts, matching ItemCore's three gear slots. */ +const MAX_SLOT = 2; +/** Sanity bound on a declared bonus, mirroring the snapshot's. */ +const MAX_BONUS = 0xffff; + +/** + * Validates the item catalog. + * + * Ordered strictly by `itemType`, which makes the encoding canonical without an implicit + * sort and rejects one item type declared twice — two prices for one sword is not a + * ruleset anyone can be held to. + */ +function assertItemCatalog(items: ItemModifier[] | undefined): ItemModifier[] { + if (items === undefined) { + return []; + } + if (!Array.isArray(items)) { + throw new Error('itemCatalog must be an array'); + } + + let previous = -1n; + return items.map((item, index) => { + const where = `itemCatalog[${index}]`; + if (typeof item.itemType !== 'bigint' || item.itemType <= 0n || item.itemType >= 1n << 256n) { + throw new Error(`${where}.itemType is not a valid item type: ${item.itemType}`); + } + if (item.itemType <= previous) { + throw new Error(`${where}.itemType must be strictly ascending; got ${item.itemType} after ${previous}`); + } + previous = item.itemType; + + if (!Number.isSafeInteger(item.slot) || item.slot < 0 || item.slot > MAX_SLOT) { + throw new Error(`${where}.slot must be 0-${MAX_SLOT}, got ${item.slot}`); + } + + const bonuses = {} as Record<'hp' | 'atk' | 'def' | 'int' | 'mdef', number>; + for (const field of ['hp', 'atk', 'def', 'int', 'mdef'] as const) { + const value = item[field]; + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_BONUS) { + throw new Error(`${where}.${field} must be 0-${MAX_BONUS}, got ${value}`); + } + bonuses[field] = value; + } + + return { itemType: item.itemType, slot: item.slot, ...bonuses }; + }); +} + function assertPositiveInt(value: number, field: string): void { if (!Number.isSafeInteger(value) || value < 1) { throw new Error(`${field} must be a positive integer, got ${value}`); diff --git a/protocol/tests/receipt/vectors.test.ts b/protocol/tests/receipt/vectors.test.ts index 61a363ff..8123c13f 100644 --- a/protocol/tests/receipt/vectors.test.ts +++ b/protocol/tests/receipt/vectors.test.ts @@ -9,7 +9,7 @@ import type { Hex } from '../../src/encoding/bytes'; import { computeProgression } from '../../src/progression'; import { deriveBattleSeed } from '../../src/randomness'; import { type BattleReceipt, hashBattleReceipt, hashCombatLog } from '../../src/receipt'; -import { hashRuleset, SOURCE_DEFAULT_RULESET } from '../../src/ruleset'; +import { hashRuleset, type Ruleset, SOURCE_DEFAULT_RULESET } from '../../src/ruleset'; import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../../src/snapshot'; /** @@ -21,6 +21,22 @@ import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../.. * test cover the composition too, since a receipt whose seed does not follow from its own * inputs is rejected outright by validation. */ +/** + * The ruleset these vectors were generated under. + * + * Pinned rather than read from `SOURCE_DEFAULT_RULESET`, which tracks whatever this build + * currently implements. Taking the live constant made the vectors follow an engine bump + * instead of catching it: a receipt records the rules its fight actually ran under, and + * these fixtures ran under engine 1 with no item catalog. Anything else here still fails, + * which is the point — a change to the skill defaults is drift in the fight rules. + */ +const VECTOR_RULESET: Ruleset = { + ...SOURCE_DEFAULT_RULESET, + engineVersion: 1, + schemaVersion: 1, + itemCatalog: [], +}; + interface PetFixture { petId: string; owner: string; @@ -96,7 +112,7 @@ export function buildReceipt(fixture: ReceiptFixture): BattleReceipt { takenAt: fixture.snapshot.takenAt, }; const domain = { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }; - const rulesetHash = hashRuleset(SOURCE_DEFAULT_RULESET); + const rulesetHash = hashRuleset(VECTOR_RULESET); const seed = deriveBattleSeed({ domain, drandRandomness: fixture.beacon.randomness as Hex, @@ -114,7 +130,7 @@ export function buildReceipt(fixture: ReceiptFixture): BattleReceipt { snapshot.defender.level, snapshot.defender.skill, seed.value, - SOURCE_DEFAULT_RULESET.skillConfig, + VECTOR_RULESET.skillConfig, ); return { @@ -131,7 +147,7 @@ export function buildReceipt(fixture: ReceiptFixture): BattleReceipt { randomness: fixture.beacon.randomness as Hex, }, seed: seed.hex, - rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetVersion: VECTOR_RULESET.version, rulesetHash, result: { attackerWon: fixture.attackerWon, diff --git a/protocol/tests/ruleset/vectors.test.ts b/protocol/tests/ruleset/vectors.test.ts index cb1652bc..fb29df23 100644 --- a/protocol/tests/ruleset/vectors.test.ts +++ b/protocol/tests/ruleset/vectors.test.ts @@ -12,10 +12,23 @@ import { hashRuleset, type Ruleset, SKILL_CONFIG_FIELDS, SOURCE_DEFAULT_RULESET interface RulesetCase { name: string; note: string; - ruleset: Ruleset; + /** As stored: item types are decimal strings, since a uint256 is not a JSON number. */ + ruleset: Omit & { + itemCatalog?: { itemType: string; slot: number; hp: number; atk: number; def: number; int: number; mdef: number }[]; + }; expectedRulesetHash: string; } +/** Widens the stored item types back to bigints, as `parseRulesetBundle` does. */ +function toRuleset(stored: RulesetCase['ruleset']): Ruleset { + return { + ...stored, + ...(stored.itemCatalog && { + itemCatalog: stored.itemCatalog.map((item) => ({ ...item, itemType: BigInt(item.itemType) })), + }), + }; +} + const here = dirname(fileURLToPath(import.meta.url)); const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-ruleset.json'); const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: RulesetCase[] }; @@ -25,7 +38,7 @@ const byName = new Map(vectors.cases.map((c) => [c.name, c])); describe('ruleset golden vectors', () => { for (const c of vectors.cases) { it(`matches the recorded hash for "${c.name}"`, () => { - expect(hashRuleset(c.ruleset)).toBe(c.expectedRulesetHash); + expect(hashRuleset(toRuleset(c.ruleset))).toBe(c.expectedRulesetHash); }); } }); @@ -45,10 +58,29 @@ describe('properties the vectors exist to pin', () => { }); it('anchors the source-default ruleset this build implements', () => { - const anchor = byName.get('source-defaults')!; + const anchor = byName.get('source-defaults-v2')!; expect(hashRuleset(SOURCE_DEFAULT_RULESET)).toBe(anchor.expectedRulesetHash); }); + // Version 1 rulesets have receipts signed against them, so their hash has to keep + // reproducing forever. This is the guard on the v1 encoder specifically: it is the one + // path that no longer runs in production and would otherwise rot unnoticed. + it('still reproduces the version 1 hash from before the item catalog existed', () => { + const v1 = byName.get('source-defaults')!; + expect(hashRuleset({ ...SOURCE_DEFAULT_RULESET, engineVersion: 1, schemaVersion: 1, itemCatalog: [] })).toBe( + v1.expectedRulesetHash, + ); + }); + + // A rebalance must move the hash. Defence consent is bound to it, so re-pricing a + // sword invalidates consent given under the old numbers rather than silently + // reinterpreting it (§D). + it('separates a priced catalog from an empty one', () => { + expect(byName.get('item-catalog')!.expectedRulesetHash).not.toBe( + byName.get('source-defaults-v2')!.expectedRulesetHash, + ); + }); + it('moves the hash for a version or engine bump', () => { const base = byName.get('source-defaults')!.expectedRulesetHash; expect(byName.get('version-bump')!.expectedRulesetHash).not.toBe(base); diff --git a/verifier/fixtures/corpus-tampered.json b/verifier/fixtures/corpus-tampered.json index 3542858f..5b3ba3a9 100644 --- a/verifier/fixtures/corpus-tampered.json +++ b/verifier/fixtures/corpus-tampered.json @@ -1,8 +1,8 @@ { "receipts": [ { - "receiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", - "signature": "0xb4b8bbc088c6abe5c8e7e3a5a3e8131de91c80bb305784096df71da3f3ea22b442b633b06f2026970b832bacefe8edc4f7d2ebae591f56435653c0e8c7d720391b", + "receiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", + "signature": "0x8cd667930bbbe07e00110cd2a834a4619617f71d499fb64728f0d7610c56c2a104429640193f509f02d7bd19eda9a739e84d725d77f7ac7d2e862a208750c6151b", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -52,15 +52,15 @@ "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" }, - "seed": "0x3a43529c116460f0bfc4c23ea9a07a13379f301907411f269fcfbf305c523ee9", + "seed": "0xa2ee93d0c0932cd912dd8a3a5edc4f13995d7e76fea38e442d2ad0fc76d9369c", "rulesetVersion": 1, - "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", "result": { "attackerWon": true, - "rounds": 6, - "winnerHpRemaining": 163 + "rounds": 5, + "winnerHpRemaining": 199 }, - "combatLogHash": "0x9b29c8103de014db6af3ce262da082bb7084a3b971b8eded1ec02cbe6103e491", + "combatLogHash": "0xe18fb4b0b628ef750eb879dd20a460cbfc72709fbf6b37300ec99b04a467c930", "progression": { "attacker": { "petId": "1", @@ -94,8 +94,8 @@ } }, { - "receiptHash": "0x708856ad23500d0c3b16f04edccd21dbe84533f41fdb3dba177bcce1c43d3d5c", - "signature": "0xa1e69c666d9a55973ea9e441677a5db335297a6d17856f38c5050383edec759e7e13500b3686303d89b9ef4774e842e806bcec268cf8947d367d85a652ec96121b", + "receiptHash": "0xffadd1b4dda86c0c7c47bee4c78d8f7cb5fad47f13a30a46313c7c547f1b44aa", + "signature": "0xfa29c18d5dae886e8e217e957fb8e5eddbefd7c916bceefd13249a490e512eba5cf4329c7121015da9b6ebc339738c06a0b0b024801a6525369cab431621ba861c", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -145,15 +145,15 @@ "signature": "0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817", "randomness": "0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1" }, - "seed": "0x0ca434dfbcae5a627c36579aef46a1c08bd73101308afe21fb62fc93d99b6a24", + "seed": "0xb8c3cbf50175c405f5bcb359bc96eead958dffc4282a5c228a4da1740403f37e", "rulesetVersion": 1, - "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", "result": { "attackerWon": true, - "rounds": 5, - "winnerHpRemaining": 217 + "rounds": 4, + "winnerHpRemaining": 253 }, - "combatLogHash": "0x7af42056e7a4fd4ab1c33e6dad3f6b88b4e2dce9e04ee3faccd2e5895aed2fe2", + "combatLogHash": "0x406c81f477c05a6d703b4eecb4a6f6bda1b4ea5d19b55e8afebc094efdcb2c0a", "progression": { "attacker": { "petId": "1", @@ -179,16 +179,16 @@ } }, "sequence": 2, - "previousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", - "attackerPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", - "defenderPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "previousReceiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", + "attackerPreviousReceiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", + "defenderPreviousReceiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", "createdAt": 1692806369, "signingKeyId": "battle-signer-2026-07" } }, { - "receiptHash": "0xb6706876dca8338897b8e55665072ca7d5069e52ef5bada6756e3e3fac67b385", - "signature": "0xbc71375a7af530b9fcb324133b45088fc73db95884f51d337ff76f18eb826d7330411780d1f8b19d7247b6937f15046aee72b7419619813d2b8440751f183a571b", + "receiptHash": "0x312885fbefed24b78c8510df832fb708bd8d85a8d40ce38ee0e6730e8725bdbc", + "signature": "0x13f0a055dc65e8c303f80bd1542bbddb826b5a00479a49b7033ff3f5c63429502b0a6366d4d36f4db23a5170d97c9fd3de3d24d3ea1ff2c16bdd9c73cadf69ec1b", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -238,15 +238,15 @@ "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" }, - "seed": "0xbedd2dc5371c75eb1502e3657d2c9febc81c899740e919259e15d9a851cbb844", + "seed": "0x59edb62ce7ad180d584f817e1a4fd7e8a21aa02df2feb0cc17548214824ca299", "rulesetVersion": 1, - "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", "result": { "attackerWon": true, - "rounds": 5, - "winnerHpRemaining": 217 + "rounds": 6, + "winnerHpRemaining": 199 }, - "combatLogHash": "0xe5e54f05aa6be342cfb124b047f0987acfe75651be82c374b8b5155a3f354671", + "combatLogHash": "0xa626aa46f28919e253dd0941372e8e66e48830657f449f8214f25d912383cf1b", "progression": { "attacker": { "petId": "1", @@ -272,9 +272,9 @@ } }, "sequence": 3, - "previousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", - "attackerPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", - "defenderPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "previousReceiptHash": "0x0a2f38535771435de01bc45a9a92bc61af294a22cb3fd40147fa16d4abcb2085", + "attackerPreviousReceiptHash": "0x0a2f38535771435de01bc45a9a92bc61af294a22cb3fd40147fa16d4abcb2085", + "defenderPreviousReceiptHash": "0x0a2f38535771435de01bc45a9a92bc61af294a22cb3fd40147fa16d4abcb2085", "createdAt": 1692806370, "signingKeyId": "battle-signer-2026-07" } diff --git a/verifier/fixtures/corpus.json b/verifier/fixtures/corpus.json index 62dda64a..e72f1ec1 100644 --- a/verifier/fixtures/corpus.json +++ b/verifier/fixtures/corpus.json @@ -1,8 +1,8 @@ { "receipts": [ { - "receiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", - "signature": "0xb4b8bbc088c6abe5c8e7e3a5a3e8131de91c80bb305784096df71da3f3ea22b442b633b06f2026970b832bacefe8edc4f7d2ebae591f56435653c0e8c7d720391b", + "receiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", + "signature": "0x8cd667930bbbe07e00110cd2a834a4619617f71d499fb64728f0d7610c56c2a104429640193f509f02d7bd19eda9a739e84d725d77f7ac7d2e862a208750c6151b", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -52,15 +52,15 @@ "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" }, - "seed": "0x3a43529c116460f0bfc4c23ea9a07a13379f301907411f269fcfbf305c523ee9", + "seed": "0xa2ee93d0c0932cd912dd8a3a5edc4f13995d7e76fea38e442d2ad0fc76d9369c", "rulesetVersion": 1, - "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", "result": { "attackerWon": true, - "rounds": 6, - "winnerHpRemaining": 163 + "rounds": 5, + "winnerHpRemaining": 199 }, - "combatLogHash": "0x9b29c8103de014db6af3ce262da082bb7084a3b971b8eded1ec02cbe6103e491", + "combatLogHash": "0xe18fb4b0b628ef750eb879dd20a460cbfc72709fbf6b37300ec99b04a467c930", "progression": { "attacker": { "petId": "1", @@ -94,8 +94,8 @@ } }, { - "receiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", - "signature": "0xfae9832c1596e1a6c0d6eb602f6632688a3906fd3df8f4ddf19e82228eeb45564f50036db14bf9a407900b05924ed47484c4f9db15a988c75a56cbeb5ef633611b", + "receiptHash": "0x0a2f38535771435de01bc45a9a92bc61af294a22cb3fd40147fa16d4abcb2085", + "signature": "0xe2d8c738d77d4e2577ef3bcf3693037b71f6b9e197558fb2da18fd1972696b9c26e9230b888c0acdba70e133b846936e95c37c20e38699dbd32dc07ef0c52d3a1c", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -145,15 +145,15 @@ "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" }, - "seed": "0xfee7c217ea4a80e130ba2cd6a3b3ff1472176f76ffcf32fbf36ab1ef6d3cc941", + "seed": "0x20d08270cccff3605dc2c8b4dfd72d5c6cc7a1de8333daa06a1f6a529d24e3ec", "rulesetVersion": 1, - "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", "result": { "attackerWon": true, - "rounds": 4, - "winnerHpRemaining": 235 + "rounds": 5, + "winnerHpRemaining": 199 }, - "combatLogHash": "0xc5acdcf5dee811d8d109727c3867a6db28f102046c29031eb04adb42e19b4449", + "combatLogHash": "0x9be195db9d0ea524beea04ac2f3f04702b6c63cb4f45689fc6c580c6d506ced2", "progression": { "attacker": { "petId": "1", @@ -179,16 +179,16 @@ } }, "sequence": 2, - "previousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", - "attackerPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", - "defenderPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "previousReceiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", + "attackerPreviousReceiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", + "defenderPreviousReceiptHash": "0xf0a4d590f92627eaf8ff6a4ae4e296603c7ea2f39bd547382d523ff4caa082e8", "createdAt": 1692806369, "signingKeyId": "battle-signer-2026-07" } }, { - "receiptHash": "0xb6706876dca8338897b8e55665072ca7d5069e52ef5bada6756e3e3fac67b385", - "signature": "0xbc71375a7af530b9fcb324133b45088fc73db95884f51d337ff76f18eb826d7330411780d1f8b19d7247b6937f15046aee72b7419619813d2b8440751f183a571b", + "receiptHash": "0x312885fbefed24b78c8510df832fb708bd8d85a8d40ce38ee0e6730e8725bdbc", + "signature": "0x13f0a055dc65e8c303f80bd1542bbddb826b5a00479a49b7033ff3f5c63429502b0a6366d4d36f4db23a5170d97c9fd3de3d24d3ea1ff2c16bdd9c73cadf69ec1b", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -238,15 +238,15 @@ "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" }, - "seed": "0xbedd2dc5371c75eb1502e3657d2c9febc81c899740e919259e15d9a851cbb844", + "seed": "0x59edb62ce7ad180d584f817e1a4fd7e8a21aa02df2feb0cc17548214824ca299", "rulesetVersion": 1, - "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", "result": { "attackerWon": true, - "rounds": 5, - "winnerHpRemaining": 217 + "rounds": 6, + "winnerHpRemaining": 199 }, - "combatLogHash": "0xe5e54f05aa6be342cfb124b047f0987acfe75651be82c374b8b5155a3f354671", + "combatLogHash": "0xa626aa46f28919e253dd0941372e8e66e48830657f449f8214f25d912383cf1b", "progression": { "attacker": { "petId": "1", @@ -272,9 +272,9 @@ } }, "sequence": 3, - "previousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", - "attackerPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", - "defenderPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "previousReceiptHash": "0x0a2f38535771435de01bc45a9a92bc61af294a22cb3fd40147fa16d4abcb2085", + "attackerPreviousReceiptHash": "0x0a2f38535771435de01bc45a9a92bc61af294a22cb3fd40147fa16d4abcb2085", + "defenderPreviousReceiptHash": "0x0a2f38535771435de01bc45a9a92bc61af294a22cb3fd40147fa16d4abcb2085", "createdAt": 1692806370, "signingKeyId": "battle-signer-2026-07" } diff --git a/verifier/rulesets/0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac.json b/verifier/rulesets/0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac.json new file mode 100644 index 00000000..024b153f --- /dev/null +++ b/verifier/rulesets/0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 2, + "maxRounds": 30, + "maxLevel": 100, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "schemaVersion": 2, + "itemCatalog": [] +} From c811918db10c83db72308ef4e6c75d516dd8cbd2 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 01:09:24 -0400 Subject: [PATCH 19/56] feat(protocol): make equipment change the fight --- contracts/test-vectors/equipment.json | 287 ++++++++++++++++++ protocol/package.json | 1 + protocol/scripts/gen-equipment-vectors.ts | 114 +++++++ protocol/src/combat/equipment.ts | 70 +++++ protocol/src/combat/index.ts | 1 + protocol/src/combat/sim.ts | 17 ++ .../tests/combat/equipmentVectors.test.ts | 118 +++++++ protocol/tests/ruleset/vectors.test.ts | 10 +- 8 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 contracts/test-vectors/equipment.json create mode 100644 protocol/scripts/gen-equipment-vectors.ts create mode 100644 protocol/src/combat/equipment.ts create mode 100644 protocol/tests/combat/equipmentVectors.test.ts diff --git a/contracts/test-vectors/equipment.json b/contracts/test-vectors/equipment.json new file mode 100644 index 00000000..b3955176 --- /dev/null +++ b/contracts/test-vectors/equipment.json @@ -0,0 +1,287 @@ +{ + "description": "Equipment combat vectors (roadmap §4). Generated by protocol/scripts/gen-equipment-vectors.ts from protocol/src/combat. Separate from battle.json, which is unchanged and still gates the ungeared engine: these pin what equipment adds. Both live ports (protocol/src/combat and services/indexer-go/internal/combat) must reproduce every case. A failure means a port drifted; fix the port, never the vector.", + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "cases": [ + { + "name": "ungeared-matches-battle-json", + "note": "Both pets bare, with the same inputs as battle.json's baseline-no-skill. Must reproduce that case exactly: adding the modifier path cannot change an ungeared fight.", + "dna1": "1234567890123456", + "rarity1": 1, + "level1": 20, + "skill1": 99, + "bonus1": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "dna2": "9876543210987654", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "seed": "1", + "expected": { + "firstWins": false, + "rounds": 6, + "winnerHpRemaining": 174, + "startHp1": 356, + "startHp2": 524 + } + }, + { + "name": "attacker-weapon", + "note": "Attacker carries +4 ATK. Pins that a bonus on one side reaches the fight at all.", + "dna1": "1234567890123456", + "rarity1": 1, + "level1": 20, + "skill1": 99, + "bonus1": { + "hp": 0, + "atk": 4, + "def": 0, + "int": 0, + "mdef": 0 + }, + "dna2": "9876543210987654", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "seed": "1", + "expected": { + "firstWins": false, + "rounds": 6, + "winnerHpRemaining": 164, + "startHp1": 356, + "startHp2": 524 + } + }, + { + "name": "defender-armor", + "note": "Defender carries +30 HP and +10 DEF. Pins that the two sides are not interchangeable.", + "dna1": "1234567890123456", + "rarity1": 1, + "level1": 20, + "skill1": 99, + "bonus1": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "dna2": "9876543210987654", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 30, + "atk": 0, + "def": 10, + "int": 0, + "mdef": 0 + }, + "seed": "1", + "expected": { + "firstWins": false, + "rounds": 6, + "winnerHpRemaining": 211, + "startHp1": 356, + "startHp2": 554 + } + }, + { + "name": "both-geared-full-set", + "note": "Both sides wearing all three slots. Pins the summed-bonus path across every attribute.", + "dna1": "1234567890123456", + "rarity1": 1, + "level1": 20, + "skill1": 99, + "bonus1": { + "hp": 12, + "atk": 4, + "def": 4, + "int": 0, + "mdef": 0 + }, + "dna2": "9876543210987654", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 45, + "atk": 0, + "def": 16, + "int": 12, + "mdef": 14 + }, + "seed": "1", + "expected": { + "firstWins": false, + "rounds": 6, + "winnerHpRemaining": 237, + "startHp1": 368, + "startHp2": 569 + } + }, + { + "name": "gear-before-tank", + "note": "Tank (skill 0) with +45 HP of armour. Pins the ordering: gear applies before the skill multiplier, so Tank's +20% multiplies the geared total. Applying it after would give a different HP and a different fight.", + "dna1": "1234567890123456", + "rarity1": 1, + "level1": 20, + "skill1": 0, + "bonus1": { + "hp": 45, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "dna2": "9876543210987654", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "seed": "1", + "expected": { + "firstWins": false, + "rounds": 8, + "winnerHpRemaining": 43, + "startHp1": 481, + "startHp2": 524 + } + }, + { + "name": "gear-before-shell", + "note": "Shell (skill 2) with +16 DEF. The DEF equivalent of the case above, and Shell also drives initiative, so a reorder shows up in the strike order too.", + "dna1": "1234567890123456", + "rarity1": 1, + "level1": 20, + "skill1": 2, + "bonus1": { + "hp": 0, + "atk": 0, + "def": 16, + "int": 0, + "mdef": 0 + }, + "dna2": "9876543210987654", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "seed": "1", + "expected": { + "firstWins": false, + "rounds": 6, + "winnerHpRemaining": 174, + "startHp1": 356, + "startHp2": 524 + } + }, + { + "name": "int-bonus-flips-initiative", + "note": "Attacker gains +200 INT against an otherwise identical pet. INT decides who strikes first, so this pins that gear can change initiative and not merely damage.", + "dna1": "1234567890123456", + "rarity1": 1, + "level1": 20, + "skill1": 99, + "bonus1": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 200, + "mdef": 0 + }, + "dna2": "1234567890123456", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "seed": "7", + "expected": { + "firstWins": true, + "rounds": 2, + "winnerHpRemaining": 331, + "startHp1": 356, + "startHp2": 356 + } + }, + { + "name": "clamped-at-u16", + "note": "An absurd +65535 HP bonus. Pins the clamp: the total saturates at 65535 rather than wrapping, because a wrapping addition would turn a well-geared pet into a nearly dead one at 65536.", + "dna1": "1234567890123456", + "rarity1": 5, + "level1": 100, + "skill1": 99, + "bonus1": { + "hp": 65535, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "dna2": "9876543210987654", + "rarity2": 1, + "level2": 20, + "skill2": 99, + "bonus2": { + "hp": 0, + "atk": 0, + "def": 0, + "int": 0, + "mdef": 0 + }, + "seed": "3", + "expected": { + "firstWins": true, + "rounds": 3, + "winnerHpRemaining": 65459, + "startHp1": 65535, + "startHp2": 524 + } + } + ] +} diff --git a/protocol/package.json b/protocol/package.json index 35972f3d..d7333c2f 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -14,6 +14,7 @@ "lint:fix": "pnpm exec eslint . --fix", "typecheck": "pnpm exec tsc --noEmit", "vectors": "pnpm exec tsx scripts/gen-vectors.ts", + "vectors:equipment": "pnpm exec tsx scripts/gen-equipment-vectors.ts", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" diff --git a/protocol/scripts/gen-equipment-vectors.ts b/protocol/scripts/gen-equipment-vectors.ts new file mode 100644 index 00000000..7b277f91 --- /dev/null +++ b/protocol/scripts/gen-equipment-vectors.ts @@ -0,0 +1,114 @@ +/** + * Writes contracts/test-vectors/equipment.json (roadmap §4). + * + * Run with `pnpm --filter @cryptopets/protocol vectors:equipment`. + * + * **Regenerating to make a failing test pass is forbidden** (`AGENTS.md`), the same rule + * gen-vectors.ts carries. These cases lock what equipment does to a fight, and both live + * combat ports are held to them. A failure means a port drifted; fix the port. + * + * Deliberately a separate file from battle.json rather than more cases inside it. + * battle.json is unchanged and still gates the ungeared engine, and the first case here + * reproduces one of its rows exactly, which is what proves the modifier path costs an + * ungeared fight nothing. + */ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { type AttrBonus, DEFAULT_SKILL_CONFIG, NO_BONUS, simulate } from '../src/combat'; + +/** Cases chosen to pin a property each, not to pad a count. */ +const CASES: { + name: string; + note: string; + dna1: string; rarity1: number; level1: number; skill1: number; bonus1: AttrBonus; + dna2: string; rarity2: number; level2: number; skill2: number; bonus2: AttrBonus; + seed: string; +}[] = [ + { + name: 'ungeared-matches-battle-json', + note: 'Both pets bare, with the same inputs as battle.json\'s baseline-no-skill. Must reproduce that case exactly: adding the modifier path cannot change an ungeared fight.', + dna1: '1234567890123456', rarity1: 1, level1: 20, skill1: 99, bonus1: NO_BONUS, + dna2: '9876543210987654', rarity2: 1, level2: 20, skill2: 99, bonus2: NO_BONUS, + seed: '1', + }, + { + name: 'attacker-weapon', + note: 'Attacker carries +4 ATK. Pins that a bonus on one side reaches the fight at all.', + dna1: '1234567890123456', rarity1: 1, level1: 20, skill1: 99, bonus1: { ...NO_BONUS, atk: 4 }, + dna2: '9876543210987654', rarity2: 1, level2: 20, skill2: 99, bonus2: NO_BONUS, + seed: '1', + }, + { + name: 'defender-armor', + note: 'Defender carries +30 HP and +10 DEF. Pins that the two sides are not interchangeable.', + dna1: '1234567890123456', rarity1: 1, level1: 20, skill1: 99, bonus1: NO_BONUS, + dna2: '9876543210987654', rarity2: 1, level2: 20, skill2: 99, bonus2: { ...NO_BONUS, hp: 30, def: 10 }, + seed: '1', + }, + { + name: 'both-geared-full-set', + note: 'Both sides wearing all three slots. Pins the summed-bonus path across every attribute.', + dna1: '1234567890123456', rarity1: 1, level1: 20, skill1: 99, + bonus1: { hp: 12, atk: 4, def: 4, int: 0, mdef: 0 }, + dna2: '9876543210987654', rarity2: 1, level2: 20, skill2: 99, + bonus2: { hp: 45, atk: 0, def: 16, int: 12, mdef: 14 }, + seed: '1', + }, + { + name: 'gear-before-tank', + note: 'Tank (skill 0) with +45 HP of armour. Pins the ordering: gear applies before the skill multiplier, so Tank\'s +20% multiplies the geared total. Applying it after would give a different HP and a different fight.', + dna1: '1234567890123456', rarity1: 1, level1: 20, skill1: 0, bonus1: { ...NO_BONUS, hp: 45 }, + dna2: '9876543210987654', rarity2: 1, level2: 20, skill2: 99, bonus2: NO_BONUS, + seed: '1', + }, + { + name: 'gear-before-shell', + note: 'Shell (skill 2) with +16 DEF. The DEF equivalent of the case above, and Shell also drives initiative, so a reorder shows up in the strike order too.', + dna1: '1234567890123456', rarity1: 1, level1: 20, skill1: 2, bonus1: { ...NO_BONUS, def: 16 }, + dna2: '9876543210987654', rarity2: 1, level2: 20, skill2: 99, bonus2: NO_BONUS, + seed: '1', + }, + { + name: 'int-bonus-flips-initiative', + note: 'Attacker gains +200 INT against an otherwise identical pet. INT decides who strikes first, so this pins that gear can change initiative and not merely damage.', + dna1: '1234567890123456', rarity1: 1, level1: 20, skill1: 99, bonus1: { ...NO_BONUS, int: 200 }, + dna2: '1234567890123456', rarity2: 1, level2: 20, skill2: 99, bonus2: NO_BONUS, + seed: '7', + }, + { + name: 'clamped-at-u16', + note: 'An absurd +65535 HP bonus. Pins the clamp: the total saturates at 65535 rather than wrapping, because a wrapping addition would turn a well-geared pet into a nearly dead one at 65536.', + dna1: '1234567890123456', rarity1: 5, level1: 100, skill1: 99, bonus1: { ...NO_BONUS, hp: 65535 }, + dna2: '9876543210987654', rarity2: 1, level2: 20, skill2: 99, bonus2: NO_BONUS, + seed: '3', + }, +]; + +const doc = { + description: + 'Equipment combat vectors (roadmap §4). Generated by protocol/scripts/gen-equipment-vectors.ts from protocol/src/combat. Separate from battle.json, which is unchanged and still gates the ungeared engine: these pin what equipment adds. Both live ports (protocol/src/combat and services/indexer-go/internal/combat) must reproduce every case. A failure means a port drifted; fix the port, never the vector.', + skillConfig: DEFAULT_SKILL_CONFIG, + cases: CASES.map((c) => { + const outcome = simulate( + BigInt(c.dna1), c.rarity1, c.level1, c.skill1, + BigInt(c.dna2), c.rarity2, c.level2, c.skill2, + BigInt(c.seed), DEFAULT_SKILL_CONFIG, c.bonus1, c.bonus2, + ); + return { + ...c, + expected: { + firstWins: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + startHp1: Number(outcome.startHp1), + startHp2: Number(outcome.startHp2), + }, + }; + }), +}; + +const out = join(process.cwd(), '../contracts/test-vectors/equipment.json'); +writeFileSync(out, `${JSON.stringify(doc, null, 2)}\n`); +console.log('wrote', out); +for (const c of doc.cases) console.log(' ', c.name, JSON.stringify(c.expected)); diff --git a/protocol/src/combat/equipment.ts b/protocol/src/combat/equipment.ts new file mode 100644 index 00000000..4ba21cbb --- /dev/null +++ b/protocol/src/combat/equipment.ts @@ -0,0 +1,70 @@ +import { type Attrs } from './dna'; + +/** + * Equipment's effect on a pet's attributes (roadmap §4). + * + * Lives in `combat/` and takes no dependency on `snapshot/`, so the engine stays a + * function of plain numbers. The caller resolves a snapshot's equipment list into one of + * these and hands it over; the engine never learns what an item is. + * + * Flat and additive, which is what §4 recommends and what keeps the modifier space small + * enough for two independent ports to stay in step. A multiplicative or conditional + * system (set bonuses, Dota-style) multiplies the vector matrix combinatorially and is a + * v2 of the equipment model, not a field added here. + */ +export interface AttrBonus { + hp: number; + atk: number; + def: number; + int: number; + mdef: number; +} + +/** An ungeared pet. The default everywhere, so an unequipped fight is unchanged. */ +export const NO_BONUS: AttrBonus = { hp: 0, atk: 0, def: 0, int: 0, mdef: 0 }; + +const U16_MAX = 65535n; + +/** + * Adds a bonus to extracted attributes, in place. + * + * **Clamped, not truncated**, and this is the one place in the engine where those differ + * on purpose. Everything else here mirrors Solidity's `uint16` cast, which wraps; a + * wrapping *addition* would turn a well-geared pet into a nearly dead one at 65536, which + * is the opposite of what the item says it does. Base attributes cannot reach that on + * their own (a level-100 legendary lands in the low hundreds), so the clamp is a + * guardrail rather than a live code path — but it has to be a clamp, and the Go port has + * to clamp identically. + * + * `element` is untouched. No item changes a pet's element, and one that did would be + * changing which matchups it wins rather than how hard it hits. + */ +export function applyBonus(attrs: Attrs, bonus: AttrBonus): void { + attrs.hp = clampU16(attrs.hp + BigInt(bonus.hp)); + attrs.atk = clampU16(attrs.atk + BigInt(bonus.atk)); + attrs.def = clampU16(attrs.def + BigInt(bonus.def)); + attrs.int = clampU16(attrs.int + BigInt(bonus.int)); + attrs.mdef = clampU16(attrs.mdef + BigInt(bonus.mdef)); +} + +/** + * Totals several equipped items into one bonus. + * + * Order-independent by construction: addition commutes, so the caller does not have to + * sort, unlike the snapshot encoding where order is part of the digest. + */ +export function sumBonuses(items: readonly AttrBonus[]): AttrBonus { + const total: AttrBonus = { ...NO_BONUS }; + for (const item of items) { + total.hp += item.hp; + total.atk += item.atk; + total.def += item.def; + total.int += item.int; + total.mdef += item.mdef; + } + return total; +} + +function clampU16(value: bigint): bigint { + return value > U16_MAX ? U16_MAX : value; +} diff --git a/protocol/src/combat/index.ts b/protocol/src/combat/index.ts index e2ac28d7..f7e45884 100644 --- a/protocol/src/combat/index.ts +++ b/protocol/src/combat/index.ts @@ -1,4 +1,5 @@ export type { Attrs } from './dna'; +export { applyBonus, type AttrBonus, NO_BONUS, sumBonuses } from './equipment'; export { digitPair, elementMod, extract, toUint16 } from './dna'; export { roundSeed, strikeRoll } from './rng'; export { diff --git a/protocol/src/combat/sim.ts b/protocol/src/combat/sim.ts index daebd7a6..93696a82 100644 --- a/protocol/src/combat/sim.ts +++ b/protocol/src/combat/sim.ts @@ -1,4 +1,5 @@ import { addHeal, strike } from './strike'; +import { applyBonus, type AttrBonus, NO_BONUS } from './equipment'; import { elementMod, extract, toUint16 } from './dna'; import { roundSeed } from './rng'; import { DEFAULT_SKILL_CONFIG, SKILL_REBIRTH, SKILL_SAGE, SKILL_SHELL, SKILL_SWIFT, SKILL_TANK, type SkillConfig } from './skills'; @@ -70,10 +71,26 @@ export function simulate( skill2: number, seed: bigint, sc: SkillConfig = DEFAULT_SKILL_CONFIG, + /** Pet 1's equipment total (roadmap §4). Defaults to ungeared. */ + bonus1: AttrBonus = NO_BONUS, + /** Pet 2's equipment total. */ + bonus2: AttrBonus = NO_BONUS, ): SimOutcome { const a = extract(dna1, rarity1, level1); const b = extract(dna2, rarity2, level2); + // Equipment lands between extraction and the skill modifiers, and the order is a + // real decision. Applying it first means Tank's +20% HP multiplies the geared total + // rather than the bare one, so armour and the archetype compound the way a player + // expects. It also keeps one clamp site: gear is the only additive input here, and + // everything after it is a percentage of whatever it produced. + // + // The Go verifier applies it at the identical point. These two ports were written to + // disagree if either drifts (§F), which is worth nothing if a reordering here goes + // unmatched there. + applyBonus(a, bonus1); + applyBonus(b, bonus2); + // Pre-battle skill modifiers (Tank, Shell, Sage) — mutate the extracted // attrs in place, exactly like the Go/Solidity/Rust ports do. if (skill1 === SKILL_TANK) a.hp = toUint16((a.hp * BigInt(sc.tankHpMult)) / 100n); diff --git a/protocol/tests/combat/equipmentVectors.test.ts b/protocol/tests/combat/equipmentVectors.test.ts new file mode 100644 index 00000000..91ff4f3f --- /dev/null +++ b/protocol/tests/combat/equipmentVectors.test.ts @@ -0,0 +1,118 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { type AttrBonus, NO_BONUS, simulate, sumBonuses } from '../../src/combat'; +import type { SkillConfig } from '../../src/combat/skills'; + +/** + * Consumes contracts/test-vectors/equipment.json (roadmap §4). A failure means this port + * drifted from the rules geared battles were settled under, and the fix is the code, never + * the vector (`AGENTS.md`). + * + * `services/indexer-go/internal/combat` is held to the same file. The two ports were + * written to disagree if either drifts, which is the whole value of §F's circuit breaker, + * and a vector file only one of them reads would quietly disarm it. + */ + +interface EquipmentCase { + name: string; + note: string; + dna1: string; rarity1: number; level1: number; skill1: number; bonus1: AttrBonus; + dna2: string; rarity2: number; level2: number; skill2: number; bonus2: AttrBonus; + seed: string; + expected: { + firstWins: boolean; + rounds: number; + winnerHpRemaining: number; + startHp1: number; + startHp2: number; + }; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const load = (name: string): T => + JSON.parse(readFileSync(join(here, '../../../contracts/test-vectors/', name), 'utf8')) as T; + +const vectors = load<{ skillConfig: SkillConfig; cases: EquipmentCase[] }>('equipment.json'); +const battleVectors = load<{ cases: { name: string; expected: EquipmentCase['expected'] }[] }>('battle.json'); + +function run(c: EquipmentCase) { + return simulate( + BigInt(c.dna1), c.rarity1, c.level1, c.skill1, + BigInt(c.dna2), c.rarity2, c.level2, c.skill2, + BigInt(c.seed), vectors.skillConfig, c.bonus1, c.bonus2, + ); +} + +describe('equipment golden vectors', () => { + for (const c of vectors.cases) { + it(`reproduces "${c.name}"`, () => { + const outcome = run(c); + expect({ + firstWins: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + startHp1: Number(outcome.startHp1), + startHp2: Number(outcome.startHp2), + }).toEqual(c.expected); + }); + } +}); + +describe('properties the vectors exist to pin', () => { + const byName = new Map(vectors.cases.map((c) => [c.name, c])); + + // The compatibility claim, checked against the other file rather than asserted: adding + // the modifier path must cost an ungeared fight nothing, and battle.json is what says + // what an ungeared fight produces. + it('leaves an ungeared fight identical to the one battle.json already records', () => { + const geared = byName.get('ungeared-matches-battle-json')!; + const baseline = battleVectors.cases.find((c) => c.name === 'baseline-no-skill')!; + + expect(geared.expected.firstWins).toBe(baseline.expected.firstWins); + expect(geared.expected.rounds).toBe(baseline.expected.rounds); + expect(geared.expected.winnerHpRemaining).toBe(baseline.expected.winnerHpRemaining); + }); + + // Gear applies before the skill multiplier, so Tank's +20% multiplies the geared + // total. Computed here rather than restated, so the assertion fails if the order moves. + it('applies gear before the skill multiplier, not after', () => { + const c = byName.get('gear-before-tank')!; + // The same pet without Tank and without gear, so this is its extracted HP exactly. + // Taken from the ungeared case rather than divided back out of the Tank case: the + // multiplier floors, so undoing it loses a point and the two orderings differ by + // one, which is precisely the margin under test. + const extracted = byName.get('ungeared-matches-battle-json')!.expected.startHp1; + const tank = vectors.skillConfig.tankHpMult; + + const gearedThenTank = Math.floor(((extracted + c.bonus1.hp) * tank) / 100); + const tankThenGeared = Math.floor((extracted * tank) / 100) + c.bonus1.hp; + + expect(c.expected.startHp1).toBe(gearedThenTank); + expect(c.expected.startHp1).not.toBe(tankThenGeared); + }); + + // Saturating, not wrapping. A wrapping addition would turn a well-geared pet into a + // nearly dead one the moment its HP crossed 65536. + it('clamps a bonus at 16 bits instead of wrapping it', () => { + expect(byName.get('clamped-at-u16')!.expected.startHp1).toBe(65535); + }); + + // Gear reaches initiative, not only damage: INT decides who strikes first. + it('lets a bonus change who strikes first', () => { + const c = byName.get('int-bonus-flips-initiative')!; + expect(c.dna1).toBe(c.dna2); // identical pets, so only the bonus can differ + expect(run({ ...c, bonus1: NO_BONUS }).result.firstWins).not.toBe(c.expected.firstWins); + }); + + it('is unaffected by the order items are summed in', () => { + const parts: AttrBonus[] = [ + { hp: 12, atk: 4, def: 0, int: 0, mdef: 0 }, + { hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, + { hp: 0, atk: 0, def: 0, int: 12, mdef: 8 }, + ]; + expect(sumBonuses(parts)).toEqual(sumBonuses([...parts].reverse())); + }); +}); diff --git a/protocol/tests/ruleset/vectors.test.ts b/protocol/tests/ruleset/vectors.test.ts index fb29df23..98772bcf 100644 --- a/protocol/tests/ruleset/vectors.test.ts +++ b/protocol/tests/ruleset/vectors.test.ts @@ -21,11 +21,13 @@ interface RulesetCase { /** Widens the stored item types back to bigints, as `parseRulesetBundle` does. */ function toRuleset(stored: RulesetCase['ruleset']): Ruleset { + const { itemCatalog, ...rest } = stored; + if (!itemCatalog) { + return rest; + } return { - ...stored, - ...(stored.itemCatalog && { - itemCatalog: stored.itemCatalog.map((item) => ({ ...item, itemType: BigInt(item.itemType) })), - }), + ...rest, + itemCatalog: itemCatalog.map((item) => ({ ...item, itemType: BigInt(item.itemType) })), }; } From b89f6bc291b63d558479f9f615a416f2d6ad74af Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 01:15:45 -0400 Subject: [PATCH 20/56] feat(indexer-go): port equipment modifiers to the Go combat verifier --- .../indexer-go/internal/combat/equipment.go | 71 ++++++ .../internal/combat/equipment_golden_test.go | 232 ++++++++++++++++++ .../indexer-go/internal/combat/progression.go | 3 + services/indexer-go/internal/combat/sim.go | 26 ++ services/indexer-go/internal/combat/simlog.go | 18 ++ services/indexer-go/internal/combat/verify.go | 8 +- 6 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 services/indexer-go/internal/combat/equipment.go create mode 100644 services/indexer-go/internal/combat/equipment_golden_test.go diff --git a/services/indexer-go/internal/combat/equipment.go b/services/indexer-go/internal/combat/equipment.go new file mode 100644 index 00000000..9433d25a --- /dev/null +++ b/services/indexer-go/internal/combat/equipment.go @@ -0,0 +1,71 @@ +package combat + +// Equipment modifiers (roadmap §4). +// +// The Go half of a change that must land in both live ports together. AGENTS.md makes +// this a MUST: §F's circuit breaker compares this port's recomputation against the +// TypeScript engine's, and it is only worth running because the two were written +// independently enough to disagree when one drifts. A modifier applied here at a +// different point, or clamped differently, would make them disagree on every geared +// battle and take the breaker with it. +// +// Mirrors protocol/src/combat/equipment.ts. Both are held to +// contracts/test-vectors/equipment.json. + +// AttrBonus is equipment's flat, additive effect on a pet's attributes. +// +// Element is deliberately absent: no item changes a pet's element, and one that did +// would change which matchups it wins rather than how hard it hits. +type AttrBonus struct { + HP uint16 + ATK uint16 + DEF uint16 + INT uint16 + MDEF uint16 +} + +// NoBonus is an ungeared pet. The zero value, so a PetInputs that never mentions +// equipment behaves exactly as it did before this existed. +var NoBonus = AttrBonus{} + +// applyBonus adds a bonus to extracted attributes, in place. +// +// Saturating, not wrapping, and this is the one place in this package where those differ +// on purpose. Everything else mirrors Solidity's uint16 cast, which wraps; a wrapping +// addition would turn a well-geared pet into a nearly dead one the moment its HP crossed +// 65536, the opposite of what the item says it does. The TypeScript port clamps +// identically, and equipment.json's clamped-at-u16 case pins both. +func applyBonus(attrs *Attrs, bonus AttrBonus) { + attrs.HP = addClamped(attrs.HP, bonus.HP) + attrs.ATK = addClamped(attrs.ATK, bonus.ATK) + attrs.DEF = addClamped(attrs.DEF, bonus.DEF) + attrs.INT = addClamped(attrs.INT, bonus.INT) + attrs.MDEF = addClamped(attrs.MDEF, bonus.MDEF) +} + +// SumBonuses totals several equipped items into one bonus. +// +// Order-independent by construction, unlike the snapshot encoding where order is part of +// the digest: addition commutes, so a caller does not have to sort. +func SumBonuses(items []AttrBonus) AttrBonus { + var total AttrBonus + for _, item := range items { + total.HP = addClamped(total.HP, item.HP) + total.ATK = addClamped(total.ATK, item.ATK) + total.DEF = addClamped(total.DEF, item.DEF) + total.INT = addClamped(total.INT, item.INT) + total.MDEF = addClamped(total.MDEF, item.MDEF) + } + return total +} + +// addClamped adds two uint16 values, saturating at the maximum rather than wrapping. +// Widened to uint32 first, because the wrap this exists to prevent would otherwise happen +// in the addition itself. +func addClamped(a, b uint16) uint16 { + sum := uint32(a) + uint32(b) + if sum > 0xffff { + return 0xffff + } + return uint16(sum) +} diff --git a/services/indexer-go/internal/combat/equipment_golden_test.go b/services/indexer-go/internal/combat/equipment_golden_test.go new file mode 100644 index 00000000..b6aeb54f --- /dev/null +++ b/services/indexer-go/internal/combat/equipment_golden_test.go @@ -0,0 +1,232 @@ +package combat + +import ( + "testing" +) + +// Equipment golden vectors (roadmap §4), shared with @cryptopets/protocol's +// tests/combat/equipmentVectors.test.ts. Both live ports read this one file. +// +// That sharing is the point rather than a convenience. §F's circuit breaker compares this +// port's recomputation against the TypeScript engine's before anything is signed, and it +// only has value because the two were written independently enough to disagree when one +// drifts. A vector file only one of them consumed would quietly disarm it. If a case here +// fails, this port has drifted; fix the Go, never the vector. +// +// battle.json is untouched and still gates the ungeared engine. These cases pin what +// equipment adds on top, and the first of them reproduces a battle.json row exactly. +const equipmentVectorsPath = "../../../../contracts/test-vectors/equipment.json" + +type equipmentVectors struct { + SkillConfig struct { + TankHpMult uint16 `json:"tankHpMult"` + ShellDefMult uint16 `json:"shellDefMult"` + SwiftCritBonus uint16 `json:"swiftCritBonus"` + CunningCritCap uint16 `json:"cunningCritCap"` + FuryDmgMult uint16 `json:"furyDmgMult"` + FuryHpThreshold uint16 `json:"furyHpThreshold"` + SageMdefMult uint16 `json:"sageMdefMult"` + BloodlustBps uint16 `json:"bloodlustBps"` + } `json:"skillConfig"` + Cases []equipmentCase `json:"cases"` +} + +type vectorBonus struct { + HP uint16 `json:"hp"` + ATK uint16 `json:"atk"` + DEF uint16 `json:"def"` + INT uint16 `json:"int"` + MDEF uint16 `json:"mdef"` +} + +func (b vectorBonus) toAttrBonus() AttrBonus { + return AttrBonus{HP: b.HP, ATK: b.ATK, DEF: b.DEF, INT: b.INT, MDEF: b.MDEF} +} + +type equipmentCase struct { + Name string `json:"name"` + DNA1 string `json:"dna1"` + Rarity1 uint8 `json:"rarity1"` + Level1 uint16 `json:"level1"` + Skill1 uint8 `json:"skill1"` + Bonus1 vectorBonus `json:"bonus1"` + DNA2 string `json:"dna2"` + Rarity2 uint8 `json:"rarity2"` + Level2 uint16 `json:"level2"` + Skill2 uint8 `json:"skill2"` + Bonus2 vectorBonus `json:"bonus2"` + Seed string `json:"seed"` + + Expected struct { + FirstWins bool `json:"firstWins"` + Rounds uint8 `json:"rounds"` + WinnerHpRemaining uint16 `json:"winnerHpRemaining"` + StartHp1 uint32 `json:"startHp1"` + StartHp2 uint32 `json:"startHp2"` + } `json:"expected"` +} + +func loadEquipmentVectors(t *testing.T) equipmentVectors { + t.Helper() + var v equipmentVectors + loadJSON(t, equipmentVectorsPath, &v) + if len(v.Cases) == 0 { + t.Fatal("no equipment vectors loaded") + } + return v +} + +func (v equipmentVectors) skillConfig() SkillConfig { + return SkillConfig{ + TankHPMult: v.SkillConfig.TankHpMult, + ShellDefMult: v.SkillConfig.ShellDefMult, + SwiftCritBonus: v.SkillConfig.SwiftCritBonus, + CunningCritCap: v.SkillConfig.CunningCritCap, + FuryDmgMult: v.SkillConfig.FuryDmgMult, + FuryHPThreshold: v.SkillConfig.FuryHpThreshold, + SageMdefMult: v.SkillConfig.SageMdefMult, + BloodlustBps: v.SkillConfig.BloodlustBps, + } +} + +func (v equipmentVectors) caseNamed(t *testing.T, name string) equipmentCase { + t.Helper() + for _, c := range v.Cases { + if c.Name == name { + return c + } + } + t.Fatalf("equipment vector case missing: %s", name) + return equipmentCase{} +} + +func runEquipmentCase(t *testing.T, sc SkillConfig, c equipmentCase, b1, b2 AttrBonus) LoggedResult { + t.Helper() + return SimulateWithLogAndBonus( + parseDNA(t, c.DNA1), c.Rarity1, c.Level1, c.Skill1, + parseDNA(t, c.DNA2), c.Rarity2, c.Level2, c.Skill2, + seedBytes(t, c.Seed), sc, b1, b2, + ) +} + +func TestSimulateMatchesEquipmentGoldenVectors(t *testing.T) { + v := loadEquipmentVectors(t) + sc := v.skillConfig() + + for _, c := range v.Cases { + t.Run(c.Name, func(t *testing.T) { + got := runEquipmentCase(t, sc, c, c.Bonus1.toAttrBonus(), c.Bonus2.toAttrBonus()) + + if got.Result.FirstWins != c.Expected.FirstWins { + t.Errorf("firstWins = %v, want %v", got.Result.FirstWins, c.Expected.FirstWins) + } + if got.Result.Rounds != c.Expected.Rounds { + t.Errorf("rounds = %d, want %d", got.Result.Rounds, c.Expected.Rounds) + } + if got.Result.WinnerHpRemaining != c.Expected.WinnerHpRemaining { + t.Errorf("winnerHpRemaining = %d, want %d", got.Result.WinnerHpRemaining, c.Expected.WinnerHpRemaining) + } + // The start HPs are recorded too, so an ordering mistake between gear and the + // skill multipliers fails here rather than only showing up as a different + // fight several rounds later. + if got.StartHp1 != c.Expected.StartHp1 { + t.Errorf("startHp1 = %d, want %d", got.StartHp1, c.Expected.StartHp1) + } + if got.StartHp2 != c.Expected.StartHp2 { + t.Errorf("startHp2 = %d, want %d", got.StartHp2, c.Expected.StartHp2) + } + }) + } +} + +// The compatibility claim, checked against the other file rather than asserted: adding the +// modifier path must cost an ungeared fight nothing, and battle.json is what says what an +// ungeared fight produces. +func TestUngearedEquipmentCaseMatchesBattleVectors(t *testing.T) { + v := loadEquipmentVectors(t) + geared := v.caseNamed(t, "ungeared-matches-battle-json") + + var bv battleVectors + loadJSON(t, battleVectorsPath, &bv) + + for _, c := range bv.Cases { + if c.Name != "baseline-no-skill" { + continue + } + if geared.Expected.FirstWins != c.Expected.FirstWins || + geared.Expected.Rounds != c.Expected.Rounds || + geared.Expected.WinnerHpRemaining != c.Expected.WinnerHpRemaining { + t.Fatalf("ungeared case %+v disagrees with battle.json baseline %+v", + geared.Expected, c.Expected) + } + return + } + t.Fatal("battle.json case missing: baseline-no-skill") +} + +// Gear applies before the skill multiplier, so Tank's bonus multiplies the geared total. +// Computed rather than restated, so the assertion fails if the order moves. +func TestGearAppliesBeforeSkillMultiplier(t *testing.T) { + v := loadEquipmentVectors(t) + // The same pet with neither Tank nor gear, so this is its extracted HP exactly. Taken + // from the ungeared case rather than divided back out of the Tank case: the multiplier + // floors, so undoing it loses a point and the two orderings differ by one, which is + // precisely the margin under test. + extracted := uint32(v.caseNamed(t, "ungeared-matches-battle-json").Expected.StartHp1) + c := v.caseNamed(t, "gear-before-tank") + tank := uint32(v.SkillConfig.TankHpMult) + + gearedThenTank := (extracted + uint32(c.Bonus1.HP)) * tank / 100 + tankThenGeared := extracted*tank/100 + uint32(c.Bonus1.HP) + + if c.Expected.StartHp1 != gearedThenTank { + t.Errorf("startHp1 = %d, want %d (gear then Tank)", c.Expected.StartHp1, gearedThenTank) + } + if c.Expected.StartHp1 == tankThenGeared { + t.Errorf("startHp1 = %d matches Tank-then-gear; the ordering is not pinned", c.Expected.StartHp1) + } +} + +// Saturating, not wrapping: a wrapping addition would turn a well-geared pet into a nearly +// dead one the moment its HP crossed 65536. +func TestBonusClampsAtU16(t *testing.T) { + v := loadEquipmentVectors(t) + if got := v.caseNamed(t, "clamped-at-u16").Expected.StartHp1; got != 0xffff { + t.Errorf("startHp1 = %d, want 65535", got) + } + + if got := addClamped(0xfff0, 0xff); got != 0xffff { + t.Errorf("addClamped overflow = %d, want 65535", got) + } + if got := addClamped(10, 20); got != 30 { + t.Errorf("addClamped = %d, want 30", got) + } +} + +// Gear reaches initiative, not only damage: INT decides who strikes first. +func TestBonusCanChangeInitiative(t *testing.T) { + v := loadEquipmentVectors(t) + sc := v.skillConfig() + c := v.caseNamed(t, "int-bonus-flips-initiative") + + if c.DNA1 != c.DNA2 { + t.Fatalf("case is only meaningful with identical pets, got %s and %s", c.DNA1, c.DNA2) + } + bare := runEquipmentCase(t, sc, c, NoBonus, NoBonus) + if bare.Result.FirstWins == c.Expected.FirstWins { + t.Error("removing the INT bonus did not change the outcome; the case pins nothing") + } +} + +func TestSumBonusesIsOrderIndependent(t *testing.T) { + parts := []AttrBonus{ + {HP: 12, ATK: 4}, + {HP: 30, DEF: 10}, + {INT: 12, MDEF: 8}, + } + reversed := []AttrBonus{parts[2], parts[1], parts[0]} + + if SumBonuses(parts) != SumBonuses(reversed) { + t.Errorf("sum depends on order: %+v vs %+v", SumBonuses(parts), SumBonuses(reversed)) + } +} diff --git a/services/indexer-go/internal/combat/progression.go b/services/indexer-go/internal/combat/progression.go index 533f3c21..116245c9 100644 --- a/services/indexer-go/internal/combat/progression.go +++ b/services/indexer-go/internal/combat/progression.go @@ -91,4 +91,7 @@ type PetInputs struct { XP uint32 LastOpponentID uint64 Streak uint32 + // Bonus is the pet's equipment total, resolved from the frozen snapshot (roadmap §4). + // Zero value means ungeared, so a caller that predates equipment is unaffected. + Bonus AttrBonus } diff --git a/services/indexer-go/internal/combat/sim.go b/services/indexer-go/internal/combat/sim.go index 29bd0b83..0c2ecc59 100644 --- a/services/indexer-go/internal/combat/sim.go +++ b/services/indexer-go/internal/combat/sim.go @@ -21,10 +21,36 @@ func Simulate( dna1 uint64, rarity1 uint8, level1 uint16, skill1 uint8, dna2 uint64, rarity2 uint8, level2 uint16, skill2 uint8, seed [32]byte, sc SkillConfig, +) Result { + return SimulateWithBonus( + dna1, rarity1, level1, skill1, + dna2, rarity2, level2, skill2, + seed, sc, NoBonus, NoBonus, + ) +} + +// SimulateWithBonus is Simulate with equipment (roadmap §4). +// +// A separate entry point rather than two more parameters on Simulate, because Go has no +// default arguments and the ungeared signature is what contracts/test-vectors/battle.json +// is checked through. Leaving it untouched is what proves the modifier path costs an +// ungeared fight nothing. +func SimulateWithBonus( + dna1 uint64, rarity1 uint8, level1 uint16, skill1 uint8, + dna2 uint64, rarity2 uint8, level2 uint16, skill2 uint8, + seed [32]byte, sc SkillConfig, bonus1, bonus2 AttrBonus, ) Result { a := Extract(dna1, rarity1, level1) b := Extract(dna2, rarity2, level2) + // Equipment lands between extraction and the skill modifiers, matching + // protocol/src/combat/sim.ts exactly. Applying it first means Tank's +20% HP + // multiplies the geared total rather than the bare one. The ordering is pinned by + // equipment.json's gear-before-tank case in both ports, so a reorder in one shows up + // as a vector failure rather than as a §F mismatch on live traffic. + applyBonus(&a, bonus1) + applyBonus(&b, bonus2) + // Pre-battle skill modifiers (Tank, Shell, Sage). if skill1 == SkillTank { a.HP = uint16(uint32(a.HP) * uint32(sc.TankHPMult) / 100) diff --git a/services/indexer-go/internal/combat/simlog.go b/services/indexer-go/internal/combat/simlog.go index ca1fb752..dd4f202b 100644 --- a/services/indexer-go/internal/combat/simlog.go +++ b/services/indexer-go/internal/combat/simlog.go @@ -39,10 +39,28 @@ func SimulateWithLog( dna1 uint64, rarity1 uint8, level1 uint16, skill1 uint8, dna2 uint64, rarity2 uint8, level2 uint16, skill2 uint8, seed [32]byte, sc SkillConfig, +) LoggedResult { + return SimulateWithLogAndBonus( + dna1, rarity1, level1, skill1, + dna2, rarity2, level2, skill2, + seed, sc, NoBonus, NoBonus, + ) +} + +// SimulateWithLogAndBonus is SimulateWithLog with equipment (roadmap §4). See +// SimulateWithBonus for why this is a separate entry point. +func SimulateWithLogAndBonus( + dna1 uint64, rarity1 uint8, level1 uint16, skill1 uint8, + dna2 uint64, rarity2 uint8, level2 uint16, skill2 uint8, + seed [32]byte, sc SkillConfig, bonus1, bonus2 AttrBonus, ) LoggedResult { a := Extract(dna1, rarity1, level1) b := Extract(dna2, rarity2, level2) + // Same insertion point as SimulateWithBonus and as the TypeScript engine. + applyBonus(&a, bonus1) + applyBonus(&b, bonus2) + if skill1 == SkillTank { a.HP = uint16(uint32(a.HP) * uint32(sc.TankHPMult) / 100) } diff --git a/services/indexer-go/internal/combat/verify.go b/services/indexer-go/internal/combat/verify.go index bcbbeed9..b0929ec6 100644 --- a/services/indexer-go/internal/combat/verify.go +++ b/services/indexer-go/internal/combat/verify.go @@ -46,10 +46,14 @@ type VerifyResult struct { // Verify runs the fight and the progression composition against a frozen // snapshot and a verified seed. func Verify(req VerifyRequest) VerifyResult { - logged := SimulateWithLog( + // The bonuses come from the request, which the caller resolved from the frozen + // snapshot. This port never reads an item catalog: what it verifies is that the fight + // follows from the numbers the receipt will publish, and those numbers are the + // resolved modifiers, not the items that granted them. + logged := SimulateWithLogAndBonus( req.Attacker.DNA, req.Attacker.Rarity, req.Attacker.Level, req.Attacker.Skill, req.Defender.DNA, req.Defender.Rarity, req.Defender.Level, req.Defender.Skill, - req.Seed, req.SkillConfig, + req.Seed, req.SkillConfig, req.Attacker.Bonus, req.Defender.Bonus, ) progression := ComputeProgression(req.Attacker, req.Defender, logged.Result.FirstWins, req.MaxLevel) From dd0ca76ce087f823991bc2c077c029108bc3ad28 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 01:31:10 -0400 Subject: [PATCH 21/56] feat(battle): fight with the equipment frozen in the snapshot --- .../features/battle/ledger/accept.service.ts | 26 ++-- .../battle/ledger/reads.controller.ts | 12 +- .../features/battle/ledger/reads.service.ts | 10 +- .../features/battle/ledger/ruleset.builder.ts | 66 ++++++++++ .../battle/ledger/snapshot.builder.ts | 51 +++++++- .../features/battle/worker/compute.worker.ts | 41 +++++++ .../features/battle/worker/verify.worker.ts | 17 ++- backend/src/grpc/verifyBattle.ts | 13 ++ .../battle/ledger/accept.service.test.ts | 9 ++ .../battle/ledger/config.service.test.ts | 40 +++--- .../battle/ledger/ruleset.builder.test.ts | 116 ++++++++++++++++++ .../battle/ledger/snapshot.builder.test.ts | 73 +++++++++++ proto/cryptopets.proto | 13 ++ protocol/src/ruleset/index.ts | 1 + .../indexer-go/internal/grpcsrv/verify.go | 37 ++++++ services/indexer-go/pb/cryptopets.pb.go | 62 +++++++++- 16 files changed, 552 insertions(+), 35 deletions(-) create mode 100644 backend/src/features/battle/ledger/ruleset.builder.ts create mode 100644 backend/tests/features/battle/ledger/ruleset.builder.test.ts diff --git a/backend/src/features/battle/ledger/accept.service.ts b/backend/src/features/battle/ledger/accept.service.ts index 3340c7de..dd59c323 100644 --- a/backend/src/features/battle/ledger/accept.service.ts +++ b/backend/src/features/battle/ledger/accept.service.ts @@ -10,7 +10,7 @@ import { type Hex, publishRuleset, QUICKNET, - SOURCE_DEFAULT_RULESET, + SNAPSHOT_SCHEMA_VERSION, } from '@cryptopets/protocol'; import type { Prisma } from '@generated/prisma/client'; import { BattleState } from '@generated/prisma/enums'; @@ -25,6 +25,7 @@ import { type ConsentFailure, consumeDailyBudget, findCoveringAuthorization } fr import { servedDeploymentId } from './domain'; import { OUTBOX_TOPICS } from './outbox'; import { buildPetSnapshot } from './snapshot.builder'; +import { servedRuleset } from './ruleset.builder'; import { applyTransition, openBattle } from './transitions'; /** @@ -123,7 +124,9 @@ export async function acceptBattle(request: AcceptBattleRequest): Promise { if (existing) { return; } - const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); + const ruleset = await servedRuleset(); + const { hash, json } = publishRuleset(ruleset); try { await prisma.battleRuleset.create({ data: { rulesetHash: hash, - version: SOURCE_DEFAULT_RULESET.version, - engineId: SOURCE_DEFAULT_RULESET.engineId, - engineVersion: SOURCE_DEFAULT_RULESET.engineVersion, + version: ruleset.version, + engineId: ruleset.engineId, + engineVersion: ruleset.engineVersion, bundle: JSON.parse(json), }, }); diff --git a/backend/src/features/battle/ledger/reads.controller.ts b/backend/src/features/battle/ledger/reads.controller.ts index 1f40dc41..87b96a59 100644 --- a/backend/src/features/battle/ledger/reads.controller.ts +++ b/backend/src/features/battle/ledger/reads.controller.ts @@ -13,8 +13,16 @@ import { } from './reads.service'; /** The deployment, chains, and active ruleset a client needs before it can sign an intent. */ -export function getBattleConfigHandler(_req: Request, res: Response): void { - res.status(200).json(getBattleConfig()); +export async function getBattleConfigHandler(_req: Request, res: Response): Promise { + try { + res.status(200).json(await getBattleConfig()); + } catch (err) { + // Async since the ruleset now joins the item catalog (roadmap §4), so this can + // fail on a database that is down. A 500 is right: a client that signed against a + // guessed ruleset hash would have every battle rejected. + console.error('[battle] failed to read battle config:', err); + res.status(500).json({ error: 'config-unavailable' }); + } } export async function getBattleStateHandler(req: Request, res: Response): Promise { diff --git a/backend/src/features/battle/ledger/reads.service.ts b/backend/src/features/battle/ledger/reads.service.ts index 041bfd74..58bd7c24 100644 --- a/backend/src/features/battle/ledger/reads.service.ts +++ b/backend/src/features/battle/ledger/reads.service.ts @@ -1,4 +1,6 @@ -import { hashRuleset, SOURCE_DEFAULT_RULESET, type Hex } from '@cryptopets/protocol'; +import { hashRuleset, type Hex } from '@cryptopets/protocol'; + +import { servedRuleset } from './ruleset.builder'; import { ethers } from 'ethers'; import { prisma } from '@config/prisma'; @@ -55,8 +57,10 @@ export interface BattleConfig { * is secret, and needing a login to find out which rules are in force would make * a third-party client harder to write than it has any reason to be. */ -export function getBattleConfig(): BattleConfig { - const ruleset = SOURCE_DEFAULT_RULESET; +export async function getBattleConfig(): Promise { + // The same ruleset accept would use, item catalog included, so a client signing + // defence consent binds to the hash its battles will actually name (roadmap §4). + const ruleset = await servedRuleset(); return { enabled: backendBattleModeEnabled(), deploymentId: servedDeploymentId(), diff --git a/backend/src/features/battle/ledger/ruleset.builder.ts b/backend/src/features/battle/ledger/ruleset.builder.ts new file mode 100644 index 00000000..aac12d26 --- /dev/null +++ b/backend/src/features/battle/ledger/ruleset.builder.ts @@ -0,0 +1,66 @@ +import { type ItemModifier, type Ruleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +import { getCatalog } from '@features/inventory'; + +/** + * Builds the ruleset this deployment fights under (roadmap §4). + * + * `SOURCE_DEFAULT_RULESET` is the local-development baseline and ships with an empty item + * catalog, deliberately: `@cryptopets/protocol` is MIT and cannot import the catalog, + * which is content owned by a PolyForm package. So the catalog is joined on here, the same + * way a deployment already reads `skillConfig` from `GameConfig` rather than trusting the + * constant beside it. + * + * Only equipment reaches the ruleset. A potion cannot change a fight, and listing one + * would make adding a collectible move `rulesetHash` — which invalidates every outstanding + * defence authorization, since consent is bound to that hash. Re-prompting every defender + * because a badge was added would train players to click through the one prompt that + * matters. + */ + +/** + * Cached for the process's life. + * + * The catalog changes when someone runs the seeder, which is a deploy-shaped event, and + * this is read on every accept. Re-querying per battle would put a table scan on the hot + * path to answer the same question. A catalog edit therefore needs a restart to take + * effect, which is the right shape for something that invalidates outstanding consent: + * it should be a deliberate rollout, not a row edit that quietly re-prices live battles. + */ +let cached: Ruleset | null = null; + +export async function servedRuleset(): Promise { + if (cached) { + return cached; + } + + const catalog = await getCatalog(); + const itemCatalog: ItemModifier[] = []; + for (const item of catalog) { + if (item.effect?.kind !== 'stat_bonus' || item.slot === null) { + continue; + } + itemCatalog.push({ + itemType: BigInt(item.itemType), + slot: item.slot, + hp: item.effect.hp, + atk: item.effect.atk, + def: item.effect.def, + int: item.effect.int, + mdef: item.effect.mdef, + }); + } + + // Ascending by item type, which the protocol requires: the order is part of the + // ruleset digest, and `assertRuleset` refuses to sort silently so a duplicate item + // type surfaces rather than being tidied away. + itemCatalog.sort((a, b) => (a.itemType < b.itemType ? -1 : a.itemType > b.itemType ? 1 : 0)); + + cached = { ...SOURCE_DEFAULT_RULESET, itemCatalog }; + return cached; +} + +/** Test seam: drops the memoized ruleset so a changed catalog is picked up. */ +export function resetServedRuleset(): void { + cached = null; +} diff --git a/backend/src/features/battle/ledger/snapshot.builder.ts b/backend/src/features/battle/ledger/snapshot.builder.ts index 478004ca..5ae78eee 100644 --- a/backend/src/features/battle/ledger/snapshot.builder.ts +++ b/backend/src/features/battle/ledger/snapshot.builder.ts @@ -1,6 +1,7 @@ -import { chainFamily, type ChainId, type PetSnapshot } from '@cryptopets/protocol'; +import { chainFamily, type ChainId, type EquipEntry, type PetSnapshot } from '@cryptopets/protocol'; import { prisma } from '@config/prisma'; +import { getPetEquipment } from '@features/inventory'; import { servedDeploymentId } from './domain'; @@ -38,6 +39,7 @@ export async function buildPetSnapshot(chainId: ChainId, petId: string): Promise winCount: roster.winCount, lossCount: roster.lossCount, }); + const equipment = await resolveEquipment(family, petId); return { petId: BigInt(petId), @@ -51,9 +53,56 @@ export async function buildPetSnapshot(chainId: ChainId, petId: string): Promise streak: progress.streak, readyAt: Number(progress.readyAt), sourceVersion: roster.lastVersion, + // Omitted rather than empty for an ungeared pet, matching what + // `assertPetSnapshot` normalizes to. It encodes the same either way, and it keeps + // an ungeared snapshot's stored JSON identical to what it was before equipment + // existed, so a diff of stored rows shows only the pets that actually wear + // something. + ...(equipment.length > 0 && { equipment }), }; } +/** + * Freezes what a pet is wearing, with each item's modifier already resolved (roadmap §4). + * + * Resolved here rather than referenced, because the snapshot is the photo: unequipping + * after acceptance must not change a committed fight, exactly as a level-up between + * acceptance and settlement must not. Storing the item id alone would leave the fight + * depending on a row anyone can still edit. + * + * The equip state comes from `pet_equipment`, which only indexer-go writes from the chain, + * so what is frozen is what the chain said at a version the snapshot records. An outsider + * can therefore check the gear as well as the numbers. + * + * An equipped item with no catalog effect contributes nothing and is left out entirely. + * Including it with zeroes would put an entry in the receipt claiming an item was worn and + * did nothing, which reads as a bug rather than as a fact. + */ +async function resolveEquipment(family: string, petId: string): Promise { + const equipped = await getPetEquipment(family, petId); + const entries: EquipEntry[] = []; + + for (const { slot, item } of equipped) { + if (item.effect?.kind !== 'stat_bonus') { + continue; + } + entries.push({ + slot, + itemType: BigInt(item.itemType), + hp: item.effect.hp, + atk: item.effect.atk, + def: item.effect.def, + int: item.effect.int, + mdef: item.effect.mdef, + }); + } + + // Ascending by slot, which the protocol requires: the order is part of the snapshot + // digest, and `assertPetSnapshot` refuses to sort silently so an upstream bug that + // produced two weapons surfaces instead of being tidied away. + return entries.sort((a, b) => a.slot - b.slot); +} + /** * Reads a pet's backend progression, creating the row on first use. * diff --git a/backend/src/features/battle/worker/compute.worker.ts b/backend/src/features/battle/worker/compute.worker.ts index dbc74be5..fab68863 100644 --- a/backend/src/features/battle/worker/compute.worker.ts +++ b/backend/src/features/battle/worker/compute.worker.ts @@ -1,10 +1,13 @@ import { type BattleSnapshot, computeProgression, + type AttrBonus, hashCombatLog, type Hex, loadRulesetBundle, + NO_BONUS, simulate, + sumBonuses, } from '@cryptopets/protocol'; import { BattleState } from '@generated/prisma/enums'; import type { Prisma } from '@generated/prisma/client'; @@ -52,6 +55,9 @@ export async function processComputeMessage(message: ClaimedMessage, nowSeconds: const attacker = deserializePet(snapshot.attacker); const defender = deserializePet(snapshot.defender); + // Equipment totals come from the frozen snapshot, not from the catalog: the fight has + // to use the modifiers that were written down at acceptance, so unequipping since then + // changes nothing (roadmap §4). const outcome = simulate( attacker.dna, attacker.rarity, @@ -63,6 +69,8 @@ export async function processComputeMessage(message: ClaimedMessage, nowSeconds: defender.skill, BigInt(battle.seed), ruleset.skillConfig, + equipmentBonus(attacker.equipment), + equipmentBonus(defender.equipment), ); const progression = computeProgression( @@ -92,6 +100,33 @@ export async function processComputeMessage(message: ClaimedMessage, nowSeconds: await completeOutbox(message.id, new Date(nowSeconds * 1000)); } +/** Totals a snapshot's frozen equipment into the bonus the engine consumes. */ +export function equipmentBonus(equipment: SnapshotEquipment | undefined): AttrBonus { + if (!equipment || equipment.length === 0) { + return NO_BONUS; + } + return sumBonuses( + equipment.map((entry) => ({ + hp: Number(entry.hp), + atk: Number(entry.atk), + def: Number(entry.def), + int: Number(entry.int), + mdef: Number(entry.mdef), + })), + ); +} + +/** As stored: JSON, so the item type arrives as a decimal string. */ +export type SnapshotEquipment = { + slot: number; + itemType: string | bigint; + hp: number; + atk: number; + def: number; + int: number; + mdef: number; +}[]; + /** The snapshot is stored as JSON, where bigint fields round-trip as decimal strings. */ function deserializePet(pet: { petId: string | bigint; @@ -105,6 +140,7 @@ function deserializePet(pet: { streak: number; readyAt: number; sourceVersion: string | bigint; + equipment?: SnapshotEquipment; }) { return { petId: BigInt(pet.petId), @@ -118,6 +154,11 @@ function deserializePet(pet: { streak: pet.streak, readyAt: pet.readyAt, sourceVersion: BigInt(pet.sourceVersion), + // Widened back to bigint: JSON storage round-trips the item type as a decimal + // string, and the protocol's validator wants the number it was written as. + ...(pet.equipment && { + equipment: pet.equipment.map((entry) => ({ ...entry, itemType: BigInt(entry.itemType) })), + }), }; } diff --git a/backend/src/features/battle/worker/verify.worker.ts b/backend/src/features/battle/worker/verify.worker.ts index e311efa2..fdcee6dc 100644 --- a/backend/src/features/battle/worker/verify.worker.ts +++ b/backend/src/features/battle/worker/verify.worker.ts @@ -14,6 +14,7 @@ import { prisma } from '@config/prisma'; import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle/ledger'; import { callVerifyBattle, type VerifyBattleWire, type VerifyPetProgressionWire } from '@grpc-client/verifyBattle'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; +import { equipmentBonus, type SnapshotEquipment } from './compute.worker'; /** * Handles `verify` messages: `computed` -> `verified` (§F). @@ -56,8 +57,8 @@ export async function processVerifyMessage(message: ClaimedMessage, nowSeconds: const ruleset = loadRulesetBundle(JSON.stringify(rulesetRow.bundle), battle.rulesetHash as Hex); const snapshot = battle.snapshot as unknown as BattleSnapshot; - const attacker = snapshot.attacker as unknown as Record; - const defender = snapshot.defender as unknown as Record; + const attacker = snapshot.attacker as unknown as Record; + const defender = snapshot.defender as unknown as Record; const outcome = await callVerifyBattle({ attacker: toWirePet(attacker), @@ -107,7 +108,12 @@ export async function processVerifyMessage(message: ClaimedMessage, nowSeconds: await completeOutbox(message.id, new Date(nowSeconds * 1000)); } -function toWirePet(pet: Record) { +function toWirePet(pet: Record) { + // The resolved equipment total, so the independent recomputation runs on the same + // inputs the canonical engine used (roadmap §4). Sending the frozen modifiers rather + // than item ids is what lets the verifier hold no item catalog at all: what §F checks + // is that the fight follows from the numbers the receipt publishes. + const bonus = equipmentBonus(pet.equipment as SnapshotEquipment | undefined); return { petId: String(pet.petId), dna: String(pet.dna), @@ -117,6 +123,11 @@ function toWirePet(pet: Record) { xp: Number(pet.xp), lastOpponentId: String(pet.lastOpponentId), streak: Number(pet.streak), + bonusHp: bonus.hp, + bonusAtk: bonus.atk, + bonusDef: bonus.def, + bonusInt: bonus.int, + bonusMdef: bonus.mdef, }; } diff --git a/backend/src/grpc/verifyBattle.ts b/backend/src/grpc/verifyBattle.ts index 26eb3925..bb26fbb1 100644 --- a/backend/src/grpc/verifyBattle.ts +++ b/backend/src/grpc/verifyBattle.ts @@ -35,6 +35,19 @@ export interface VerifyPetInputsWire { xp: number; lastOpponentId: string; streak: number; + /** + * The pet's resolved equipment total (roadmap §4), frozen at acceptance. + * + * Five scalars rather than a nested message, so a server that predates equipment reads + * proto3 defaults of zero. That is exactly "ungeared", which means a geared battle sent + * to an old verifier fails §F loudly rather than being checked against the wrong + * inputs and passing. + */ + bonusHp: number; + bonusAtk: number; + bonusDef: number; + bonusInt: number; + bonusMdef: number; } export interface VerifySkillConfigWire { diff --git a/backend/tests/features/battle/ledger/accept.service.test.ts b/backend/tests/features/battle/ledger/accept.service.test.ts index d0487446..a22dd324 100644 --- a/backend/tests/features/battle/ledger/accept.service.test.ts +++ b/backend/tests/features/battle/ledger/accept.service.test.ts @@ -14,6 +14,15 @@ vi.mock('@config/prisma', () => ({ }, })); +// The catalog join is covered in ruleset.builder's own test; stubbed to the source +// default here so these stay about what accept does, not about how a ruleset is assembled. +vi.mock('../../../../src/features/battle/ledger/ruleset.builder', async () => { + const { SOURCE_DEFAULT_RULESET } = await vi.importActual( + '@cryptopets/protocol', + ); + return { servedRuleset: vi.fn(async () => SOURCE_DEFAULT_RULESET) }; +}); + vi.mock('../../../../src/features/battle/ledger/snapshot.builder', () => ({ buildPetSnapshot: vi.fn(), })); diff --git a/backend/tests/features/battle/ledger/config.service.test.ts b/backend/tests/features/battle/ledger/config.service.test.ts index eb421886..daeaa177 100644 --- a/backend/tests/features/battle/ledger/config.service.test.ts +++ b/backend/tests/features/battle/ledger/config.service.test.ts @@ -8,6 +8,14 @@ const battleEnv = vi.hoisted(() => ({ chainIds: ['eip155:84532', 'solana:devnet'], })); +// The catalog join has its own test; stubbed so this stays about what config serves. +vi.mock('../../../../src/features/battle/ledger/ruleset.builder', async () => { + const { SOURCE_DEFAULT_RULESET } = await vi.importActual( + '@cryptopets/protocol', + ); + return { servedRuleset: vi.fn(async () => SOURCE_DEFAULT_RULESET) }; +}); + vi.mock('@config/env', () => ({ env: { battle: battleEnv } })); vi.mock('@config/prisma', () => ({ prisma: { @@ -28,55 +36,57 @@ beforeEach(() => { }); describe('getBattleConfig', () => { - it('serves the deployment a client must name in an intent', () => { + it('serves the deployment a client must name in an intent', async () => { // A client that guessed this would have its intent refused as `wrong-deployment`, // after the wallet prompt rather than before it. - expect(getBattleConfig().deploymentId).toBe('base-sepolia-live'); + expect((await getBattleConfig()).deploymentId).toBe('base-sepolia-live'); }); - it('serves every chain this process accepts intents for', () => { - expect(getBattleConfig().chainIds).toEqual(['eip155:84532', 'solana:devnet']); + it('serves every chain this process accepts intents for', async () => { + expect((await getBattleConfig()).chainIds).toEqual(['eip155:84532', 'solana:devnet']); }); - it('serves the ruleset the accept path actually commits battles under', () => { + it('serves the ruleset the accept path actually commits battles under', async () => { // Not "some published ruleset" — the one `acceptBattle` binds a battle to, since a // defence authorization bound to any other is refused as `ruleset-mismatch`. - expect(getBattleConfig().ruleset).toEqual({ + expect((await getBattleConfig()).ruleset).toEqual({ hash: hashRuleset(SOURCE_DEFAULT_RULESET), version: SOURCE_DEFAULT_RULESET.version, }); }); - it('reflects a reconfigured deployment rather than a cached first read', () => { + it('reflects a reconfigured deployment rather than a cached first read', async () => { battleEnv.deploymentId = 'base-mainnet-live'; battleEnv.chainIds = ['eip155:8453']; - expect(getBattleConfig()).toMatchObject({ + expect(await getBattleConfig()).toMatchObject({ deploymentId: 'base-mainnet-live', chainIds: ['eip155:8453'], }); }); - it('reports whether this deployment is accepting backend battles', () => { + it('reports whether this deployment is accepting backend battles', async () => { // The frontend mode switch reads this. Discovering the answer by submitting an // intent and getting a 503 would mean finding out after the wallet prompt. - expect(getBattleConfig().enabled).toBe(true); + expect((await getBattleConfig()).enabled).toBe(true); battleEnv.enabled = false; - expect(getBattleConfig().enabled).toBe(false); + expect((await getBattleConfig()).enabled).toBe(false); }); - it('keeps serving the deployment and ruleset while the mode is off', () => { + it('keeps serving the deployment and ruleset while the mode is off', async () => { // Reads stay open when writes are refused, so a client can still verify receipts // this deployment issued before the mode was switched off. battleEnv.enabled = false; - expect(getBattleConfig()).toMatchObject({ deploymentId: 'base-sepolia-live' }); + expect(await getBattleConfig()).toMatchObject({ deploymentId: 'base-sepolia-live' }); }); - it('rejects a chain id the protocol does not recognise', () => { + it('rejects a chain id the protocol does not recognise', async () => { // Served config is what clients build signable objects from, so a malformed chain // id must fail here rather than become an unsignable intent. battleEnv.chainIds = ['not-a-chain-id']; - expect(() => getBattleConfig()).toThrow(); + // Rejects rather than throws: the read became async when the ruleset started + // joining the item catalog, so a bad chain id surfaces as a rejected promise. + await expect(getBattleConfig()).rejects.toThrow(); }); }); diff --git a/backend/tests/features/battle/ledger/ruleset.builder.test.ts b/backend/tests/features/battle/ledger/ruleset.builder.test.ts new file mode 100644 index 00000000..9f806af4 --- /dev/null +++ b/backend/tests/features/battle/ledger/ruleset.builder.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const catalog = vi.fn(); +vi.mock('@features/inventory', () => ({ getCatalog: () => catalog() })); + +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +import { resetServedRuleset, servedRuleset } from '@features/battle/ledger/ruleset.builder'; + +/** + * The ruleset a deployment fights under (roadmap §4). + * + * What matters here is which catalog rows reach the hash, because `rulesetHash` is what + * defence consent is bound to: anything that moves it re-prompts every defender. + */ + +const BLADE = { + itemType: '1', + key: 'iron_fang', + category: 'equipment', + slot: 0, + rarity: 1, + effect: { kind: 'stat_bonus' as const, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + name: 'Iron Fang', + description: '', +}; + +const PLATE = { + ...BLADE, + itemType: '11', + key: 'scale_mail', + slot: 1, + effect: { kind: 'stat_bonus' as const, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, +}; + +const POTION = { + ...BLADE, + itemType: '100', + key: 'xp_potion_i', + category: 'consumable', + slot: null, + effect: { kind: 'grant_xp' as const, amount: 50 }, +}; + +const BADGE = { ...POTION, itemType: '201', key: 'founders_badge', category: 'collectible', effect: null }; + +beforeEach(() => { + vi.clearAllMocks(); + resetServedRuleset(); +}); + +describe('servedRuleset', () => { + it('carries the equipment catalog into the ruleset', async () => { + catalog.mockResolvedValue([BLADE, PLATE]); + + const ruleset = await servedRuleset(); + + expect(ruleset.itemCatalog).toEqual([ + { itemType: 1n, slot: 0, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + { itemType: 11n, slot: 1, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, + ]); + }); + + // A potion cannot change a fight, and listing one would move rulesetHash — which + // invalidates every outstanding defence authorization. Re-prompting every defender + // because a badge was added would train players to click through the prompt that + // actually matters. + it('leaves out anything that cannot change a fight', async () => { + catalog.mockResolvedValue([BLADE, POTION, BADGE]); + + const ruleset = await servedRuleset(); + + expect(ruleset.itemCatalog?.map((i) => i.itemType)).toEqual([1n]); + }); + + it('produces the source-default hash on a deployment with no equipment', async () => { + catalog.mockResolvedValue([POTION, BADGE]); + + expect(hashRuleset(await servedRuleset())).toBe(hashRuleset(SOURCE_DEFAULT_RULESET)); + }); + + // Order is part of the ruleset digest, and assertRuleset refuses to sort silently, so + // a catalog returned in any order still has to hash the same. + it('sorts by item type, so catalog ordering cannot move the hash', async () => { + catalog.mockResolvedValue([PLATE, BLADE]); + const forward = hashRuleset(await servedRuleset()); + + resetServedRuleset(); + catalog.mockResolvedValue([BLADE, PLATE]); + + expect(hashRuleset(await servedRuleset())).toBe(forward); + }); + + // The whole mechanism §4 asks for: a rebalance has to move the hash, so consent given + // under the old numbers stops covering battles fought under the new ones. + it('moves the hash when an item is re-priced', async () => { + catalog.mockResolvedValue([BLADE]); + const before = hashRuleset(await servedRuleset()); + + resetServedRuleset(); + catalog.mockResolvedValue([{ ...BLADE, effect: { ...BLADE.effect, atk: 5 } }]); + + expect(hashRuleset(await servedRuleset())).not.toBe(before); + }); + + // Read on every accept, so re-querying per battle would put a table scan on the hot + // path to answer a question that changes at deploy time. + it('reads the catalog once and caches it', async () => { + catalog.mockResolvedValue([BLADE]); + + await servedRuleset(); + await servedRuleset(); + + expect(catalog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/tests/features/battle/ledger/snapshot.builder.test.ts b/backend/tests/features/battle/ledger/snapshot.builder.test.ts index bbfd3476..2f2055c3 100644 --- a/backend/tests/features/battle/ledger/snapshot.builder.test.ts +++ b/backend/tests/features/battle/ledger/snapshot.builder.test.ts @@ -4,6 +4,12 @@ vi.mock('@config/env', () => ({ env: { battle: { deploymentId: 'base-sepolia-live', chainIds: ['eip155:84532'] } }, })); +// Equipment resolution has its own coverage; stubbed to ungeared so these stay about +// merging the roster with progression. +vi.mock('@features/inventory', () => ({ + getPetEquipment: vi.fn(async () => []), +})); + vi.mock('@config/prisma', () => ({ prisma: { petRoster: { findUnique: vi.fn() }, @@ -13,6 +19,7 @@ vi.mock('@config/prisma', () => ({ import { prisma } from '@config/prisma'; import { buildPetSnapshot } from '@features/battle/ledger'; +import { getPetEquipment } from '@features/inventory'; const ROSTER_ROW = { chain: 'evm', @@ -221,3 +228,69 @@ describe('first backend battle for a pet', () => { await expect(buildPetSnapshot('eip155:84532', '1')).rejects.toThrow(/connection reset/); }); }); + +describe('freezing equipment (roadmap §4)', () => { + // Its own setup: vi.clearAllMocks resets call records but not implementations, so an + // earlier case's rejecting create would otherwise carry into these. + beforeEach(() => { + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue({ + level: 40, xp: 0, lastOpponentId: '0', streak: 0, readyAt: 0n, + } as never); + }); + + const BLADE = { + slot: 0, + item: { + itemType: '1', key: 'iron_fang', category: 'equipment', slot: 0, rarity: 1, + effect: { kind: 'stat_bonus' as const, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + name: 'Iron Fang', description: '', + }, + }; + const PLATE = { + slot: 1, + item: { + ...BLADE.item, itemType: '11', key: 'scale_mail', slot: 1, + effect: { kind: 'stat_bonus' as const, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, + }, + }; + + // Resolved, not referenced: unequipping after acceptance must not change a committed + // fight, exactly as a level-up between acceptance and settlement must not. + it('freezes the resolved modifiers alongside the item type', async () => { + vi.mocked(getPetEquipment).mockResolvedValue([BLADE] as never); + + const snapshot = await buildPetSnapshot('eip155:84532', '1'); + + expect(snapshot!.equipment).toEqual([ + { slot: 0, itemType: 1n, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + ]); + }); + + // Slot order is part of the snapshot digest, and assertPetSnapshot refuses to sort + // silently, so the builder has to hand it over already ordered. + it('orders slots ascending whatever order the rows arrive in', async () => { + vi.mocked(getPetEquipment).mockResolvedValue([PLATE, BLADE] as never); + + const snapshot = await buildPetSnapshot('eip155:84532', '1'); + + expect(snapshot!.equipment?.map((e) => e.slot)).toEqual([0, 1]); + }); + + // An entry claiming an item was worn and did nothing reads as a bug rather than a fact. + it('leaves out an equipped item with no combat effect', async () => { + vi.mocked(getPetEquipment).mockResolvedValue([ + { slot: 0, item: { ...BLADE.item, effect: null } }, + ] as never); + + expect((await buildPetSnapshot('eip155:84532', '1'))!.equipment).toBeUndefined(); + }); + + // Omitted rather than empty, so an ungeared snapshot's stored JSON is identical to what + // it was before equipment existed. + it('omits the field entirely for an ungeared pet', async () => { + vi.mocked(getPetEquipment).mockResolvedValue([] as never); + + expect((await buildPetSnapshot('eip155:84532', '1'))!.equipment).toBeUndefined(); + }); +}); diff --git a/proto/cryptopets.proto b/proto/cryptopets.proto index 7bccb0ce..a663ddd7 100644 --- a/proto/cryptopets.proto +++ b/proto/cryptopets.proto @@ -87,6 +87,19 @@ message VerifyPetInputs { uint32 xp = 6; string last_opponent_id = 7; // decimal string; "0" = no prior opponent uint32 streak = 8; + + // Equipment total, already resolved from the frozen snapshot (roadmap §4). + // + // The resolved modifiers rather than the item ids, because that is what the fight + // actually consumes and what the receipt publishes; the verifier has no item catalog + // and needs none. Sent as five scalars rather than a nested message so an older server + // sees proto3 defaults of zero, which is exactly "ungeared" — a geared battle then + // fails §F loudly instead of being verified against the wrong inputs. + uint32 bonus_hp = 9; + uint32 bonus_atk = 10; + uint32 bonus_def = 11; + uint32 bonus_int = 12; + uint32 bonus_mdef = 13; } // Mirrors protocol's SkillConfig field for field. diff --git a/protocol/src/ruleset/index.ts b/protocol/src/ruleset/index.ts index bad30c43..6e75449c 100644 --- a/protocol/src/ruleset/index.ts +++ b/protocol/src/ruleset/index.ts @@ -1,6 +1,7 @@ export { loadRulesetBundle, parseRulesetBundle, publishRuleset, serializeRuleset } from './bundle'; export { assertRulesetHash, encodeRuleset, hashRuleset } from './hash'; export { + type ItemModifier, assertRuleset, ENGINE_ID, ENGINE_VERSION, diff --git a/services/indexer-go/internal/grpcsrv/verify.go b/services/indexer-go/internal/grpcsrv/verify.go index f5ab22af..c33f5e0b 100644 --- a/services/indexer-go/internal/grpcsrv/verify.go +++ b/services/indexer-go/internal/grpcsrv/verify.go @@ -84,6 +84,13 @@ func petInputsFromProto(p *pb.VerifyPetInputs) (combat.PetInputs, error) { if p.GetSkill() > 255 { return combat.PetInputs{}, fmt.Errorf("skill out of range: %d", p.GetSkill()) } + // Equipment bonuses (roadmap §4). Range-checked rather than truncated: a value past + // 16 bits means the caller resolved something this port cannot represent, and silently + // wrapping it would make the two engines disagree on a battle instead of refusing it. + bonus, err := bonusFromProto(p) + if err != nil { + return combat.PetInputs{}, err + } return combat.PetInputs{ PetID: petID, DNA: dna, @@ -93,6 +100,36 @@ func petInputsFromProto(p *pb.VerifyPetInputs) (combat.PetInputs, error) { XP: p.GetXp(), LastOpponentID: lastOpponentID, Streak: p.GetStreak(), + Bonus: bonus, + }, nil +} + +// bonusFromProto narrows the wire's uint32 bonus fields to the uint16 the engine uses. +// +// An unset field is 0, which is exactly "ungeared", so a client that predates equipment +// verifies as it always did. +func bonusFromProto(p *pb.VerifyPetInputs) (combat.AttrBonus, error) { + fields := []struct { + name string + value uint32 + }{ + {"bonus_hp", p.GetBonusHp()}, + {"bonus_atk", p.GetBonusAtk()}, + {"bonus_def", p.GetBonusDef()}, + {"bonus_int", p.GetBonusInt()}, + {"bonus_mdef", p.GetBonusMdef()}, + } + for _, f := range fields { + if f.value > 0xFFFF { + return combat.AttrBonus{}, fmt.Errorf("%s out of range: %d", f.name, f.value) + } + } + return combat.AttrBonus{ + HP: uint16(p.GetBonusHp()), + ATK: uint16(p.GetBonusAtk()), + DEF: uint16(p.GetBonusDef()), + INT: uint16(p.GetBonusInt()), + MDEF: uint16(p.GetBonusMdef()), }, nil } diff --git a/services/indexer-go/pb/cryptopets.pb.go b/services/indexer-go/pb/cryptopets.pb.go index fdc9cf1b..a81bc850 100644 --- a/services/indexer-go/pb/cryptopets.pb.go +++ b/services/indexer-go/pb/cryptopets.pb.go @@ -417,8 +417,20 @@ type VerifyPetInputs struct { Xp uint32 `protobuf:"varint,6,opt,name=xp,proto3" json:"xp,omitempty"` LastOpponentId string `protobuf:"bytes,7,opt,name=last_opponent_id,json=lastOpponentId,proto3" json:"last_opponent_id,omitempty"` // decimal string; "0" = no prior opponent Streak uint32 `protobuf:"varint,8,opt,name=streak,proto3" json:"streak,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Equipment total, already resolved from the frozen snapshot (roadmap §4). + // + // The resolved modifiers rather than the item ids, because that is what the fight + // actually consumes and what the receipt publishes; the verifier has no item catalog + // and needs none. Sent as five scalars rather than a nested message so an older server + // sees proto3 defaults of zero, which is exactly "ungeared" — a geared battle then + // fails §F loudly instead of being verified against the wrong inputs. + BonusHp uint32 `protobuf:"varint,9,opt,name=bonus_hp,json=bonusHp,proto3" json:"bonus_hp,omitempty"` + BonusAtk uint32 `protobuf:"varint,10,opt,name=bonus_atk,json=bonusAtk,proto3" json:"bonus_atk,omitempty"` + BonusDef uint32 `protobuf:"varint,11,opt,name=bonus_def,json=bonusDef,proto3" json:"bonus_def,omitempty"` + BonusInt uint32 `protobuf:"varint,12,opt,name=bonus_int,json=bonusInt,proto3" json:"bonus_int,omitempty"` + BonusMdef uint32 `protobuf:"varint,13,opt,name=bonus_mdef,json=bonusMdef,proto3" json:"bonus_mdef,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VerifyPetInputs) Reset() { @@ -507,6 +519,41 @@ func (x *VerifyPetInputs) GetStreak() uint32 { return 0 } +func (x *VerifyPetInputs) GetBonusHp() uint32 { + if x != nil { + return x.BonusHp + } + return 0 +} + +func (x *VerifyPetInputs) GetBonusAtk() uint32 { + if x != nil { + return x.BonusAtk + } + return 0 +} + +func (x *VerifyPetInputs) GetBonusDef() uint32 { + if x != nil { + return x.BonusDef + } + return 0 +} + +func (x *VerifyPetInputs) GetBonusInt() uint32 { + if x != nil { + return x.BonusInt + } + return 0 +} + +func (x *VerifyPetInputs) GetBonusMdef() uint32 { + if x != nil { + return x.BonusMdef + } + return 0 +} + // Mirrors protocol's SkillConfig field for field. type VerifySkillConfig struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1070,7 +1117,7 @@ const file_cryptopets_proto_rawDesc = "" + "\asamples\x18\x04 \x01(\rR\asamples\"P\n" + "\vWinResponse\x12'\n" + "\x0fwin_probability\x18\x01 \x01(\x01R\x0ewinProbability\x12\x18\n" + - "\asamples\x18\x02 \x01(\rR\asamples\"\xd0\x01\n" + + "\asamples\x18\x02 \x01(\rR\asamples\"\xe1\x02\n" + "\x0fVerifyPetInputs\x12\x15\n" + "\x06pet_id\x18\x01 \x01(\tR\x05petId\x12\x10\n" + "\x03dna\x18\x02 \x01(\tR\x03dna\x12\x16\n" + @@ -1079,7 +1126,14 @@ const file_cryptopets_proto_rawDesc = "" + "\x05skill\x18\x05 \x01(\rR\x05skill\x12\x0e\n" + "\x02xp\x18\x06 \x01(\rR\x02xp\x12(\n" + "\x10last_opponent_id\x18\a \x01(\tR\x0elastOpponentId\x12\x16\n" + - "\x06streak\x18\b \x01(\rR\x06streak\"\xca\x02\n" + + "\x06streak\x18\b \x01(\rR\x06streak\x12\x19\n" + + "\bbonus_hp\x18\t \x01(\rR\abonusHp\x12\x1b\n" + + "\tbonus_atk\x18\n" + + " \x01(\rR\bbonusAtk\x12\x1b\n" + + "\tbonus_def\x18\v \x01(\rR\bbonusDef\x12\x1b\n" + + "\tbonus_int\x18\f \x01(\rR\bbonusInt\x12\x1d\n" + + "\n" + + "bonus_mdef\x18\r \x01(\rR\tbonusMdef\"\xca\x02\n" + "\x11VerifySkillConfig\x12 \n" + "\ftank_hp_mult\x18\x01 \x01(\rR\n" + "tankHpMult\x12$\n" + From 65ac46cf1b4846188accf0e70ac405aa5321b171 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 01:47:14 -0400 Subject: [PATCH 22/56] feat(verifier): replay and audit geared receipts --- protocol/src/receipt/wire.ts | 21 +++- verifier/fixtures/corpus-tampered.json | 39 ++++-- verifier/fixtures/corpus.json | 39 ++++-- ...62a38fb84f14a4228f94e95f2a3a97689ab38.json | 38 ++++++ verifier/scripts/gen-corpus.ts | 18 ++- verifier/src/checks/combatReplay.ts | 7 ++ verifier/src/checks/equipment.ts | 77 ++++++++++++ verifier/src/checks/index.ts | 1 + verifier/src/verify.ts | 4 + verifier/tests/checks/equipment.test.ts | 116 ++++++++++++++++++ verifier/tests/fixtures/corpus.ts | 17 ++- verifier/tests/fixtures/signedReceipt.ts | 79 +++++++++--- verifier/tests/verify.test.ts | 1 + 13 files changed, 414 insertions(+), 43 deletions(-) create mode 100644 verifier/rulesets/0x9b4fe8ecacfecd7af6d6d39b82c62a38fb84f14a4228f94e95f2a3a97689ab38.json create mode 100644 verifier/src/checks/equipment.ts create mode 100644 verifier/tests/checks/equipment.test.ts diff --git a/protocol/src/receipt/wire.ts b/protocol/src/receipt/wire.ts index fa303569..6965389a 100644 --- a/protocol/src/receipt/wire.ts +++ b/protocol/src/receipt/wire.ts @@ -1,5 +1,5 @@ import type { PetProgression } from '../progression/progression'; -import type { BattleSnapshot, PetSnapshot } from '../snapshot/types'; +import type { BattleSnapshot, EquipEntry, PetSnapshot } from '../snapshot/types'; import type { BattleReceipt } from './types'; @@ -26,11 +26,16 @@ export type WireBattleSnapshot = Omit & defender: WirePetSnapshot; }; -export type WirePetSnapshot = Omit & { +export type WirePetSnapshot = Omit< + PetSnapshot, + 'petId' | 'dna' | 'lastOpponentId' | 'sourceVersion' | 'equipment' +> & { petId: string; dna: string; lastOpponentId: string; sourceVersion: string; + /** Item types are uint256 too, so they cross the wire as decimal strings. */ + equipment?: (Omit & { itemType: string })[]; }; export interface WireProgressionDelta { @@ -60,12 +65,22 @@ export function receiptFromWire(wire: WireBattleReceipt): BattleReceipt { } function petSnapshotFromWire(pet: WirePetSnapshot): PetSnapshot { + // `equipment` is pulled out of the spread rather than overridden after it: its wire + // shape carries a string item type, and spreading it first would leave that type in + // the result even though the value is replaced. + const { equipment, ...rest } = pet; return { - ...pet, + ...rest, petId: BigInt(pet.petId), dna: BigInt(pet.dna), lastOpponentId: BigInt(pet.lastOpponentId), sourceVersion: BigInt(pet.sourceVersion), + // Widened like every other uint256 here. Left as a string it reaches + // `assertPetSnapshot` as the wrong type and rejects the whole receipt, which is how + // this was found: a geared receipt failed seed derivation rather than decoding. + ...(equipment && { + equipment: equipment.map((entry) => ({ ...entry, itemType: BigInt(entry.itemType) })), + }), }; } diff --git a/verifier/fixtures/corpus-tampered.json b/verifier/fixtures/corpus-tampered.json index 5b3ba3a9..476475ba 100644 --- a/verifier/fixtures/corpus-tampered.json +++ b/verifier/fixtures/corpus-tampered.json @@ -187,8 +187,8 @@ } }, { - "receiptHash": "0x312885fbefed24b78c8510df832fb708bd8d85a8d40ce38ee0e6730e8725bdbc", - "signature": "0x13f0a055dc65e8c303f80bd1542bbddb826b5a00479a49b7033ff3f5c63429502b0a6366d4d36f4db23a5170d97c9fd3de3d24d3ea1ff2c16bdd9c73cadf69ec1b", + "receiptHash": "0x3fb6e9e01a9ee7ac6d649254ddfaa4e5f0a2d1dd6336cb448ef318604bf418ad", + "signature": "0x8f63f41c3f106d33148e1a2e91058147d87fff49bac9ea95722ec827afa1119d6a1298624691208e627ce34d959f25ceb06ec84b8225cac152d77c768fe322f01c", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -215,7 +215,27 @@ "lastOpponentId": "0", "streak": 0, "readyAt": 1692806267, - "sourceVersion": "1692806317" + "sourceVersion": "1692806317", + "equipment": [ + { + "slot": 0, + "itemType": "1", + "hp": 0, + "atk": 4, + "def": 0, + "int": 0, + "mdef": 0 + }, + { + "slot": 1, + "itemType": "11", + "hp": 30, + "atk": 0, + "def": 10, + "int": 0, + "mdef": 0 + } + ] }, "defender": { "petId": "2", @@ -230,7 +250,8 @@ "readyAt": 1692806267, "sourceVersion": "1692806317" }, - "takenAt": 1692806361 + "takenAt": 1692806361, + "schemaVersion": 2 }, "beacon": { "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", @@ -238,15 +259,15 @@ "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" }, - "seed": "0x59edb62ce7ad180d584f817e1a4fd7e8a21aa02df2feb0cc17548214824ca299", + "seed": "0xe9753fb217140a8a38c6527997e1e438426a022ded2043a3d60937fa8691656c", "rulesetVersion": 1, - "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", + "rulesetHash": "0x9b4fe8ecacfecd7af6d6d39b82c62a38fb84f14a4228f94e95f2a3a97689ab38", "result": { "attackerWon": true, - "rounds": 6, - "winnerHpRemaining": 199 + "rounds": 5, + "winnerHpRemaining": 218 }, - "combatLogHash": "0xa626aa46f28919e253dd0941372e8e66e48830657f449f8214f25d912383cf1b", + "combatLogHash": "0x133d7ed5ec19f64213075c07e1a42439a5d68f49ab33a310c0f6e488f9937f2f", "progression": { "attacker": { "petId": "1", diff --git a/verifier/fixtures/corpus.json b/verifier/fixtures/corpus.json index e72f1ec1..5044e8cc 100644 --- a/verifier/fixtures/corpus.json +++ b/verifier/fixtures/corpus.json @@ -187,8 +187,8 @@ } }, { - "receiptHash": "0x312885fbefed24b78c8510df832fb708bd8d85a8d40ce38ee0e6730e8725bdbc", - "signature": "0x13f0a055dc65e8c303f80bd1542bbddb826b5a00479a49b7033ff3f5c63429502b0a6366d4d36f4db23a5170d97c9fd3de3d24d3ea1ff2c16bdd9c73cadf69ec1b", + "receiptHash": "0x3fb6e9e01a9ee7ac6d649254ddfaa4e5f0a2d1dd6336cb448ef318604bf418ad", + "signature": "0x8f63f41c3f106d33148e1a2e91058147d87fff49bac9ea95722ec827afa1119d6a1298624691208e627ce34d959f25ceb06ec84b8225cac152d77c768fe322f01c", "signingKeyId": "battle-signer-2026-07", "payload": { "domain": { @@ -215,7 +215,27 @@ "lastOpponentId": "0", "streak": 0, "readyAt": 1692806267, - "sourceVersion": "1692806317" + "sourceVersion": "1692806317", + "equipment": [ + { + "slot": 0, + "itemType": "1", + "hp": 0, + "atk": 4, + "def": 0, + "int": 0, + "mdef": 0 + }, + { + "slot": 1, + "itemType": "11", + "hp": 30, + "atk": 0, + "def": 10, + "int": 0, + "mdef": 0 + } + ] }, "defender": { "petId": "2", @@ -230,7 +250,8 @@ "readyAt": 1692806267, "sourceVersion": "1692806317" }, - "takenAt": 1692806361 + "takenAt": 1692806361, + "schemaVersion": 2 }, "beacon": { "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", @@ -238,15 +259,15 @@ "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" }, - "seed": "0x59edb62ce7ad180d584f817e1a4fd7e8a21aa02df2feb0cc17548214824ca299", + "seed": "0xe9753fb217140a8a38c6527997e1e438426a022ded2043a3d60937fa8691656c", "rulesetVersion": 1, - "rulesetHash": "0xe51caf2298cfd0afab7d4bb3e391bdc0e1187d014782dbbb72e10ca42a4ccbac", + "rulesetHash": "0x9b4fe8ecacfecd7af6d6d39b82c62a38fb84f14a4228f94e95f2a3a97689ab38", "result": { "attackerWon": true, - "rounds": 6, - "winnerHpRemaining": 199 + "rounds": 5, + "winnerHpRemaining": 218 }, - "combatLogHash": "0xa626aa46f28919e253dd0941372e8e66e48830657f449f8214f25d912383cf1b", + "combatLogHash": "0x133d7ed5ec19f64213075c07e1a42439a5d68f49ab33a310c0f6e488f9937f2f", "progression": { "attacker": { "petId": "1", diff --git a/verifier/rulesets/0x9b4fe8ecacfecd7af6d6d39b82c62a38fb84f14a4228f94e95f2a3a97689ab38.json b/verifier/rulesets/0x9b4fe8ecacfecd7af6d6d39b82c62a38fb84f14a4228f94e95f2a3a97689ab38.json new file mode 100644 index 00000000..8b72c1b2 --- /dev/null +++ b/verifier/rulesets/0x9b4fe8ecacfecd7af6d6d39b82c62a38fb84f14a4228f94e95f2a3a97689ab38.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 2, + "maxRounds": 30, + "maxLevel": 100, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "schemaVersion": 2, + "itemCatalog": [ + { + "itemType": "1", + "slot": 0, + "hp": 0, + "atk": 4, + "def": 0, + "int": 0, + "mdef": 0 + }, + { + "itemType": "11", + "slot": 1, + "hp": 30, + "atk": 0, + "def": 10, + "int": 0, + "mdef": 0 + } + ] +} diff --git a/verifier/scripts/gen-corpus.ts b/verifier/scripts/gen-corpus.ts index 94b9f3a8..e5d87efe 100644 --- a/verifier/scripts/gen-corpus.ts +++ b/verifier/scripts/gen-corpus.ts @@ -24,6 +24,8 @@ import { fileURLToPath } from 'node:url'; import { publishRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; +import { GEARED_RULESET } from '../tests/fixtures/signedReceipt'; + import { buildCorpus, buildTamperedCorpus, corpusSigningKeys } from '../tests/fixtures/corpus'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -38,12 +40,18 @@ function writeJson(path: string, value: unknown): void { mkdirSync(RULESETS_DIR, { recursive: true }); mkdirSync(FIXTURES_DIR, { recursive: true }); -// The ruleset this build implements, pinned so a battle fought under it stays replayable +// The rulesets this corpus's battles were fought under, pinned so they stay replayable // after ENGINE_VERSION moves on. `serializeRuleset` already emits a trailing newline. -const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); -const rulesetPath = join(RULESETS_DIR, `${hash.toLowerCase()}.json`); -writeFileSync(rulesetPath, json, 'utf8'); -console.log(`wrote ${rulesetPath}`); +// +// Two of them: the source default that the ungeared receipts name, and the geared one the +// last receipt names. A pinned bundle per ruleset is the rule, not an exception — a +// receipt whose bundle is missing is a receipt nobody can replay. +for (const ruleset of [SOURCE_DEFAULT_RULESET, GEARED_RULESET]) { + const { hash, json } = publishRuleset(ruleset); + const rulesetPath = join(RULESETS_DIR, `${hash.toLowerCase()}.json`); + writeFileSync(rulesetPath, json, 'utf8'); + console.log(`wrote ${rulesetPath}`); +} writeJson(join(FIXTURES_DIR, 'corpus.json'), buildCorpus()); writeJson(join(FIXTURES_DIR, 'corpus-tampered.json'), buildTamperedCorpus()); diff --git a/verifier/src/checks/combatReplay.ts b/verifier/src/checks/combatReplay.ts index 4123f6c4..8b199731 100644 --- a/verifier/src/checks/combatReplay.ts +++ b/verifier/src/checks/combatReplay.ts @@ -1,5 +1,6 @@ import { type BattleReceipt, hashCombatLog, type Ruleset, simulate } from '@cryptopets/protocol'; +import { equipmentBonus } from './equipment'; import type { CheckResult } from './types'; /** @@ -37,6 +38,12 @@ export function checkCombatReplay(receipt: BattleReceipt, ruleset: Ruleset): Che defender.skill, BigInt(receipt.seed), ruleset.skillConfig, + // The bonuses the snapshot froze, not the catalog's: this reproduces the fight + // that happened. Whether those bonuses were the right ones is `checkEquipment`, + // reported separately so a mispriced item reads as a mispriced item rather + // than as an unexplained replay mismatch. + equipmentBonus(attacker.equipment), + equipmentBonus(defender.equipment), ); } catch (error) { return { check, ok: false, detail: `replay could not run: ${(error as Error).message}` }; diff --git a/verifier/src/checks/equipment.ts b/verifier/src/checks/equipment.ts new file mode 100644 index 00000000..7b7f128e --- /dev/null +++ b/verifier/src/checks/equipment.ts @@ -0,0 +1,77 @@ +import type { BattleReceipt, EquipEntry, Ruleset } from '@cryptopets/protocol'; + +import type { CheckResult } from './types'; + +/** + * Confirms each pet's frozen modifiers are the ones its items are supposed to grant + * (roadmap §4). + * + * The combat replay proves a fight followed from the numbers in the receipt. It cannot + * prove those numbers were *right*: a receipt that quietly gave one pet +50 ATK from a + * dagger replays perfectly, because the inflated bonus is the very thing being replayed + * against. Self-consistent is not the same as honest. + * + * This closes that gap using two fields that exist for no other reason. The snapshot + * records each item's `itemType` alongside its resolved bonus, and the ruleset the receipt + * names publishes what every combat-affecting item does. So the declared effect and the + * applied effect can be compared, by anyone, years later, from the receipt and its bundle + * alone. + * + * What it still does not prove is that the pet *owned* the item. That is a claim about + * chain state at `sourceVersion`, which this package deliberately cannot read — it has no + * network access, by design. A verifier that wants that checks `ItemCore.equipmentOf` at + * the recorded version itself; this narrows the remaining trust to exactly that one + * question (threat T13). + */ +export function checkEquipment(receipt: BattleReceipt, ruleset: Ruleset): CheckResult { + const check = 'equipment'; + const declared = new Map((ruleset.itemCatalog ?? []).map((item) => [item.itemType, item])); + + const mismatches: string[] = []; + for (const [role, pet] of [['attacker', receipt.snapshot.attacker], ['defender', receipt.snapshot.defender]] as const) { + for (const entry of pet.equipment ?? []) { + const item = declared.get(entry.itemType); + if (!item) { + // An item the ruleset never priced. The fight used a modifier from + // nowhere, which is unauditable rather than merely unusual. + mismatches.push(`${role} slot ${entry.slot}: item ${entry.itemType} is not in the ruleset's catalog`); + continue; + } + if (item.slot !== entry.slot) { + mismatches.push( + `${role} item ${entry.itemType}: worn in slot ${entry.slot}, catalog says slot ${item.slot}`, + ); + } + for (const field of ['hp', 'atk', 'def', 'int', 'mdef'] as const) { + if (entry[field] !== item[field]) { + mismatches.push( + `${role} item ${entry.itemType}: ${field} applied ${entry[field]}, catalog declares ${item[field]}`, + ); + } + } + } + } + + return mismatches.length === 0 ? { check, ok: true } : { check, ok: false, detail: mismatches.join('; ') }; +} + +/** + * Totals a pet's frozen equipment into the bonus the engine consumes. + * + * The snapshot's numbers, not the catalog's, and the distinction is the point: the replay + * has to reproduce the fight that happened, so it runs on what was applied. + * `checkEquipment` is what says whether that was correct, and it reports separately, so a + * mispriced item shows up as a mispriced item rather than as an unexplained replay + * mismatch. + */ +export function equipmentBonus(equipment: readonly EquipEntry[] | undefined) { + const total = { hp: 0, atk: 0, def: 0, int: 0, mdef: 0 }; + for (const entry of equipment ?? []) { + total.hp += entry.hp; + total.atk += entry.atk; + total.def += entry.def; + total.int += entry.int; + total.mdef += entry.mdef; + } + return total; +} diff --git a/verifier/src/checks/index.ts b/verifier/src/checks/index.ts index b0b597b2..787bd91f 100644 --- a/verifier/src/checks/index.ts +++ b/verifier/src/checks/index.ts @@ -13,6 +13,7 @@ export { checkBeaconSignature } from './beaconSignature'; export { checkChainContinuity } from './chainContinuity'; export { checkCombatReplay } from './combatReplay'; +export { checkEquipment, equipmentBonus } from './equipment'; export { checkOperatorSignature } from './operatorSignature'; export { checkProgression } from './progression'; export { checkSeedDerivation } from './seedDerivation'; diff --git a/verifier/src/verify.ts b/verifier/src/verify.ts index c552792a..3e19cd31 100644 --- a/verifier/src/verify.ts +++ b/verifier/src/verify.ts @@ -4,6 +4,7 @@ import { checkBeaconSignature, checkChainContinuity, checkCombatReplay, + checkEquipment, checkOperatorSignature, checkProgression, checkSeedDerivation, @@ -127,6 +128,9 @@ function verifyOne( return results; } results.push(about(checkCombatReplay(receipt, ruleset))); + // After the replay: a mispriced item still replays perfectly, so this is what says + // whether the numbers the replay used were the ones the items declare (roadmap §4). + results.push(about(checkEquipment(receipt, ruleset))); results.push(about(checkProgression(receipt, ruleset))); return results; } diff --git a/verifier/tests/checks/equipment.test.ts b/verifier/tests/checks/equipment.test.ts new file mode 100644 index 00000000..fbc3948e --- /dev/null +++ b/verifier/tests/checks/equipment.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { checkEquipment, equipmentBonus } from '../../src/checks/equipment'; +import { + buildReceipt, + GEARED_RULESET, + gearedSnapshot, + SNAPSHOT, +} from '../fixtures/signedReceipt'; + +import { SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +/** + * The check that makes a geared receipt checkable rather than merely self-consistent + * (roadmap §4). + * + * The replay proves the fight followed from the numbers in the receipt. It cannot prove + * those numbers were the right ones, because the inflated bonus would be the very thing + * replayed against. Every case below is about that gap. + */ + +const geared = () => buildReceipt({ snapshot: gearedSnapshot(), ruleset: GEARED_RULESET }); + +describe('checkEquipment', () => { + it('passes when the applied modifiers match what the catalog declares', () => { + expect(checkEquipment(geared(), GEARED_RULESET)).toEqual({ check: 'equipment', ok: true }); + }); + + // Nothing to check is a pass, not a skip: a receipt with no gear has no gear claim to + // be wrong about. + it('passes an ungeared receipt', () => { + expect(checkEquipment(buildReceipt(), SOURCE_DEFAULT_RULESET).ok).toBe(true); + }); + + // The case the check exists for. This receipt replays perfectly — the fight really did + // use +50 ATK — and is still dishonest, because no item grants that. + it('catches a modifier larger than the item declares', () => { + const receipt = buildReceipt({ + snapshot: { + ...gearedSnapshot(), + attacker: { + ...gearedSnapshot().attacker, + equipment: [ + { slot: 0, itemType: 1n, hp: 0, atk: 50, def: 0, int: 0, mdef: 0 }, + { slot: 1, itemType: 11n, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, + ], + }, + }, + ruleset: GEARED_RULESET, + }); + + const result = checkEquipment(receipt, GEARED_RULESET); + + expect(result.ok).toBe(false); + expect(result.detail).toContain('atk applied 50'); + expect(result.detail).toContain('catalog declares 4'); + }); + + // A modifier from nowhere: unauditable rather than merely unusual, since the ruleset + // the receipt itself names never priced this item. + it('catches an item the ruleset never priced', () => { + const result = checkEquipment(geared(), SOURCE_DEFAULT_RULESET); + + expect(result.ok).toBe(false); + expect(result.detail).toContain('is not in the ruleset'); + }); + + it('catches an item worn in a slot the catalog does not put it in', () => { + const base = gearedSnapshot(); + const receipt = buildReceipt({ + snapshot: { + ...base, + attacker: { + ...base.attacker, + // Armour declared for slot 1, worn in slot 2. + equipment: [{ slot: 2, itemType: 11n, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }], + }, + }, + ruleset: GEARED_RULESET, + }); + + const result = checkEquipment(receipt, GEARED_RULESET); + + expect(result.ok).toBe(false); + expect(result.detail).toContain('worn in slot 2'); + }); + + it('reports the defender gear as well as the attacker gear', () => { + const base = gearedSnapshot(); + const receipt = buildReceipt({ + snapshot: { + ...base, + attacker: SNAPSHOT.attacker, + defender: { + ...base.defender, + equipment: [{ slot: 0, itemType: 999n, hp: 0, atk: 1, def: 0, int: 0, mdef: 0 }], + }, + }, + ruleset: GEARED_RULESET, + }); + + expect(checkEquipment(receipt, GEARED_RULESET).detail).toContain('defender'); + }); +}); + +describe('equipmentBonus', () => { + it('totals every attribute across the worn items', () => { + expect(equipmentBonus(gearedSnapshot().attacker.equipment)).toEqual({ + hp: 30, atk: 4, def: 10, int: 0, mdef: 0, + }); + }); + + it('treats absent equipment as no bonus', () => { + expect(equipmentBonus(undefined)).toEqual({ hp: 0, atk: 0, def: 0, int: 0, mdef: 0 }); + }); +}); diff --git a/verifier/tests/fixtures/corpus.ts b/verifier/tests/fixtures/corpus.ts index c6fd3865..401a68fb 100644 --- a/verifier/tests/fixtures/corpus.ts +++ b/verifier/tests/fixtures/corpus.ts @@ -2,7 +2,10 @@ import { type BattleReceipt, hashBattleReceipt, type Hex } from '@cryptopets/pro import type { SignedReceiptEnvelope, TrustedSigningKey } from '../../src/io/types'; -import { buildReceipt, envelopeFor, FORGED_BEACON, testTrustedKey } from './signedReceipt'; +import { buildReceipt, envelopeFor, FORGED_BEACON, testTrustedKey, + GEARED_RULESET, + gearedSnapshot, +} from './signedReceipt'; /** * The committed regression corpus (§H item 3's export shape, used here as a fixture). @@ -48,11 +51,19 @@ function buildChain(tamperAt?: number): BattleReceipt[] { ...(createdAt === undefined ? {} : { createdAt }), }; - const receipt = index === tamperAt ? tamper(links) : buildReceipt(links); + // The last receipt is geared, under a ruleset that prices what it wears (roadmap + // §4). Appended rather than swapped in: the earlier receipts stay ungeared under + // the original ruleset, so this corpus keeps proving that battles signed before + // equipment existed still verify. + const geared = + index === CORPUS_SIZE - 1 + ? { snapshot: gearedSnapshot(), ruleset: GEARED_RULESET } + : {}; + const receipt = index === tamperAt ? tamper({ ...links, ...geared }) : buildReceipt({ ...links, ...geared }); receipts.push(receipt); // Deliberately the *honest* hash: a chain built on the tampered receipt's own hash // would be internally consistent again, which is the opposite of the fixture's job. - previousReceiptHash = hashBattleReceipt(buildReceipt(links)); + previousReceiptHash = hashBattleReceipt(buildReceipt({ ...links, ...geared })); createdAt = receipt.createdAt + 1; } diff --git a/verifier/tests/fixtures/signedReceipt.ts b/verifier/tests/fixtures/signedReceipt.ts index 92734a99..22e25f7e 100644 --- a/verifier/tests/fixtures/signedReceipt.ts +++ b/verifier/tests/fixtures/signedReceipt.ts @@ -1,4 +1,5 @@ import { secp256k1 } from '@noble/curves/secp256k1'; +import { equipmentBonus } from '../../src/checks/equipment'; import { keccak_256 } from '@noble/hashes/sha3'; import { @@ -10,6 +11,7 @@ import { hashBattleSnapshot, hashCombatLog, hashRuleset, + type Ruleset, type Hex, QUICKNET, roundTime, @@ -57,6 +59,44 @@ const PUBLISHED_AT = roundTime(QUICKNET, BEACON.round); const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; export const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +/** + * A ruleset that prices two items, and a snapshot wearing them (roadmap §4). + * + * Kept beside the ungeared fixtures rather than replacing them: the corpus has to keep + * proving that a receipt signed before equipment existed still verifies, so the geared + * receipt is an addition to that chain, not a migration of it. + */ +export const GEARED_RULESET: Ruleset = { + ...SOURCE_DEFAULT_RULESET, + itemCatalog: [ + { itemType: 1n, slot: 0, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + { itemType: 11n, slot: 1, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, + ], +}; + +export const GEARED_RULESET_HASH = hashRuleset(GEARED_RULESET); + +/** + * The same pets, with the attacker wearing both catalogued items. + * + * schemaVersion is stated rather than left to default. An absent one means 1, which cannot + * carry equipment at all, so omitting it here would not produce an ungeared snapshot but a + * rejected one. + */ +export function gearedSnapshot(): BattleSnapshot { + return { + ...SNAPSHOT, + schemaVersion: 2, + attacker: { + ...SNAPSHOT.attacker, + equipment: [ + { slot: 0, itemType: 1n, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + { slot: 1, itemType: 11n, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, + ], + }, + }; +} + export const SNAPSHOT: BattleSnapshot = { domain: DOMAIN, attacker: { @@ -125,6 +165,13 @@ export function signWithTestKey(digest: Hex): Hex { export interface ReceiptOverrides { battleId?: string; + /** + * Fights a different snapshot. The seed binds `snapshotHash`, so this re-derives it + * rather than patching afterwards, for the same reason `rulesetHash` does. + */ + snapshot?: BattleSnapshot; + /** The ruleset the fight runs under. Defaults to the source-default one. */ + ruleset?: Ruleset; sequence?: number; previousReceiptHash?: Hex | null; attackerPreviousReceiptHash?: Hex | null; @@ -154,25 +201,29 @@ export interface ReceiptOverrides { export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { const battleId = overrides.battleId ?? 'btl_0001'; const beacon = overrides.beacon ?? BEACON; - const rulesetHash = overrides.rulesetHash ?? RULESET_HASH; + const snapshot = overrides.snapshot ?? SNAPSHOT; + const ruleset = overrides.ruleset ?? SOURCE_DEFAULT_RULESET; + const rulesetHash = overrides.rulesetHash ?? hashRuleset(ruleset); const seed = deriveBattleSeed({ domain: DOMAIN, drandRandomness: beacon.randomness, battleId, - snapshotHash: hashBattleSnapshot(SNAPSHOT), + snapshotHash: hashBattleSnapshot(snapshot), rulesetHash, }); const outcome = simulate( - SNAPSHOT.attacker.dna, - SNAPSHOT.attacker.rarity, - SNAPSHOT.attacker.level, - SNAPSHOT.attacker.skill, - SNAPSHOT.defender.dna, - SNAPSHOT.defender.rarity, - SNAPSHOT.defender.level, - SNAPSHOT.defender.skill, + snapshot.attacker.dna, + snapshot.attacker.rarity, + snapshot.attacker.level, + snapshot.attacker.skill, + snapshot.defender.dna, + snapshot.defender.rarity, + snapshot.defender.level, + snapshot.defender.skill, seed.value, - SOURCE_DEFAULT_RULESET.skillConfig, + ruleset.skillConfig, + equipmentBonus(snapshot.attacker.equipment), + equipmentBonus(snapshot.defender.equipment), ); return { domain: DOMAIN, @@ -180,10 +231,10 @@ export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { intentHash: `0x${'11'.repeat(32)}`, commitmentHash: `0x${'22'.repeat(32)}`, defenseAuthorizationHash: `0x${'33'.repeat(32)}`, - snapshot: SNAPSHOT, + snapshot, beacon, seed: seed.hex, - rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetVersion: ruleset.version, rulesetHash, result: { attackerWon: outcome.result.firstWins, @@ -191,7 +242,7 @@ export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { winnerHpRemaining: outcome.result.winnerHpRemaining, }, combatLogHash: hashCombatLog(outcome), - progression: computeProgression(SNAPSHOT, outcome.result.firstWins), + progression: computeProgression(snapshot, outcome.result.firstWins), sequence: overrides.sequence ?? 1, previousReceiptHash: overrides.previousReceiptHash ?? null, attackerPreviousReceiptHash: overrides.attackerPreviousReceiptHash ?? null, diff --git a/verifier/tests/verify.test.ts b/verifier/tests/verify.test.ts index c3aca55e..fba4b0a8 100644 --- a/verifier/tests/verify.test.ts +++ b/verifier/tests/verify.test.ts @@ -11,6 +11,7 @@ const SINGLE_RECEIPT_CHECKS = [ 'operator-signature', 'beacon-signature', 'combat-replay', + 'equipment', 'progression', 'chain-continuity', ]; From 75513c134a50a32960070b8bf58f517e6850e76c Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 8 Aug 2026 02:00:28 -0400 Subject: [PATCH 23/56] docs: record what the inventory feature actually shipped --- AGENTS.md | 7 ++- CLAUDE.md | 59 +++++++++++++++++-- docs/plan-future-features-roadmap.md | 26 +++++++++ docs/plan-inventory-items.md | 87 ++++++++++++++++++---------- 4 files changed, 139 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0dd0c405..e7d40832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are e ## Non-Negotiables - `MUST NOT` change Solana's frozen combat port (`game/battle_sim.rs`, `game/xp.rs`). It has no caller left in the program, but its golden-vector tests are what still prove `contracts/test-vectors/{battle,xp}.json` describe what actually settled on that chain. A bug found there is fixed forward in the live ports below, under a new `rulesetVersion`, never by patching the frozen one. **The Solidity port is gone**: `CombatSim.sol` was deleted once it had no on-chain caller, which also removed `battle.json`'s Solidity generator and validator. `battle.json` itself is unchanged and still gates the live ports. -- `MUST` keep the two **live** combat ports in step with each other and with the golden vectors: `protocol/src/combat/` (the canonical engine, re-exported from `shared/src/utils/combat` for existing importers) and `services/indexer-go/internal/combat/` (the independent verifier). Changing one without the other re-breaks the circuit breaker in §F, whose whole value is that the two were written to disagree if either drifts. This covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is a both-ports change. `services/indexer-go/internal/combat/xp.go` still covers the formula and the decay but not level-up. -- `MUST NOT` edit `contracts/test-vectors/{battle,xp}.json` to make a failing test pass — this holds more strongly now, not less. The vectors are the only mechanical link left between the frozen ports and the live ones. A live port that fails them has drifted away from the rules real battles were settled under. +- `MUST` keep the two **live** combat ports in step with each other and with the golden vectors: `protocol/src/combat/` (the canonical engine, re-exported from `shared/src/utils/combat` for existing importers) and `services/indexer-go/internal/combat/` (the independent verifier). Changing one without the other re-breaks the circuit breaker in §F, whose whole value is that the two were written to disagree if either drifts. This covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is a both-ports change. `services/indexer-go/internal/combat/xp.go` still covers the formula and the decay but not level-up. It also covers **equipment modifiers** (roadmap §4): `protocol/src/combat/equipment.ts` and `services/indexer-go/internal/combat/equipment.go`, both validated against `contracts/test-vectors/equipment.json`. The modifiers apply at one specific point — after `extract`, before the skill multipliers — and moving that point in one port without the other changes every geared fight, so the ordering is pinned by a vector case in both. +- `MUST NOT` edit `contracts/test-vectors/{battle,xp,equipment}.json` to make a failing test pass — this holds more strongly now, not less. `battle.json` and `xp.json` are the only mechanical link left between the frozen ports and the live ones, and a live port that fails them has drifted away from the rules real battles were settled under. `equipment.json` is newer and has no frozen witness, but the same rule applies for the same reason: it is what holds the two live ports to one another. Its first case deliberately reproduces a `battle.json` row, so an ungeared fight is proven unchanged rather than assumed. +- `MUST` treat a `snapshot` or `ruleset` schema-version bump as append-only. An absent version means **1**, never "whatever this build implements": every snapshot and published bundle written before those fields existed is version 1 and has receipts signed over it, so defaulting to the current version silently re-encodes them under a layout they were never hashed under and invalidates every signature. Old versions stay listed in `SUPPORTED_VERSIONS` permanently (`protocol/src/domain/schemaVersions.ts`). - `MUST NOT` assume the `ChainAdapter` interface (`shared/src/hooks/adapters/`) covers more than pet-action mutations and reads. It is a real, shared interface (`useEvmAdapter`/`useSolanaAdapter` both implement it) and every public pet-action hook consumes it for the mutation, but the low-level chain wiring in `frontend/src/chains/{ethereum,solana}/`, the async breed/mint randomness flows, and the combat simulator remain intentionally separate per chain. `useCreatePet` and `useBreedPets` are only chain-blind on the action: both carry the EVM settle lifecycle inline behind `isEvm` guards. See CLAUDE.md's cross-chain interfaces section for the exact boundary. - `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `services/indexer-go`, `proto`, `protocol`, and `verifier` are MIT; everything else, `services/image-generator` included, is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. `protocol` is MIT on purpose (third parties have to be able to replay signed battle receipts), so it `MUST NOT` import from a PolyForm package; a test in that package enforces it. `verifier` is MIT for the same reason and depends on nothing but `protocol`. - `MUST NOT` assume the root `pnpm lint` / `pnpm test` cover `image-generator`, and `MUST NOT` verify it with `pnpm --filter image-generator