diff --git a/packages/apps/assembly/CHANGELOG.md b/packages/apps/assembly/CHANGELOG.md index 111e728..54117ca 100644 --- a/packages/apps/assembly/CHANGELOG.md +++ b/packages/apps/assembly/CHANGELOG.md @@ -1,5 +1,13 @@ # @eveworld/assembly +## 0.3.3 + +### Patch Changes + +- Updated dependencies + - @evefrontier/dapp-kit@0.1.11 + - @eveworld/ui-components@0.2.1 + ## 0.3.2 ### Patch Changes diff --git a/packages/apps/assembly/package.json b/packages/apps/assembly/package.json index 0b38a36..f08c774 100644 --- a/packages/apps/assembly/package.json +++ b/packages/apps/assembly/package.json @@ -1,7 +1,7 @@ { "name": "@eveworld/assembly", "private": true, - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/libs/dapp-kit/CHANGELOG.md b/packages/libs/dapp-kit/CHANGELOG.md index c537067..2387e34 100644 --- a/packages/libs/dapp-kit/CHANGELOG.md +++ b/packages/libs/dapp-kit/CHANGELOG.md @@ -1,5 +1,11 @@ # @evefrontier/dapp-kit +## 0.1.11 + +### Patch Changes + +- use optimistic updates for inventory + ## 0.1.10 ### Patch Changes diff --git a/packages/libs/dapp-kit/config/dapp-kit.ts b/packages/libs/dapp-kit/config/dapp-kit.ts index c7f9658..8a7f4a9 100644 --- a/packages/libs/dapp-kit/config/dapp-kit.ts +++ b/packages/libs/dapp-kit/config/dapp-kit.ts @@ -1,13 +1,10 @@ import { createDAppKit } from '@mysten/dapp-kit-react' import { SuiGrpcClient } from '@mysten/sui/grpc' -const GRPC_URLS = { - testnet: 'https://fullnode.testnet.sui.io:443', - devnet: 'https://fullnode.devnet.sui.io:443', -} +import { SUI_GRPC_URLS } from '../utils/constants' -type SupportedNetwork = keyof typeof GRPC_URLS -const SUPPORTED_NETWORKS = Object.keys(GRPC_URLS) as SupportedNetwork[] +type SupportedNetwork = keyof typeof SUI_GRPC_URLS +const SUPPORTED_NETWORKS = Object.keys(SUI_GRPC_URLS) as SupportedNetwork[] /** DApp Kit instance for Sui wallet and network. @category Config */ export const dAppKit = createDAppKit({ @@ -15,7 +12,7 @@ export const dAppKit = createDAppKit({ createClient(network) { return new SuiGrpcClient({ network, - baseUrl: GRPC_URLS[network as keyof typeof GRPC_URLS], + baseUrl: SUI_GRPC_URLS[network as keyof typeof SUI_GRPC_URLS], }) }, }) diff --git a/packages/libs/dapp-kit/package.json b/packages/libs/dapp-kit/package.json index 203a6bc..6e32e27 100644 --- a/packages/libs/dapp-kit/package.json +++ b/packages/libs/dapp-kit/package.json @@ -1,6 +1,6 @@ { "name": "@evefrontier/dapp-kit", - "version": "0.1.10", + "version": "0.1.11", "description": "React SDK for EVE Frontier dApps on Sui", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx index 814daa4..8646f38 100644 --- a/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx +++ b/packages/libs/dapp-kit/providers/SmartObjectProvider.tsx @@ -1,3 +1,4 @@ +import { TENANT_CONFIG, TenantId } from '@evefrontier/wallet-core/tenant' import type { ReactNode } from 'react' import { createContext, useCallback, useEffect, useRef, useState } from 'react' import { getAssemblyWithOwner, type MoveObjectData } from '../graphql' @@ -11,15 +12,48 @@ import { } from '../types' import { createLogger, + getEveWorldPackageId, getObjectId, transformToAssembly, transformToCharacter, } from '../utils' import { DEFAULT_TENANT, POLLING_INTERVAL } from '../utils/constants' import { getDatahubGameInfo } from '../utils/datahub' +import type { EventUnsubscribe } from '../utils/events/checkpointStream' +import { + createEventRefetchScheduler, + subscribeToInventoryEvents, +} from '../utils/events/eventRefresh' +import { + applyFuelEventToAssembly, + getFuelEventTarget, + getFuelEventType, + isRelevantFuelEvent, +} from '../utils/events/fuelEventHandlers' +import { + applyInventoryEventToAssembly, + getInventoryEventTarget, + getInventoryEventTypes, + isRelevantAssemblyInventoryEvent, +} from '../utils/events/inventoryEventHandlers' +import { + getInventoryTypeVolumeM3, + mergeSmartStorageInventoryFromRefetch, + setInventoryTypeVolumeM3, +} from '../utils/inventory' const log = createLogger() +function getStreamEventTypes( + tenant: string, + getTypes: (packageId: string) => string[], +): string[] { + const packageIds = new Set([getEveWorldPackageId()]) + const tenantPackageId = TENANT_CONFIG[tenant as TenantId]?.packageId + if (tenantPackageId) packageIds.add(tenantPackageId) + return [...packageIds].flatMap(getTypes) +} + /** Input for fetching object data: either itemId + tenant (derive object ID) or a Sui object ID directly. * @category Types */ @@ -37,6 +71,33 @@ export const SmartObjectContext = createContext({ refetch: async () => {}, }) +function primeInventoryTypeVolumes(assembly: AssemblyType | null) { + if (!assembly || assembly.type !== Assemblies.SmartStorageUnit) return + + const typeIds = new Set() + for (const item of assembly.storage.mainInventory.items ?? []) { + const typeId = Number(item.type_id) + if (Number.isFinite(typeId)) typeIds.add(typeId) + } + for (const ephemeral of assembly.storage.ephemeralInventories ?? []) { + for (const item of ephemeral.ephemeralInventoryItems ?? []) { + const typeId = Number(item.type_id) + if (Number.isFinite(typeId)) typeIds.add(typeId) + } + } + + for (const typeId of typeIds) { + // getDatahubGameInfo is uncached and this runs on every poll/refetch; + // skip type volumes already in the cache. + if (getInventoryTypeVolumeM3(typeId) !== undefined) continue + void getDatahubGameInfo(typeId) + .then((info) => { + setInventoryTypeVolumeM3(typeId, info.volume) + }) + .catch(() => {}) + } +} + /** * SmartObjectProvider component provides context for smart objects data. * It uses GraphQL queries to fetch objects on the Sui blockchain @@ -63,6 +124,11 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { const pollingRef = useRef | null>(null) const lastDataHashRef = useRef(null) + const lastConfirmedInventorySignatureRef = useRef(null) + const lastConfirmedFuelSignatureRef = useRef(null) + const assemblyRef = useRef | null>(null) + + assemblyRef.current = assembly const { isConnected } = useConnection() @@ -152,7 +218,50 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { }, ) - setAssembly(transformed) + primeInventoryTypeVolumes(transformed) + + setAssembly((currentAssembly) => { + if (transformed?.type === Assemblies.NetworkNode) { + const { quantity, isBurning } = transformed.networkNode.fuel + const nextSignature = `${quantity}:${isBurning}` + + // On a refetch, if the indexer hasn't caught up yet it returns the + // same quantity+isBurning we already confirmed — keep the optimistic + // state in that case rather than reverting it. + if ( + !isInitialFetch && + currentAssembly?.type === Assemblies.NetworkNode && + lastConfirmedFuelSignatureRef.current !== null && + nextSignature === lastConfirmedFuelSignatureRef.current + ) { + return currentAssembly + } + + lastConfirmedFuelSignatureRef.current = nextSignature + return transformed + } + + if (transformed?.type !== Assemblies.SmartStorageUnit) { + return transformed + } + + // Merge against the prior storage state only on refetches; an + // initial fetch (or a type change) has no optimistic state to keep. + const previousStorage = + !isInitialFetch && + currentAssembly?.type === Assemblies.SmartStorageUnit + ? currentAssembly + : null + + const { assembly, inventorySignature } = + mergeSmartStorageInventoryFromRefetch( + previousStorage, + transformed, + lastConfirmedInventorySignatureRef.current, + ) + lastConfirmedInventorySignatureRef.current = inventorySignature + return assembly + }) // Transform and set assemblyOwner: owner of the assembly if (characterInfo) { @@ -242,6 +351,141 @@ const SmartObjectProvider = ({ children }: { children: ReactNode }) => { log.info('[DappKit] SmartObjectProvider: Stopped polling') } lastDataHashRef.current = null + lastConfirmedInventorySignatureRef.current = null + lastConfirmedFuelSignatureRef.current = null + } + }, [ + selectedObjectId, + selectedTenant, + isObjectIdDirect, + isConnected, + fetchObjectData, + ]) + + // Listen for inventory events via gRPC checkpoint stream. GraphQL polling above + // remains the fallback/source of truth if stream events are missed or unavailable. + useEffect(() => { + if (!selectedObjectId || !isConnected) return + + const input: FetchObjectDataInput = isObjectIdDirect + ? { objectId: selectedObjectId } + : { itemId: selectedObjectId, selectedTenant } + const inventoryEventTypes = getStreamEventTypes( + selectedTenant, + getInventoryEventTypes, + ) + const fuelEventTypes = getStreamEventTypes(selectedTenant, (pkg) => [ + getFuelEventType(pkg), + ]) + const allEventTypes = [...inventoryEventTypes, ...fuelEventTypes] + const abortController = new AbortController() + const triggerRefetch = createEventRefetchScheduler( + () => fetchObjectData(input, false), + undefined, + (error) => { + log.warn( + '[DappKit] SmartObjectProvider: Event-triggered refetch failed:', + error, + ) + }, + ) + let unsubscribe: EventUnsubscribe | null = null + + log.debug( + '[DappKit] SmartObjectProvider: Starting assembly checkpoint stream', + { allEventTypes, selectedObjectId, selectedTenant }, + ) + + subscribeToInventoryEvents({ + eventTypes: allEventTypes, + signal: abortController.signal, + onGap: () => { + triggerRefetch() + }, + onEvents: (events) => { + const inventoryEventTarget = getInventoryEventTarget({ + assembly: assemblyRef.current, + eventTypes: inventoryEventTypes, + isObjectIdDirect, + selectedObjectId, + selectedTenant, + }) + const fuelEventTarget = getFuelEventTarget( + assemblyRef.current, + fuelEventTypes, + ) + + const relevantInventoryEvents = events.filter((event) => + isRelevantAssemblyInventoryEvent(event, inventoryEventTarget), + ) + const relevantFuelEvents = fuelEventTarget + ? events.filter((event) => + isRelevantFuelEvent(event, fuelEventTarget), + ) + : [] + + if ( + events.length > 0 && + relevantInventoryEvents.length === 0 && + relevantFuelEvents.length === 0 + ) { + log.debug( + '[DappKit] SmartObjectProvider: Ignoring stream events for other assemblies', + { count: events.length, inventoryEventTarget, fuelEventTarget }, + ) + } + + if ( + relevantInventoryEvents.length > 0 || + relevantFuelEvents.length > 0 + ) { + log.info( + '[DappKit] SmartObjectProvider: Applying assembly stream events', + { + inventoryCount: relevantInventoryEvents.length, + fuelCount: relevantFuelEvents.length, + }, + ) + setAssembly((currentAssembly) => { + let next: AssemblyType | null = currentAssembly + next = relevantInventoryEvents.reduce( + (asm, event) => applyInventoryEventToAssembly(asm, event), + next, + ) + next = relevantFuelEvents.reduce( + (asm, event) => applyFuelEventToAssembly(asm, event), + next, + ) + return next + }) + triggerRefetch() + } + }, + }) + .then((eventUnsubscribe) => { + if (abortController.signal.aborted) { + void eventUnsubscribe() + return + } + unsubscribe = eventUnsubscribe + log.debug( + '[DappKit] SmartObjectProvider: Inventory checkpoint stream active', + ) + }) + .catch((error) => { + if (abortController.signal.aborted) return + log.warn( + '[DappKit] SmartObjectProvider: Inventory checkpoint stream unavailable; polling remains active:', + error, + ) + }) + + return () => { + abortController.abort() + triggerRefetch.cancel() + if (unsubscribe) { + void unsubscribe() + } } }, [ selectedObjectId, diff --git a/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts new file mode 100644 index 0000000..21b1c58 --- /dev/null +++ b/packages/libs/dapp-kit/utils/__tests__/inventory.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import type { InventoryItem } from '../../types' +import { Assemblies, type AssemblyType } from '../../types' +import { + adjustInventoryUsedCapacity, + areInventoryItemListsEqual, + clearInventoryTypeVolumeM3Cache, + mergeSmartStorageInventoryFromRefetch, + setInventoryTypeVolumeM3, +} from '../inventory' + +function createInventoryItem(typeId: number, quantity: number): InventoryItem { + return { + id: `item-${typeId}`, + item_id: `item-${typeId}`, + location: { location_hash: 'main' }, + name: `Item ${typeId}`, + quantity, + tenant: 'stillness', + type_id: typeId, + } +} + +function createStorageAssembly( + quantity = 20, + usedCapacity = '1000', +): AssemblyType { + return { + type: Assemblies.SmartStorageUnit, + storage: { + mainInventory: { + capacity: '1000000', + usedCapacity, + items: [createInventoryItem(77810, quantity)], + }, + ephemeralInventories: [], + }, + } as unknown as AssemblyType +} + +describe('inventory utilities', () => { + beforeEach(() => { + clearInventoryTypeVolumeM3Cache() + }) + + it('adjusts used capacity from cached type volume on mint and burn', () => { + setInventoryTypeVolumeM3(77810, 0.1) + + expect(adjustInventoryUsedCapacity('1000', 10, 77810, 'add')).toBe('2000') + expect(adjustInventoryUsedCapacity('2000', 10, 77810, 'subtract')).toBe( + '1000', + ) + }) + + it('floors used capacity at zero when burning more than is tracked', () => { + setInventoryTypeVolumeM3(77810, 0.1) + + expect(adjustInventoryUsedCapacity('500', 10, 77810, 'subtract')).toBe('0') + }) + + it('leaves used capacity unchanged when type volume is unknown', () => { + expect(adjustInventoryUsedCapacity('1000', 10, 77810, 'add')).toBe('1000') + }) + + it('keeps optimistic inventory when refetch only moved ancillary fields', () => { + const current = createStorageAssembly(520, '2000') + const stale = createStorageAssembly(20, '1500') + const lastConfirmed = mergeSmartStorageInventoryFromRefetch( + null, + stale, + null, + ).inventorySignature + + const merged = mergeSmartStorageInventoryFromRefetch( + current, + stale, + lastConfirmed, + ) + + expect(merged.assembly).toBe(current) + expect(merged.assembly.storage.mainInventory.items).toEqual( + current.storage.mainInventory.items, + ) + expect(merged.assembly.storage.mainInventory.usedCapacity).toBe('2000') + expect(merged.inventorySignature).toBe(lastConfirmed) + }) + + it('accepts refetch inventory when indexer catches up', () => { + const current = createStorageAssembly(520, '2000') + const fresh = createStorageAssembly(520, '2000') + const lastConfirmed = mergeSmartStorageInventoryFromRefetch( + null, + createStorageAssembly(20, '1000'), + null, + ).inventorySignature + + const merged = mergeSmartStorageInventoryFromRefetch( + current, + fresh, + lastConfirmed, + ) + + expect(merged.assembly.storage.mainInventory.items).toEqual( + fresh.storage.mainInventory.items, + ) + expect(merged.inventorySignature).not.toBe(lastConfirmed) + }) + + it('treats inventory lists as equal when type quantities match in different orders', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20), createInventoryItem(82128, 500)], + [createInventoryItem(82128, 500), createInventoryItem(77810, 20)], + ), + ).toBe(true) + }) + + it('treats duplicate item rows as equal to their merged quantity', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20), createInventoryItem(77810, 30)], + [createInventoryItem(77810, 50)], + ), + ).toBe(true) + }) + + it('detects quantity changes', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20)], + [createInventoryItem(77810, 19)], + ), + ).toBe(false) + }) + + it('detects item type changes', () => { + expect( + areInventoryItemListsEqual( + [createInventoryItem(77810, 20)], + [createInventoryItem(82128, 20)], + ), + ).toBe(false) + }) +}) diff --git a/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts new file mode 100644 index 0000000..a9edd16 --- /dev/null +++ b/packages/libs/dapp-kit/utils/__tests__/inventoryEventBcs.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { + decodeInventoryEventBcs, + inventoryEventBcsToParsedJson, +} from '../inventoryEventBcs' + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16) + } + return bytes +} + +// Real ItemMintedEvent BCS bytes captured from testnet. Mint and burn share the +// InventoryMoveEvent struct, so this vector exercises the full decode path +// without a live RPC call. +const INVENTORY_MOVE_EVENT_BCS_HEX = + '34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b7a2dc1d4e8000000097374696c6c6e657373a60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c781bee37d00000000097374696c6c6e6573730000000000000000f22f010000000000f4010000' + +describe('inventoryEventBcs', () => { + it('decodes an inventory move event from chain BCS bytes', () => { + const decoded = decodeInventoryEventBcs( + hexToBytes(INVENTORY_MOVE_EVENT_BCS_HEX), + ) + + expect(inventoryEventBcsToParsedJson(decoded)).toEqual({ + assembly_id: + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b', + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + character_id: + '0xa60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c7', + character_key: { + item_id: '2112077441', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '77810', + }) + }) +}) diff --git a/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts b/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts index 307d366..892df8d 100644 --- a/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/mapping.test.ts @@ -2,7 +2,6 @@ import { bcs } from '@mysten/sui/bcs' import { deriveObjectID } from '@mysten/sui/utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Assemblies, State } from '../../types' -import { TENANT_CONFIG, TenantId } from '../constants' import { getAssemblyType, parseStatus } from '../mapping' // Mock env vars for testing @@ -14,6 +13,7 @@ vi.mock('../../graphql/client', () => ({ getSingletonObjectByType: vi.fn(), })) +import { TENANT_CONFIG, TenantId } from '@evefrontier/wallet-core/tenant' import { getSingletonObjectByType } from '../../graphql/client' describe('mapping utilities', () => { diff --git a/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts b/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts index 0dc61cb..801d294 100644 --- a/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts +++ b/packages/libs/dapp-kit/utils/__tests__/transforms.test.ts @@ -19,7 +19,12 @@ import type { DynamicFieldNode, MoveObjectData, } from '../../graphql/types' -import { Assemblies, type DatahubGameInfo, State } from '../../types' +import { + Assemblies, + type DatahubGameInfo, + type InventoryItem, + State, +} from '../../types' import { getEnergyConfig, getEnergyUsageForType } from '../config' import { getDatahubGameInfo } from '../datahub' import { transformToAssembly, transformToCharacter } from '../transforms' @@ -202,6 +207,60 @@ describe('transformToAssembly — SmartStorageUnit', () => { expect(result?.storage.mainInventory.capacity).toBeUndefined() expect(result?.storage.mainInventory.usedCapacity).toBeUndefined() }) + + it('orders storage inventory items by quantity from GraphQL data', async () => { + function createInventoryItem( + typeId: number, + quantity: number, + ): InventoryItem { + return { + id: `item-${typeId}`, + item_id: `item-${typeId}`, + location: { location_hash: 'main' }, + name: `Item ${typeId}`, + quantity, + tenant: 'stillness', + type_id: typeId, + } + } + + const inventoryKey = 'main-inventory' + const inventoryDynField: DynamicFieldNode = { + name: { json: inventoryKey, type: { repr: '0x::String' } }, + contents: { + json: { + key: inventoryKey, + value: { + max_capacity: '1000000', + used_capacity: '1000', + items: { + contents: [ + { key: 'low', value: createInventoryItem(77810, 20) }, + { key: 'high', value: createInventoryItem(82128, 500) }, + { key: 'mid', value: createInventoryItem(88082, 50) }, + ], + }, + }, + }, + type: { layout: '' }, + }, + } + + const moveObj = makeMoveObject( + makeRawJson({ inventory_keys: [inventoryKey] }), + SSU_TYPE, + [inventoryDynField], + ) + + const result = (await transformToAssembly('0x1', moveObj)) as Extract< + Awaited>, + { type: Assemblies.SmartStorageUnit } + > + + expect( + result?.storage.mainInventory.items.map((item) => item.type_id), + ).toEqual([82128, 88082, 77810]) + }) }) // ============================================================================ diff --git a/packages/libs/dapp-kit/utils/constants.ts b/packages/libs/dapp-kit/utils/constants.ts index 44ebed3..daa0ae1 100644 --- a/packages/libs/dapp-kit/utils/constants.ts +++ b/packages/libs/dapp-kit/utils/constants.ts @@ -12,7 +12,6 @@ export { export { DEFAULT_TENANT, EVE_PACKAGE_ID_BY_TENANT, - TENANT_CONFIG, TenantId, } from '@evefrontier/wallet-core/tenant' @@ -110,6 +109,27 @@ export const GRAPHQL_ENDPOINTS: Record = { mainnet: 'https://graphql.mainnet.sui.io/graphql', } +/** gRPC base URLs for each Sui network. + * @category Constants + */ +export const SUI_GRPC_URLS: Record = { + testnet: 'https://fullnode.testnet.sui.io:443', + devnet: 'https://fullnode.devnet.sui.io:443', + mainnet: 'https://fullnode.mainnet.sui.io:443', +} + +/** + * Get the Sui gRPC base URL for the given network. + * Unknown values fall back to testnet. + * @category Utilities + */ +export function getSuiGrpcBaseUrl( + env: string = DEFAULT_GRAPHQL_NETWORK, +): string { + const network = isSuiGraphqlNetwork(env) ? env : DEFAULT_GRAPHQL_NETWORK + return SUI_GRPC_URLS[network] +} + /** Polling interval in milliseconds (10 seconds). * @category Constants */ diff --git a/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts b/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts new file mode 100644 index 0000000..c15b1f8 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/__tests__/eventRefresh.test.ts @@ -0,0 +1,1151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Assemblies, type AssemblyType } from '../../../types' +import { + clearInventoryTypeVolumeM3Cache, + setInventoryTypeVolumeM3, +} from '../../inventory' +import { + type CheckpointStreamMessage, + createInventoryCheckpointStream, + extractInventoryEventsFromCheckpoint, +} from '../checkpointStream' +import { createEventRefetchScheduler } from '../eventRefresh' +import { + applyInventoryEventToAssembly, + getInventoryEventTarget, + getInventoryEventTypes, + isRelevantAssemblyInventoryEvent, +} from '../inventoryEventHandlers' + +const PACKAGE_ID = + '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' +const ASSEMBLY_OBJECT_ID = + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b' + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16) + } + return bytes +} + +function createStorageAssembly( + quantity = 20, +): AssemblyType { + return { + type: Assemblies.SmartStorageUnit, + storage: { + mainInventory: { + capacity: '1000000', + usedCapacity: '1000', + items: [ + { + id: 'existing-item', + item_id: 'existing-item', + location: { location_hash: 'main' }, + quantity, + tenant: 'stillness', + type_id: 77810, + name: 'Existing Item', + }, + ], + }, + ephemeralInventories: [], + }, + } as unknown as AssemblyType +} + +function expectStorageAssembly( + assembly: AssemblyType | null, +): AssemblyType { + expect(assembly?.type).toBe(Assemblies.SmartStorageUnit) + return assembly as AssemblyType +} + +function createUnsortedStorageAssembly(): AssemblyType { + const assembly = createStorageAssembly() + assembly.storage.mainInventory.items = [ + { + id: 'type-88082', + item_id: 'type-88082', + location: { location_hash: 'main' }, + quantity: 10, + tenant: 'stillness', + type_id: 88082, + name: 'High Type', + }, + { + id: 'type-77810', + item_id: 'type-77810', + location: { location_hash: 'main' }, + quantity: 20, + tenant: 'stillness', + type_id: 77810, + name: 'Low Type', + }, + ] + return assembly +} + +describe('event refresh helpers', () => { + beforeEach(() => { + clearInventoryTypeVolumeM3Cache() + }) + + it('subscribes to inventory burn and mint events', () => { + expect(getInventoryEventTypes(PACKAGE_ID)).toEqual([ + `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + `${PACKAGE_ID}::inventory::ItemMintedEvent`, + ]) + }) + + it('matches inventory events for the selected Sui assembly object id', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + objectId: ASSEMBLY_OBJECT_ID, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(true) + }) + + it('matches inventory events for the selected itemId and tenant', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + itemId: '1000001842554', + tenant: 'stillness', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(true) + }) + + it('prefers the loaded assembly object id when building event targets', () => { + const assembly = createStorageAssembly() + assembly.id = ASSEMBLY_OBJECT_ID + assembly.item_id = 1000001842554 + + expect( + getInventoryEventTarget({ + assembly, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: false, + selectedObjectId: 'wrong-item-id', + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + objectId: ASSEMBLY_OBJECT_ID, + }) + }) + + it('falls back to the assembly item id and tenant when no object id is loaded', () => { + const assembly = createStorageAssembly() + assembly.item_id = 1000001842554 + + expect( + getInventoryEventTarget({ + assembly, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: false, + selectedObjectId: 'selected-item-id', + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + itemId: '1000001842554', + tenant: 'stillness', + }) + }) + + it('targets the direct object id when no assembly is loaded yet', () => { + expect( + getInventoryEventTarget({ + assembly: null, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: true, + selectedObjectId: ASSEMBLY_OBJECT_ID, + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + objectId: ASSEMBLY_OBJECT_ID, + }) + }) + + it('targets the selected item id and tenant when no assembly is loaded yet', () => { + expect( + getInventoryEventTarget({ + assembly: null, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + isObjectIdDirect: false, + selectedObjectId: '1000001842554', + selectedTenant: 'stillness', + }), + ).toEqual({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + itemId: '1000001842554', + tenant: 'stillness', + }) + }) + + it('matches minted inventory events for deposits', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + character_id: + '0xa60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c7', + character_key: { + item_id: '2112077441', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '77810', + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + itemId: '1000001842554', + tenant: 'stillness', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(true) + }) + + it('ignores inventory events for other assemblies or tenants', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + itemId: '1000001842554', + tenant: 'nebula', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(false) + expect( + isRelevantAssemblyInventoryEvent(event, { + objectId: '0x111', + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(false) + }) + + it('ignores non-inventory event types', () => { + const event = { + type: `${PACKAGE_ID}::storage_unit::SomeOtherEvent`, + parsedJson: { + assembly_id: ASSEMBLY_OBJECT_ID, + }, + } + + expect( + isRelevantAssemblyInventoryEvent(event, { + objectId: ASSEMBLY_OBJECT_ID, + eventTypes: getInventoryEventTypes(PACKAGE_ID), + }), + ).toBe(false) + }) + + it('optimistically adds minted item quantities by type id', () => { + const assembly = createStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 500, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([ + expect.objectContaining({ type_id: 77810, quantity: 520 }), + ]) + expect(assembly.storage.mainInventory.items[0]?.quantity).toBe(20) + }) + + it('optimistically updates used capacity when type volume is cached', () => { + setInventoryTypeVolumeM3(77810, 0.1) + const assembly = createStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 10, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.usedCapacity).toBe('2000') + }) + + it('merges duplicate rows when minting an existing item type', () => { + const assembly = createStorageAssembly() + assembly.storage.mainInventory.items = [ + { + id: 'existing-item', + item_id: 'existing-item', + location: { location_hash: 'main' }, + quantity: 20, + tenant: 'stillness', + type_id: 77810, + name: 'Existing Item', + }, + { + id: 'optimistic-77810', + item_id: '0', + location: { location_hash: '' }, + quantity: 500, + tenant: 'stillness', + type_id: 77810, + name: 'Type 77810', + }, + ] + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 10, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toHaveLength(1) + expect(updated.storage.mainInventory.items[0]).toEqual( + expect.objectContaining({ + id: 'existing-item', + name: 'Existing Item', + quantity: 530, + type_id: 77810, + }), + ) + }) + + it('optimistically subtracts burned item quantities by type id', () => { + const assembly = createStorageAssembly(20) + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + quantity: 5, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([ + expect.objectContaining({ type_id: 77810, quantity: 15 }), + ]) + }) + + it('optimistically removes burned item rows when quantity reaches zero', () => { + const assembly = createStorageAssembly(20) + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + quantity: 20, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([]) + }) + + it('optimistically removes burned item rows when GraphQL item ids are strings', () => { + const assembly = createStorageAssembly(20) + assembly.storage.mainInventory.items = [ + { + id: 'string-item', + item_id: 'string-item', + location: { location_hash: 'main' }, + quantity: '20', + tenant: 'stillness', + type_id: '77810', + name: 'String Item', + }, + ] as unknown as typeof assembly.storage.mainInventory.items + const event = { + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + parsedJson: { + quantity: 20, + type_id: '77810', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([]) + }) + + it('optimistically adds a placeholder row for a new minted item type', () => { + const assembly = createStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '88082', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect(updated.storage.mainInventory.items).toEqual([ + expect.objectContaining({ + item_id: '0', + quantity: 500, + tenant: 'stillness', + type_id: 88082, + name: 'Type 88082', + }), + expect.objectContaining({ type_id: 77810, quantity: 20 }), + ]) + }) + + it('orders optimistically updated inventory rows by quantity', () => { + const assembly = createUnsortedStorageAssembly() + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '82128', + }, + } + + const updated = expectStorageAssembly( + applyInventoryEventToAssembly(assembly, event), + ) + + expect( + updated.storage.mainInventory.items.map((item) => item.type_id), + ).toEqual([82128, 77810, 88082]) + }) + + it('schedules multiple event-driven refetch attempts', async () => { + vi.useFakeTimers() + const refetch = vi.fn().mockResolvedValue(undefined) + const scheduledRefetch = createEventRefetchScheduler(refetch, [100, 300]) + + scheduledRefetch() + + await vi.advanceTimersByTimeAsync(99) + expect(refetch).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(refetch).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(200) + expect(refetch).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it('reschedules pending event-driven refetch attempts for event bursts', async () => { + vi.useFakeTimers() + const refetch = vi.fn().mockResolvedValue(undefined) + const scheduledRefetch = createEventRefetchScheduler(refetch, [100, 300]) + + scheduledRefetch() + await vi.advanceTimersByTimeAsync(50) + scheduledRefetch() + + await vi.advanceTimersByTimeAsync(99) + expect(refetch).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(refetch).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(200) + expect(refetch).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it('streams inventory events without replaying the first checkpoint on startup', async () => { + const historicalCheckpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 1, + transactions: [ + { + digest: 'old', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + const liveCheckpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'new', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + const onEvents = vi.fn() + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + function subscribeCheckpoints() { + return { + responses: (async function* () { + yield historicalCheckpoint + yield liveCheckpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + cancel, + } + } + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + onEvents, + subscribeCheckpoints, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onEvents).toHaveBeenCalledTimes(1) + expect(onEvents).toHaveBeenCalledWith([ + expect.objectContaining({ + id: { txDigest: 'new', eventSeq: '0' }, + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + }), + ]) + + await stop() + }) + + it('delivers inventory events from a checkpoint as one batch', async () => { + const checkpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'new', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + { + eventType: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + { + eventType: `${PACKAGE_ID}::storage_unit::SomeOtherEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + const onEvents = vi.fn() + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + function subscribeCheckpoints() { + return { + responses: (async function* () { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + yield checkpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + cancel, + } + } + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + onEvents, + subscribeCheckpoints, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onEvents).toHaveBeenCalledTimes(1) + expect(onEvents).toHaveBeenCalledWith([ + expect.objectContaining({ + type: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + }), + expect.objectContaining({ + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + }), + ]) + + await stop() + }) + + it('reconnects after stream errors', async () => { + vi.useFakeTimers() + const onError = vi.fn() + const onEvents = vi.fn() + let session = 0 + + let releaseStream: (() => void) | undefined + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + reconnectMs: 100, + idleMs: 0, + maxSessionMs: 0, + onError, + onEvents, + subscribeCheckpoints: () => { + session += 1 + if (session === 1) { + return { + cancel: vi.fn(), + responses: (async function* () { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + throw new Error('network error') + })(), + } + } + + return { + cancel: vi.fn(() => { + releaseStream?.() + }), + responses: (async function* () { + yield { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'reconnected', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + expect(onError).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(onEvents).toHaveBeenCalledTimes(1) + + await stop() + vi.useRealTimers() + }) + + it('rotates sessions before the public fullnode stream timeout', async () => { + vi.useFakeTimers() + let session = 0 + let releaseStream: (() => void) | undefined + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + maxSessionMs: 100, + reconnectMs: 50, + idleMs: 0, + subscribeCheckpoints: () => { + session += 1 + return { + cancel: vi.fn(), + responses: (async function* () { + yield { checkpoint: { sequenceNumber: session, transactions: [] } } + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + expect(session).toBe(1) + + await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(50) + expect(session).toBe(2) + + releaseStream?.() + await stop() + vi.useRealTimers() + }) + + it('reconnects when a checkpoint read goes idle', async () => { + vi.useFakeTimers() + const onError = vi.fn() + let session = 0 + let releaseStream: (() => void) | undefined + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 100, + maxSessionMs: 0, + reconnectMs: 50, + onError, + subscribeCheckpoints: () => { + session += 1 + return { + cancel: vi.fn(), + responses: (async function* () { + yield { + checkpoint: { + sequenceNumber: session === 1 ? 1 : 3, + transactions: [], + }, + } + if (session === 1) { + yield { checkpoint: { sequenceNumber: 2, transactions: [] } } + } + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + expect(onError).toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(50) + expect(session).toBe(2) + + releaseStream?.() + await stop() + vi.useRealTimers() + }) + + it('backfills through onGap when checkpoint sequence jumps', async () => { + const onGap = vi.fn() + const onEvents = vi.fn() + const checkpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 5, + transactions: [ + { + digest: 'gap', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + reconnectMs: 0, + onGap, + onEvents, + subscribeCheckpoints: () => ({ + cancel, + responses: (async function* () { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + yield checkpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + }), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onGap).toHaveBeenCalledWith(1, 5) + expect(onEvents).toHaveBeenCalledTimes(1) + + releaseStream?.() + await stop() + }) + + it('deduplicates inventory events across reconnects', async () => { + const onEvents = vi.fn() + const checkpoint: CheckpointStreamMessage = { + checkpoint: { + sequenceNumber: 2, + transactions: [ + { + digest: 'shared', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemBurnedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + assembly_id: { + kind: { + oneofKind: 'stringValue', + stringValue: ASSEMBLY_OBJECT_ID, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + + let releaseStream: (() => void) | undefined + const cancel = vi.fn(() => { + releaseStream?.() + }) + + let session = 0 + const stop = createInventoryCheckpointStream({ + eventTypes: getInventoryEventTypes(PACKAGE_ID), + idleMs: 0, + maxSessionMs: 0, + reconnectMs: 0, + onEvents, + subscribeCheckpoints: () => { + session += 1 + return { + cancel, + responses: (async function* () { + if (session === 1) { + yield { checkpoint: { sequenceNumber: 1, transactions: [] } } + yield checkpoint + return + } + yield { checkpoint: { sequenceNumber: 3, transactions: [] } } + yield checkpoint + await new Promise((resolve) => { + releaseStream = resolve + }) + })(), + } + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onEvents).toHaveBeenCalledTimes(1) + + releaseStream?.() + await stop() + }) + + it('extracts inventory events from checkpoint transactions', () => { + const events = extractInventoryEventsFromCheckpoint( + { + transactions: [ + { + digest: 'abc123', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + json: { + kind: { + oneofKind: 'structValue', + structValue: { + fields: { + quantity: { + kind: { + oneofKind: 'numberValue', + numberValue: 500, + }, + }, + type_id: { + kind: { + oneofKind: 'stringValue', + stringValue: '77810', + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }, + getInventoryEventTypes(PACKAGE_ID), + ) + + expect(events).toEqual([ + { + id: { txDigest: 'abc123', eventSeq: '0' }, + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + quantity: 500, + type_id: '77810', + }, + }, + ]) + }) + + it('extracts inventory events from gRPC BCS bytes when json is absent', () => { + const events = extractInventoryEventsFromCheckpoint( + { + transactions: [ + { + digest: 'abc123', + events: { + events: [ + { + eventType: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + contents: { + value: hexToBytes( + '34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b7a2dc1d4e8000000097374696c6c6e657373a60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c781bee37d00000000097374696c6c6e6573730000000000000000f22f010000000000f4010000', + ), + }, + }, + ], + }, + }, + ], + }, + getInventoryEventTypes(PACKAGE_ID), + ) + + expect(events).toEqual([ + { + id: { txDigest: 'abc123', eventSeq: '0' }, + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { + assembly_id: + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b', + assembly_key: { + item_id: '1000001842554', + tenant: 'stillness', + }, + character_id: + '0xa60609a1b94ffca8ed2daf4963a2b9deffce23de76ef9f3d040d7250edb7b2c7', + character_key: { + item_id: '2112077441', + tenant: 'stillness', + }, + item_id: '0', + quantity: 500, + type_id: '77810', + }, + }, + ]) + }) +}) diff --git a/packages/libs/dapp-kit/utils/events/__tests__/fuelEventHandlers.test.ts b/packages/libs/dapp-kit/utils/events/__tests__/fuelEventHandlers.test.ts new file mode 100644 index 0000000..45797a8 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/__tests__/fuelEventHandlers.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { Assemblies, type AssemblyType } from '../../../types' +import { + applyFuelEventToAssembly, + getFuelEventTarget, + getFuelEventType, + isRelevantFuelEvent, +} from '../fuelEventHandlers' + +const PACKAGE_ID = + '0x28b497559d65ab320d9da4613bf2498d5946b2c0ae3597ccfda3072ce127448c' +const NODE_OBJECT_ID = + '0x34d08b4e1afe6a4babcc0642d6a676160df6b777b49214d5c964b4e874cc951b' +const OTHER_OBJECT_ID = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + +const FUEL_EVENT_TYPE = `${PACKAGE_ID}::fuel::FuelEvent` + +function createNetworkNodeAssembly( + quantity = 10, + isBurning = false, +): AssemblyType { + return { + type: Assemblies.NetworkNode, + id: NODE_OBJECT_ID, + networkNode: { + fuel: { + quantity, + isBurning, + burnTimeInMs: 60_000, + burnStartTime: 0, + lastUpdated: 0, + maxCapacity: 100, + previousCycleElapsedTime: 0, + unitVolume: 1, + typeId: 77810, + }, + energyProduction: 0, + energyMaxCapacity: 0, + totalReservedEnergy: 0, + linkedAssemblies: [], + }, + } as unknown as AssemblyType +} + +function createFuelEvent( + assemblyId: string, + newQuantity: number, + isBurning: boolean, +) { + return { + type: FUEL_EVENT_TYPE, + parsedJson: { + assembly_id: assemblyId, + assembly_key: { item_id: '1', tenant: 'stillness' }, + type_id: '77810', + old_quantity: '10', + new_quantity: String(newQuantity), + is_burning: isBurning, + action: 0, + }, + } +} + +describe('getFuelEventType', () => { + it('returns the correct event type string', () => { + expect(getFuelEventType(PACKAGE_ID)).toBe(`${PACKAGE_ID}::fuel::FuelEvent`) + }) +}) + +describe('getFuelEventTarget', () => { + it('returns target for a NetworkNode assembly', () => { + const assembly = createNetworkNodeAssembly() + const target = getFuelEventTarget(assembly, [FUEL_EVENT_TYPE]) + expect(target).toEqual({ + eventTypes: [FUEL_EVENT_TYPE], + objectId: NODE_OBJECT_ID, + }) + }) + + it('returns null for a non-NetworkNode assembly', () => { + const assembly = { + type: Assemblies.SmartStorageUnit, + id: NODE_OBJECT_ID, + } as unknown as AssemblyType + expect(getFuelEventTarget(assembly, [FUEL_EVENT_TYPE])).toBeNull() + }) + + it('returns null for null assembly', () => { + expect(getFuelEventTarget(null, [FUEL_EVENT_TYPE])).toBeNull() + }) +}) + +describe('isRelevantFuelEvent', () => { + const target = { eventTypes: [FUEL_EVENT_TYPE], objectId: NODE_OBJECT_ID } + + it('matches an event for the correct assembly', () => { + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + expect(isRelevantFuelEvent(event, target)).toBe(true) + }) + + it('rejects an event for a different assembly', () => { + const event = createFuelEvent(OTHER_OBJECT_ID, 5, false) + expect(isRelevantFuelEvent(event, target)).toBe(false) + }) + + it('rejects an event with a different type', () => { + const event = { + type: `${PACKAGE_ID}::inventory::ItemMintedEvent`, + parsedJson: { assembly_id: NODE_OBJECT_ID }, + } + expect(isRelevantFuelEvent(event, target)).toBe(false) + }) + + it('matches case-insensitively on assembly_id', () => { + const event = createFuelEvent(NODE_OBJECT_ID.toUpperCase(), 5, false) + expect(isRelevantFuelEvent(event, target)).toBe(true) + }) +}) + +describe('applyFuelEventToAssembly', () => { + it('updates quantity and isBurning from the event', () => { + const assembly = createNetworkNodeAssembly(10, false) + const event = createFuelEvent(NODE_OBJECT_ID, 15, true) + + const result = applyFuelEventToAssembly(assembly, event) + + expect(result?.type).toBe(Assemblies.NetworkNode) + if (result?.type !== Assemblies.NetworkNode) return + + expect(result.networkNode.fuel.quantity).toBe(15) + expect(result.networkNode.fuel.isBurning).toBe(true) + }) + + it('reflects a burn event reducing quantity to zero', () => { + const assembly = createNetworkNodeAssembly(1, true) + const event = createFuelEvent(NODE_OBJECT_ID, 0, false) + + const result = applyFuelEventToAssembly(assembly, event) + + if (result?.type !== Assemblies.NetworkNode) return + expect(result.networkNode.fuel.quantity).toBe(0) + expect(result.networkNode.fuel.isBurning).toBe(false) + }) + + it('preserves other fuel fields unchanged', () => { + const assembly = createNetworkNodeAssembly() + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + + const result = applyFuelEventToAssembly(assembly, event) + + if (result?.type !== Assemblies.NetworkNode) return + expect(result.networkNode.fuel.burnTimeInMs).toBe(60_000) + expect(result.networkNode.fuel.typeId).toBe(77810) + }) + + it('is a no-op for non-NetworkNode assemblies', () => { + const assembly = { + type: Assemblies.SmartStorageUnit, + id: NODE_OBJECT_ID, + } as unknown as AssemblyType + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + + expect(applyFuelEventToAssembly(assembly, event)).toBe(assembly) + }) + + it('is a no-op for null assembly', () => { + const event = createFuelEvent(NODE_OBJECT_ID, 5, false) + expect(applyFuelEventToAssembly(null, event)).toBeNull() + }) + + it('is a no-op when new_quantity is missing', () => { + const assembly = createNetworkNodeAssembly(10, false) + const event = { + type: FUEL_EVENT_TYPE, + parsedJson: { assembly_id: NODE_OBJECT_ID }, + } + expect(applyFuelEventToAssembly(assembly, event)).toBe(assembly) + }) +}) diff --git a/packages/libs/dapp-kit/utils/events/checkpointStream.ts b/packages/libs/dapp-kit/utils/events/checkpointStream.ts new file mode 100644 index 0000000..d83982c --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/checkpointStream.ts @@ -0,0 +1,417 @@ +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { + decodeInventoryEventBcs, + inventoryEventBcsToParsedJson, +} from '../inventoryEventBcs' +import { createLogger } from '../logger' +import { isRecord } from '../utils' + +const CHECKPOINT_STREAM_RECONNECT_MS = 1_000 +// Rotate before the public fullnode ~30s stream cutoff. +const CHECKPOINT_STREAM_MAX_SESSION_MS = 28_000 +// Backup if a session stops yielding without closing cleanly. +const CHECKPOINT_STREAM_IDLE_MS = 35_000 +// Bound the dedupe set so long-lived sessions don't grow it unbounded. +const CHECKPOINT_STREAM_MAX_SEEN_EVENTS = 5_000 +const CHECKPOINT_STREAM_READ_MASK_PATHS = [ + 'transactions.digest', + 'transactions.events', + 'sequence_number', +] as const + +type ProtobufValue = { + kind?: { + oneofKind?: string + nullValue?: unknown + numberValue?: number + stringValue?: string + boolValue?: boolean + structValue?: ProtobufStruct + listValue?: { values?: ProtobufValue[] } + } +} + +type ProtobufStruct = { + fields?: Record +} + +type StreamState = { + isInitialSession: boolean + lastCheckpointSequence: number | null + seenEventIds: Set +} + +export type CheckpointStreamTransaction = { + digest?: string + events?: { + events?: Array<{ + eventType?: string + event_type?: string + json?: ProtobufValue + contents?: { + value?: Uint8Array + } + }> + } +} + +export type CheckpointStreamMessage = { + checkpoint?: { + sequenceNumber?: number | bigint + sequence_number?: number | bigint + transactions?: CheckpointStreamTransaction[] + } +} + +export type EventUnsubscribe = () => Promise +export type InventoryEvent = Pick +export type InventoryEventBatchHandler = (events: InventoryEvent[]) => void +export type CheckpointGapHandler = ( + lastCheckpoint: number, + nextCheckpoint: number, +) => void +export type CheckpointStreamSession = { + cancel: () => void + responses: AsyncIterable +} +export type SubscribeCheckpoints = (request: { + readMask: { paths: readonly string[] } +}) => CheckpointStreamSession + +const log = createLogger() + +function protobufValueToJson(value: ProtobufValue | undefined): unknown { + const kind = value?.kind + if (!kind?.oneofKind) return null + if (kind.oneofKind === 'nullValue') return null + if (kind.oneofKind === 'structValue') + return protobufStructToJson(kind.structValue) + if (kind.oneofKind === 'listValue') { + return (kind.listValue?.values ?? []).map(protobufValueToJson) + } + // Scalar kinds: numberValue, stringValue, boolValue + return (kind as Record)[kind.oneofKind] ?? null +} + +function protobufStructToJson( + struct: ProtobufStruct | undefined, +): Record { + return Object.fromEntries( + Object.entries(struct?.fields ?? {}).map(([key, value]) => [ + key, + protobufValueToJson(value), + ]), + ) +} + +function parseInventoryEventPayloadFromStream(event: { + json?: ProtobufValue + contents?: { value?: Uint8Array } +}): Record | null { + const fromProtobuf = protobufValueToJson(event.json) + if (isRecord(fromProtobuf)) return fromProtobuf + + const bcsBytes = event.contents?.value + if (!bcsBytes) return null + + try { + return inventoryEventBcsToParsedJson(decodeInventoryEventBcs(bcsBytes)) + } catch { + return null + } +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, ms) + + const onAbort = () => { + clearTimeout(timeoutId) + reject(signal?.reason) + } + + if (signal?.aborted) { + clearTimeout(timeoutId) + reject(signal.reason) + return + } + + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +function getStreamEventType(event: { + eventType?: string + event_type?: string +}) { + return event.eventType ?? event.event_type ?? '' +} + +function getCheckpointSequenceNumber( + checkpoint: CheckpointStreamMessage['checkpoint'], +): number | null { + const seq = checkpoint?.sequenceNumber ?? checkpoint?.sequence_number + if (typeof seq === 'bigint') return Number(seq) + return typeof seq === 'number' && Number.isFinite(seq) ? seq : null +} + +function getReadTimeoutMs( + maxSessionMs: number, + idleMs: number, + elapsedMs: number, +): number | null { + if (maxSessionMs <= 0 && idleMs <= 0) return null + const msUntilRotation = + maxSessionMs > 0 ? maxSessionMs - elapsedMs : Number.POSITIVE_INFINITY + return Math.min( + idleMs > 0 ? idleMs : Number.POSITIVE_INFINITY, + msUntilRotation, + ) +} + +async function readNextCheckpointMessage( + iterator: AsyncIterator, + session: CheckpointStreamSession, + timeoutMs: number, + signal?: AbortSignal, +): Promise> { + if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { + return iterator.next() + } + + const timeout = wait(timeoutMs, signal).then(() => { + session.cancel() + throw new Error('checkpoint stream idle timeout') + }) + + return Promise.race([iterator.next(), timeout]) +} + +function getInventoryEventId( + event: InventoryEvent, + checkpointSequence?: number | null, +) { + if (event.id.txDigest) { + return `${event.id.txDigest}:${event.id.eventSeq}` + } + return `checkpoint-${checkpointSequence ?? 'unknown'}:${event.id.eventSeq}:${event.type}` +} + +function collectUnseenInventoryEvents( + events: InventoryEvent[], + seenEventIds: Set, + checkpointSequence?: number | null, +) { + return events.filter((event) => { + const eventId = getInventoryEventId(event, checkpointSequence) + if (seenEventIds.has(eventId)) return false + seenEventIds.add(eventId) + // Evict oldest ids (insertion order) once the set exceeds its cap. + while (seenEventIds.size > CHECKPOINT_STREAM_MAX_SEEN_EVENTS) { + const oldest = seenEventIds.values().next().value + if (oldest === undefined) break + seenEventIds.delete(oldest) + } + return true + }) +} + +function processCheckpointMessage( + message: CheckpointStreamMessage, + state: StreamState, + eventTypes: readonly string[], + onEvents: InventoryEventBatchHandler | undefined, + onGap: CheckpointGapHandler | undefined, +): void { + const sequenceNumber = getCheckpointSequenceNumber(message.checkpoint) + + if (state.isInitialSession) { + state.isInitialSession = false + if (sequenceNumber != null) state.lastCheckpointSequence = sequenceNumber + return + } + + if ( + state.lastCheckpointSequence != null && + sequenceNumber != null && + sequenceNumber > state.lastCheckpointSequence + 1 + ) { + onGap?.(state.lastCheckpointSequence, sequenceNumber) + } + + const events = collectUnseenInventoryEvents( + extractInventoryEventsFromCheckpoint(message.checkpoint, eventTypes), + state.seenEventIds, + sequenceNumber, + ) + if (events.length > 0) onEvents?.(events) + if (sequenceNumber != null) state.lastCheckpointSequence = sequenceNumber +} + +async function runSession( + session: CheckpointStreamSession, + state: StreamState, + config: { + eventTypes: readonly string[] + idleMs: number + maxSessionMs: number + isStopped: () => boolean + onError: ((error: unknown) => void) | undefined + onEvents: InventoryEventBatchHandler | undefined + onGap: CheckpointGapHandler | undefined + signal: AbortSignal | undefined + }, +): Promise { + const { + eventTypes, + idleMs, + maxSessionMs, + isStopped, + onError, + onEvents, + onGap, + signal, + } = config + const iterator = session.responses[Symbol.asyncIterator]() + const sessionStartedAt = Date.now() + + while (!isStopped() && !signal?.aborted) { + const timeoutMs = getReadTimeoutMs( + maxSessionMs, + idleMs, + Date.now() - sessionStartedAt, + ) + if (timeoutMs != null && timeoutMs <= 0) return + + let result: IteratorResult + try { + result = await readNextCheckpointMessage( + iterator, + session, + timeoutMs ?? 0, + signal, + ) + } catch (error) { + if (isStopped() || signal?.aborted) return + if (maxSessionMs > 0 && Date.now() - sessionStartedAt >= maxSessionMs) + return + onError?.(error) + return + } + + if (result.done || !result.value) return + + processCheckpointMessage(result.value, state, eventTypes, onEvents, onGap) + } +} + +export function extractInventoryEventsFromCheckpoint( + checkpoint: CheckpointStreamMessage['checkpoint'], + eventTypes: readonly string[], +): InventoryEvent[] { + const checkpointSequence = getCheckpointSequenceNumber(checkpoint) + + return (checkpoint?.transactions ?? []).flatMap((transaction, txIndex) => { + const txDigest = + transaction.digest ?? + (checkpointSequence != null + ? `checkpoint-${checkpointSequence}-${txIndex}` + : `checkpoint-tx-${txIndex}`) + + return (transaction.events?.events ?? []).flatMap((event, eventSeq) => { + const type = getStreamEventType(event) + if (!eventTypes.includes(type)) return [] + + const parsedJson = parseInventoryEventPayloadFromStream(event) + if (!parsedJson) return [] + + return [ + { id: { txDigest, eventSeq: String(eventSeq) }, type, parsedJson }, + ] + }) + }) +} + +export function createInventoryCheckpointStream({ + eventTypes, + idleMs = CHECKPOINT_STREAM_IDLE_MS, + maxSessionMs = CHECKPOINT_STREAM_MAX_SESSION_MS, + onError, + onEvents, + onGap, + reconnectMs = CHECKPOINT_STREAM_RECONNECT_MS, + signal, + subscribeCheckpoints, +}: { + eventTypes: readonly string[] + idleMs?: number + maxSessionMs?: number + onError?: (error: unknown) => void + onEvents?: InventoryEventBatchHandler + onGap?: CheckpointGapHandler + reconnectMs?: number + signal?: AbortSignal + subscribeCheckpoints: SubscribeCheckpoints +}): EventUnsubscribe { + let stopped = false + let activeSession: CheckpointStreamSession | null = null + + const run = async () => { + const state: StreamState = { + isInitialSession: true, + lastCheckpointSequence: null, + seenEventIds: new Set(), + } + + while (!stopped && !signal?.aborted) { + const session = subscribeCheckpoints({ + readMask: { paths: CHECKPOINT_STREAM_READ_MASK_PATHS }, + }) + activeSession = session + + try { + await runSession(session, state, { + eventTypes, + idleMs, + maxSessionMs, + isStopped: () => stopped, + onError, + onEvents, + onGap, + signal, + }) + } catch (error) { + if (stopped || signal?.aborted) return + onError?.(error) + } finally { + session.cancel() + if (activeSession === session) activeSession = null + } + + if (stopped || signal?.aborted) return + + try { + await wait(reconnectMs, signal) + } catch { + return + } + } + } + + const streamTask = run().catch((error) => { + log.error( + '[DappKit] Inventory checkpoint stream task exited unexpectedly:', + error, + ) + }) + + return async () => { + stopped = true + activeSession?.cancel() + await streamTask + } +} diff --git a/packages/libs/dapp-kit/utils/events/eventRefresh.ts b/packages/libs/dapp-kit/utils/events/eventRefresh.ts new file mode 100644 index 0000000..ef0c805 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/eventRefresh.ts @@ -0,0 +1,98 @@ +import { SuiGrpcClient } from '@mysten/sui/grpc' + +import { DEFAULT_GRAPHQL_NETWORK, getSuiGrpcBaseUrl } from '../constants' +import { createLogger } from '../logger' +import { + type CheckpointGapHandler, + type CheckpointStreamMessage, + createInventoryCheckpointStream, + type EventUnsubscribe, + type InventoryEventBatchHandler, +} from './checkpointStream' + +const log = createLogger() + +const EVENT_REFETCH_DELAYS_MS = [250, 1500, 3500] as const + +export type ScheduledRefetch = (() => void) & { cancel: () => void } + +export function createEventRefetchScheduler( + refetch: () => Promise, + delaysMs: readonly number[] = EVENT_REFETCH_DELAYS_MS, + onError?: (error: unknown) => void, +): ScheduledRefetch { + let timeouts: ReturnType[] = [] + + const scheduledRefetch = () => { + scheduledRefetch.cancel() + + timeouts = delaysMs.map((delayMs) => { + const timeoutId = setTimeout(() => { + timeouts = timeouts.filter((timeout) => timeout !== timeoutId) + refetch().catch((error) => onError?.(error)) + }, delayMs) + return timeoutId + }) + } + + scheduledRefetch.cancel = () => { + for (const timeout of timeouts) { + clearTimeout(timeout) + } + timeouts = [] + } + + return scheduledRefetch +} + +export async function subscribeToInventoryEvents({ + eventTypes, + network = DEFAULT_GRAPHQL_NETWORK, + onEvents, + onGap, + signal, +}: { + eventTypes: readonly string[] + network?: string + onEvents?: InventoryEventBatchHandler + onGap?: CheckpointGapHandler + signal?: AbortSignal +}): Promise { + const unsubscribe = createInventoryCheckpointStream({ + eventTypes, + ...(onEvents !== undefined ? { onEvents } : {}), + ...(onGap !== undefined ? { onGap } : {}), + ...(signal !== undefined ? { signal } : {}), + onError: (error) => { + log.warn('[DappKit] Inventory checkpoint stream error:', error) + }, + subscribeCheckpoints: (request) => { + const abortController = new AbortController() + const grpcClient = new SuiGrpcClient({ + network, + baseUrl: getSuiGrpcBaseUrl(network), + }) + const call = grpcClient.subscriptionService.subscribeCheckpoints( + { + readMask: { + paths: [...request.readMask.paths], + }, + }, + { abort: abortController.signal }, + ) + + return { + responses: call.responses as AsyncIterable, + cancel: () => { + abortController.abort() + }, + } + }, + }) + + signal?.addEventListener('abort', () => { + void unsubscribe() + }) + + return unsubscribe +} diff --git a/packages/libs/dapp-kit/utils/events/fuelEventHandlers.ts b/packages/libs/dapp-kit/utils/events/fuelEventHandlers.ts new file mode 100644 index 0000000..5e22243 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/fuelEventHandlers.ts @@ -0,0 +1,77 @@ +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { Assemblies, type AssemblyType } from '../../types' +import { getEveWorldPackageId } from '../constants' +import { isRecord, normalizeObjectId } from '../utils' + +type FuelEventPayload = { + assembly_id?: string + assembly_key?: { item_id?: string | number; tenant?: string } + type_id?: string | number + old_quantity?: string | number + new_quantity?: string | number + is_burning?: boolean + action?: unknown +} + +export type FuelEventTarget = { + eventTypes: readonly string[] + objectId: string +} + +function parseFuelEventPayload( + event: Pick, +): FuelEventPayload | null { + if (!isRecord(event.parsedJson)) return null + return event.parsedJson as FuelEventPayload +} + +export function getFuelEventType(packageId = getEveWorldPackageId()): string { + return `${packageId}::fuel::FuelEvent` +} + +export function getFuelEventTarget( + assembly: AssemblyType | null, + eventTypes: readonly string[], +): FuelEventTarget | null { + if (assembly?.type !== Assemblies.NetworkNode) return null + if (!assembly.id) return null + return { eventTypes, objectId: assembly.id } +} + +export function isRelevantFuelEvent( + event: Pick, + target: FuelEventTarget, +): boolean { + if (!target.eventTypes.includes(event.type)) return false + const payload = parseFuelEventPayload(event) + return ( + normalizeObjectId(payload?.assembly_id) === + normalizeObjectId(target.objectId) + ) +} + +export function applyFuelEventToAssembly( + assembly: AssemblyType | null, + event: Pick, +): AssemblyType | null { + if (assembly?.type !== Assemblies.NetworkNode) return assembly + + const payload = parseFuelEventPayload(event) + if (!payload) return assembly + + const newQuantity = Number(payload.new_quantity) + if (!Number.isFinite(newQuantity) || newQuantity < 0) return assembly + + return { + ...assembly, + networkNode: { + ...assembly.networkNode, + fuel: { + ...assembly.networkNode.fuel, + quantity: newQuantity, + isBurning: Boolean(payload.is_burning), + }, + }, + } +} diff --git a/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts new file mode 100644 index 0000000..ae18502 --- /dev/null +++ b/packages/libs/dapp-kit/utils/events/inventoryEventHandlers.ts @@ -0,0 +1,267 @@ +import type { SuiEvent } from '@mysten/sui/jsonRpc' + +import { Assemblies, type AssemblyType, type InventoryItem } from '../../types' +import { getEveWorldPackageId } from '../constants' +import { + adjustInventoryUsedCapacity, + sortInventoryItemsByQuantity, +} from '../inventory' +import { isRecord, normalizeObjectId } from '../utils' + +const INVENTORY_EVENT_NAMES = ['ItemBurnedEvent', 'ItemMintedEvent'] as const + +type AssemblyEventKey = { + item_id?: string | number + tenant?: string +} + +type AssemblyEventPayload = { + assembly_id?: string + assembly_key?: AssemblyEventKey + item_id?: string + quantity?: number + type_id?: string | number +} + +export type AssemblyEventTarget = { + eventTypes: readonly string[] + itemId?: string + objectId?: string + tenant?: string +} + +function toFiniteNumber(value: unknown, fallback = 0): number { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : fallback +} + +function parseAssemblyEventKey(raw: unknown): AssemblyEventKey | undefined { + if (!isRecord(raw)) return undefined + const { item_id, tenant } = raw + if ( + (typeof item_id === 'string' || typeof item_id === 'number') && + typeof tenant === 'string' + ) { + return { item_id, tenant } + } + return undefined +} + +function parseAssemblyEventPayload( + event: Pick, +): AssemblyEventPayload | null { + const json = event.parsedJson + if (!isRecord(json)) return null + + const payload: AssemblyEventPayload = {} + + if (typeof json['assembly_id'] === 'string') + payload.assembly_id = json['assembly_id'] + if (typeof json['item_id'] === 'string') payload.item_id = json['item_id'] + if (typeof json['quantity'] === 'number') payload.quantity = json['quantity'] + if ( + typeof json['type_id'] === 'string' || + typeof json['type_id'] === 'number' + ) { + payload.type_id = json['type_id'] + } + + const assemblyKey = parseAssemblyEventKey(json['assembly_key']) + if (assemblyKey) payload.assembly_key = assemblyKey + + return payload +} + +function parseInventoryEventDelta( + event: Pick, +) { + const payload = parseAssemblyEventPayload(event) + if (!payload) return null + + const typeId = Number(payload.type_id) + const quantity = Number(payload.quantity) + if (!Number.isFinite(typeId) || !Number.isFinite(quantity) || quantity <= 0) + return null + + const operation = event.type.endsWith('::inventory::ItemMintedEvent') + ? ('add' as const) + : event.type.endsWith('::inventory::ItemBurnedEvent') + ? ('subtract' as const) + : null + + if (!operation) return null + + return { + itemId: payload.item_id ?? String(typeId), + quantity, + tenant: payload.assembly_key?.tenant ?? '', + typeId, + operation, + } +} + +function isOptimisticInventoryItem(item: InventoryItem): boolean { + return item.id.startsWith('optimistic-') +} + +function mergeInventoryItemsByTypeId(items: InventoryItem[]): InventoryItem[] { + const itemsByTypeId = new Map() + + items.forEach((item) => { + const typeId = toFiniteNumber(item.type_id, Number.NaN) + if (!Number.isFinite(typeId)) return + + const existing = itemsByTypeId.get(typeId) + if (!existing) { + itemsByTypeId.set(typeId, { + ...item, + quantity: toFiniteNumber(item.quantity), + type_id: typeId, + }) + return + } + + const quantity = + toFiniteNumber(existing.quantity) + toFiniteNumber(item.quantity) + if (quantity <= 0) { + itemsByTypeId.delete(typeId) + return + } + + const preferred = + isOptimisticInventoryItem(existing) && !isOptimisticInventoryItem(item) + ? item + : existing + itemsByTypeId.set(typeId, { ...preferred, quantity, type_id: typeId }) + }) + + return Array.from(itemsByTypeId.values()) +} + +function computeNextItems( + items: InventoryItem[], + delta: NonNullable>, +): InventoryItem[] { + const itemIndex = items.findIndex( + (item) => toFiniteNumber(item.type_id) === delta.typeId, + ) + + if (itemIndex === -1) { + if (delta.operation === 'subtract') return items + return [ + ...items, + { + id: `optimistic-${delta.typeId}`, + item_id: delta.itemId, + location: { location_hash: '' }, + quantity: delta.quantity, + tenant: delta.tenant, + type_id: delta.typeId, + name: `Type ${delta.typeId}`, + } satisfies InventoryItem, + ] + } + + return items.flatMap((item, index) => { + if (index !== itemIndex) return [item] + const quantity = + delta.operation === 'add' + ? toFiniteNumber(item.quantity) + delta.quantity + : Math.max(0, toFiniteNumber(item.quantity) - delta.quantity) + return quantity === 0 ? [] : [{ ...item, quantity }] + }) +} + +export function getInventoryEventTypes( + packageId = getEveWorldPackageId(), +): string[] { + return INVENTORY_EVENT_NAMES.map( + (eventName) => `${packageId}::inventory::${eventName}`, + ) +} + +export function isRelevantAssemblyInventoryEvent( + event: Pick, + target: AssemblyEventTarget, +): boolean { + if (!target.eventTypes.includes(event.type)) return false + + const payload = parseAssemblyEventPayload(event) + if (!payload) return false + + if ( + target.objectId && + normalizeObjectId(payload.assembly_id) === + normalizeObjectId(target.objectId) + ) { + return true + } + + if (!target.itemId || !target.tenant) return false + + return ( + String(payload.assembly_key?.item_id ?? '') === String(target.itemId) && + payload.assembly_key?.tenant === target.tenant + ) +} + +export function getInventoryEventTarget({ + assembly, + eventTypes, + isObjectIdDirect, + selectedObjectId, + selectedTenant, +}: { + assembly: AssemblyType | null + eventTypes: readonly string[] + isObjectIdDirect: boolean + selectedObjectId: string + selectedTenant: string +}): AssemblyEventTarget { + if (assembly?.type === Assemblies.SmartStorageUnit) { + if (assembly.id) return { eventTypes, objectId: assembly.id } + if (assembly.item_id) { + return { + eventTypes, + itemId: String(assembly.item_id), + tenant: selectedTenant, + } + } + } + + if (isObjectIdDirect) return { eventTypes, objectId: selectedObjectId } + + return { eventTypes, itemId: selectedObjectId, tenant: selectedTenant } +} + +export function applyInventoryEventToAssembly( + assembly: AssemblyType | null, + event: Pick, +): AssemblyType | null { + if (!assembly || assembly.type !== Assemblies.SmartStorageUnit) + return assembly + + const delta = parseInventoryEventDelta(event) + if (!delta) return assembly + + const items = assembly.storage.mainInventory.items ?? [] + + return { + ...assembly, + storage: { + ...assembly.storage, + mainInventory: { + ...assembly.storage.mainInventory, + usedCapacity: adjustInventoryUsedCapacity( + assembly.storage.mainInventory.usedCapacity, + delta.quantity, + delta.typeId, + delta.operation, + ), + items: sortInventoryItemsByQuantity( + mergeInventoryItemsByTypeId(computeNextItems(items, delta)), + ), + }, + }, + } +} diff --git a/packages/libs/dapp-kit/utils/index.ts b/packages/libs/dapp-kit/utils/index.ts index 1ffbf12..391ee35 100644 --- a/packages/libs/dapp-kit/utils/index.ts +++ b/packages/libs/dapp-kit/utils/index.ts @@ -25,19 +25,4 @@ export { export type { TransformOptions } from './transforms' export { transformToAssembly, transformToCharacter } from './transforms' // General utilities -export { - abbreviateAddress, - assertAssemblyType, - clickToCopy, - findOwnerByAddress, - formatDuration, - formatM3, - getCommonItems, - getDappUrl, - getEnv, - getTxUrl, - getVolumeM3, - isOwner, - parseURL, - removeTrailingZeros, -} from './utils' +export * from './utils' diff --git a/packages/libs/dapp-kit/utils/inventory.ts b/packages/libs/dapp-kit/utils/inventory.ts new file mode 100644 index 0000000..563ffb4 --- /dev/null +++ b/packages/libs/dapp-kit/utils/inventory.ts @@ -0,0 +1,183 @@ +import type { Assemblies, AssemblyType, InventoryItem } from '../types' + +const typeVolumeM3ById = new Map() + +function toFiniteNumber(value: unknown, fallback = 0): number { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : fallback +} + +function getInventoryItemSignatures(items: InventoryItem[] | undefined) { + const quantityByTypeId = new Map() + + ;(items ?? []).forEach((item) => { + const typeId = toFiniteNumber(item.type_id, Number.NaN) + if (!Number.isFinite(typeId)) return + + quantityByTypeId.set( + typeId, + (quantityByTypeId.get(typeId) ?? 0) + toFiniteNumber(item.quantity), + ) + }) + + return Array.from(quantityByTypeId.entries()) + .filter(([, quantity]) => quantity > 0) + .sort(([leftTypeId], [rightTypeId]) => leftTypeId - rightTypeId) +} + +function getInventoryQuantitySignature( + items: InventoryItem[] | undefined, +): string { + return JSON.stringify(getInventoryItemSignatures(items)) +} + +function preserveStorageInventoryItemsWhenEqual( + currentAssembly: AssemblyType, + nextAssembly: AssemblyType, +): AssemblyType { + if ( + !areInventoryItemListsEqual( + currentAssembly.storage.mainInventory.items, + nextAssembly.storage.mainInventory.items, + ) + ) { + return nextAssembly + } + + return { + ...nextAssembly, + storage: { + ...nextAssembly.storage, + mainInventory: { + ...nextAssembly.storage.mainInventory, + items: currentAssembly.storage.mainInventory.items, + usedCapacity: + currentAssembly.storage.mainInventory.usedCapacity ?? + nextAssembly.storage.mainInventory.usedCapacity, + }, + }, + } +} + +/** Cache Datahub volume (m³ per unit) for optimistic used-capacity updates. */ +export function setInventoryTypeVolumeM3(typeId: number, volumeM3: number) { + if (!Number.isFinite(typeId) || !Number.isFinite(volumeM3) || volumeM3 < 0) { + return + } + typeVolumeM3ById.set(typeId, volumeM3) +} + +export function getInventoryTypeVolumeM3(typeId: number): number | undefined { + return typeVolumeM3ById.get(typeId) +} + +/** Clear cached volumes (for tests). */ +export function clearInventoryTypeVolumeM3Cache() { + typeVolumeM3ById.clear() +} + +export function adjustInventoryUsedCapacity( + usedCapacity: string, + quantity: number, + typeId: number, + operation: 'add' | 'subtract', +): string + +export function adjustInventoryUsedCapacity( + usedCapacity: string | undefined, + quantity: number, + typeId: number, + operation: 'add' | 'subtract', +): string | undefined + +export function adjustInventoryUsedCapacity( + usedCapacity: string | undefined, + quantity: number, + typeId: number, + operation: 'add' | 'subtract', +): string | undefined { + const volumeM3 = getInventoryTypeVolumeM3(typeId) + if (volumeM3 === undefined || usedCapacity === undefined) { + return usedCapacity + } + // On-chain used_capacity is dm³ (litres); Datahub volume is m³ per unit. + const volumeDeltaDm3 = Math.round(quantity * volumeM3 * 1000) + + const currentUsedCapacity = toFiniteNumber(usedCapacity) + const nextUsedCapacity = + operation === 'add' + ? currentUsedCapacity + volumeDeltaDm3 + : Math.max(0, currentUsedCapacity - volumeDeltaDm3) + + return String(nextUsedCapacity) +} + +export function sortInventoryItemsByQuantity( + items: InventoryItem[] | undefined, +): InventoryItem[] { + return [...(items ?? [])].sort((a, b) => { + const quantityDifference = + toFiniteNumber(b.quantity) - toFiniteNumber(a.quantity) + if (quantityDifference !== 0) return quantityDifference + return toFiniteNumber(a.type_id) - toFiniteNumber(b.type_id) + }) +} + +export function areInventoryItemListsEqual( + leftItems: InventoryItem[] | undefined, + rightItems: InventoryItem[] | undefined, +): boolean { + const leftSignatures = getInventoryItemSignatures(leftItems) + const rightSignatures = getInventoryItemSignatures(rightItems) + + if (leftSignatures.length !== rightSignatures.length) return false + + return leftSignatures.every(([leftTypeId, leftQuantity], index) => { + const rightSignature = rightSignatures[index] + if (!rightSignature) return false + const [rightTypeId, rightQuantity] = rightSignature + return leftTypeId === rightTypeId && leftQuantity === rightQuantity + }) +} + +/** + * Merge a GraphQL refetch into optimistic storage state without clobbering + * stream-updated items when the indexer has only moved ancillary fields first + * (e.g. used_capacity) while item quantities are still stale. + */ +export function mergeSmartStorageInventoryFromRefetch( + currentAssembly: AssemblyType | null, + nextAssembly: AssemblyType, + lastConfirmedInventorySignature: string | null, +): { + assembly: AssemblyType + inventorySignature: string +} { + const nextSignature = getInventoryQuantitySignature( + nextAssembly.storage.mainInventory.items, + ) + + if ( + currentAssembly && + lastConfirmedInventorySignature !== null && + nextSignature === lastConfirmedInventorySignature && + !areInventoryItemListsEqual( + currentAssembly.storage.mainInventory.items, + nextAssembly.storage.mainInventory.items, + ) + ) { + return { + assembly: currentAssembly, + inventorySignature: lastConfirmedInventorySignature, + } + } + + const mergedAssembly = currentAssembly + ? preserveStorageInventoryItemsWhenEqual(currentAssembly, nextAssembly) + : nextAssembly + + return { + assembly: mergedAssembly, + inventorySignature: nextSignature, + } +} diff --git a/packages/libs/dapp-kit/utils/inventoryEventBcs.ts b/packages/libs/dapp-kit/utils/inventoryEventBcs.ts new file mode 100644 index 0000000..910a704 --- /dev/null +++ b/packages/libs/dapp-kit/utils/inventoryEventBcs.ts @@ -0,0 +1,76 @@ +import { bcs } from '@mysten/sui/bcs' + +const BcsObjectId = bcs.fixedArray(32, bcs.u8()).transform({ + input: (value: number[]) => value, + output: (value: number[]) => + `0x${value.map((byte) => byte.toString(16).padStart(2, '0')).join('')}`, +}) + +const TenantKey = bcs.struct('TenantKey', { + item_id: bcs.u64(), + tenant: bcs.string(), +}) + +const InventoryMoveEvent = bcs.struct('InventoryMoveEvent', { + assembly_id: BcsObjectId, + assembly_key: TenantKey, + character_id: BcsObjectId, + character_key: TenantKey, + item_id: bcs.u64(), + type_id: bcs.u64(), + quantity: bcs.u32(), +}) + +// ---------------------------------------------------------------------------- + +export type DecodedInventoryMoveEvent = { + assembly_id: string + assembly_key: { + item_id: string + tenant: string + } + character_id: string + character_key: { + item_id: string + tenant: string + } + item_id: string + type_id: string + quantity: number +} + +export function decodeInventoryEventBcs( + bytes: Uint8Array, +): DecodedInventoryMoveEvent { + const decoded = InventoryMoveEvent.parse(bytes) + + return { + assembly_id: decoded.assembly_id, + assembly_key: { + item_id: String(decoded.assembly_key.item_id), + tenant: decoded.assembly_key.tenant, + }, + character_id: decoded.character_id, + character_key: { + item_id: String(decoded.character_key.item_id), + tenant: decoded.character_key.tenant, + }, + item_id: String(decoded.item_id), + type_id: String(decoded.type_id), + quantity: Number(decoded.quantity), + } +} + +export function inventoryEventBcsToParsedJson( + decoded: DecodedInventoryMoveEvent, +): Record { + return { + assembly_id: decoded.assembly_id, + assembly_key: decoded.assembly_key, + character_id: decoded.character_id, + character_key: decoded.character_key, + item_id: decoded.item_id, + quantity: decoded.quantity, + type_id: decoded.type_id, + } +} diff --git a/packages/libs/dapp-kit/utils/transforms.ts b/packages/libs/dapp-kit/utils/transforms.ts index 4e64ede..3126228 100644 --- a/packages/libs/dapp-kit/utils/transforms.ts +++ b/packages/libs/dapp-kit/utils/transforms.ts @@ -16,6 +16,7 @@ import { } from '../types' import { getEnergyConfig, getEnergyUsageForType } from './config' import { getDatahubGameInfo } from './datahub' +import { sortInventoryItemsByQuantity } from './inventory' import { createLogger } from './logger' import { getAssemblyType, parseStatus } from './mapping' @@ -156,7 +157,7 @@ export async function transformToAssembly( mainInventory: { capacity: inventoryData?.value?.max_capacity, usedCapacity: inventoryData?.value?.used_capacity, - items: inventoryItems, + items: sortInventoryItemsByQuantity(inventoryItems), }, ephemeralInventories: [], }, diff --git a/packages/libs/dapp-kit/utils/utils.ts b/packages/libs/dapp-kit/utils/utils.ts index e84c5db..100784b 100644 --- a/packages/libs/dapp-kit/utils/utils.ts +++ b/packages/libs/dapp-kit/utils/utils.ts @@ -268,3 +268,11 @@ export const formatDuration = (seconds: number): string => { return `${formattedSeconds}s` } } + +export const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} + +export function normalizeObjectId(value: string | undefined): string { + return value?.toLowerCase() ?? '' +} diff --git a/packages/libs/ui-components/components/EveLinearBar.tsx b/packages/libs/ui-components/components/EveLinearBar.tsx index 8ea9745..2ebbe04 100644 --- a/packages/libs/ui-components/components/EveLinearBar.tsx +++ b/packages/libs/ui-components/components/EveLinearBar.tsx @@ -5,15 +5,22 @@ const EveLinearBar = React.memo( nominator, denominator, label, + wholeNumbers = false, }: { nominator: number denominator: number label?: string + wholeNumbers?: boolean }) => { const percentage = - denominator === 0 && nominator === 0 + denominator <= 0 ? '0%' - : `${(nominator / denominator) * 100}%` + : `${Math.min(100, Math.max(0, (nominator / denominator) * 100))}%` + + const displayNominator = wholeNumbers ? Math.round(nominator) : nominator + const displayDenominator = wholeNumbers + ? Math.round(denominator) + : denominator return (
@@ -25,8 +32,8 @@ const EveLinearBar = React.memo( />
- {Intl.NumberFormat().format(nominator)} /{' '} - {Intl.NumberFormat().format(denominator)} {label} + {Intl.NumberFormat().format(displayNominator)} /{' '} + {Intl.NumberFormat().format(displayDenominator)} {label}
) diff --git a/packages/libs/ui-components/modules/InventoryView.tsx b/packages/libs/ui-components/modules/InventoryView.tsx index 1f1b29b..628ceb2 100644 --- a/packages/libs/ui-components/modules/InventoryView.tsx +++ b/packages/libs/ui-components/modules/InventoryView.tsx @@ -59,7 +59,9 @@ const InventoryView = React.memo( getDatahubGameInfo(typeId).then((info) => [typeId, info] as const), ), ).then((results) => { - if (!cancelled) setItemDetailsMap(new Map(results)) + if (!cancelled) { + setItemDetailsMap(new Map(results)) + } }) return () => { cancelled = true @@ -108,11 +110,11 @@ const InventoryView = React.memo( {!inventoryItems || inventoryItems.length === 0 ? (
Empty
) : ( - inventoryItems?.map((item, index) => ( + inventoryItems?.map((item) => ( )) )} @@ -121,7 +123,8 @@ const InventoryView = React.memo( ) : (