diff --git a/DETAILED_DOCUMENTATION.md b/DETAILED_DOCUMENTATION.md index 945ec13..193480a 100644 --- a/DETAILED_DOCUMENTATION.md +++ b/DETAILED_DOCUMENTATION.md @@ -406,15 +406,36 @@ Acknowledges XP shop tracks. --- +##### `personalStore` and `personalStoreUpdate` + +`personalStore` is `null` until the GC supplies weekly reward data. Otherwise it contains: + +- `generation_time` (`number | null`) - Server-provided offer generation timestamp. +- `redeemable_balance` (`number | null`) - Server-provided remaining balance. +- `items` (`string[]`) - Available reward item IDs, preserved as decimal strings. + +Initial data is loaded before `connectedToGC`. The `personalStoreUpdate(store)` event +fires when data arrives through welcome, create, or update messages, including batched +updates. Removal, disconnect, or a new welcome clears stale data and emits `null` if a +store was previously present. Missing scalar fields are `null`; an explicit zero balance +remains `0`. Malformed store payloads emit a debug message and are ignored. + +A missing store does not prove that an account is ineligible. This API exposes server +state; it does not calculate a weekly reset or generate rewards. Select IDs from the +current offer and pass its generation time and balance to `redeemFreeReward`. Keep +claims sequential and wait for refreshed store data before another claim. + +--- + ##### `redeemFreeReward(generationTime, redeemableBalance, items, callback)` Redeems a free reward. **Parameters:** -- `generationTime` (number) - Generation time of the reward -- `redeemableBalance` (number) - Redeemable balance -- `items` (Array) - Array of item IDs +- `generationTime` (number) - Generation time from the current `personalStore` +- `redeemableBalance` (number) - Balance from the current `personalStore` +- `items` (Array) - Selected reward IDs; use strings to preserve 64-bit precision - `callback` (function, optional) - Callback function `(err, itemIds) => {}` **Returns:** diff --git a/EXAMPLES.md b/EXAMPLES.md index 0e6c19d..cb72bbe 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -337,26 +337,44 @@ cs2.acknowledgeRentalExpiration(crateItemId); cs2.acknowledgeXPShopTracks(); ``` -### Redeem Free Reward +### View and Redeem Weekly Rewards + +The GC supplies the current offer in `personalStore`. Listen for updates to display +available choices, and explicitly choose which rewards to claim. A `null` store +means no current data is available; it does not establish weekly eligibility. ```javascript -// Redeem free reward -const generationTime = Date.now() / 1000; -const redeemableBalance = 100; -const items = [1234567890, 9876543210]; +cs2.on('personalStoreUpdate', (store) => { + console.log('Weekly reward offer:', store); +}); -await cs2.redeemFreeReward(generationTime, redeemableBalance, items); +cs2.on('connectedToGC', () => { + console.log('Initial weekly reward offer:', cs2.personalStore); +}); -// With callback -cs2.redeemFreeReward(generationTime, redeemableBalance, items, (err, itemIds) => { - if (err) { - console.error('Error redeeming reward:', err); - return; +// Call after the user selects item IDs from the current offer. +async function claimWeeklyRewards(selectedItemIds) { + const store = cs2.personalStore; + if (!cs2.haveGCSession || !store || store.generation_time === null || !store.redeemable_balance) { + throw new Error('No weekly rewards available to claim'); } - console.log('Reward redeemed, items:', itemIds); -}); + if ( + selectedItemIds.length === 0 || + selectedItemIds.length > store.redeemable_balance || + new Set(selectedItemIds).size !== selectedItemIds.length || + !selectedItemIds.every((id) => store.items.includes(id)) + ) { + throw new Error('Select distinct item IDs from the current weekly reward offer'); + } + return cs2.redeemFreeReward(store.generation_time, store.redeemable_balance, selectedItemIds); +} ``` +Keep item IDs as strings. Use the server-provided generation time and balance; +do not substitute the current time or an invented balance. Make one claim at a +time and wait for updated store data before another. The existing callback form +`redeemFreeReward(generationTime, redeemableBalance, items, callback)` is also supported. + ### Redeem Mission Reward ```javascript diff --git a/constants.js b/constants.js index 07e0749..2bb5654 100644 --- a/constants.js +++ b/constants.js @@ -27,6 +27,7 @@ const PLAYERS_PROFILE_REQUEST_LEVEL = 32; // ─── Shared Object Types ──────────────────────────────────────────────────────── const SO_TYPE_ECON_ITEM = 1; +const SO_TYPE_PERSONAL_STORE = 4; // ─── Item Definition Indices ──────────────────────────────────────────────────── const DEFINDEX_STORAGE_UNIT = 1201; @@ -70,6 +71,7 @@ module.exports = { CRATE_TIMEOUT_MS, PLAYERS_PROFILE_REQUEST_LEVEL, SO_TYPE_ECON_ITEM, + SO_TYPE_PERSONAL_STORE, DEFINDEX_STORAGE_UNIT, ATTRIB_PAINT_INDEX, ATTRIB_PAINT_SEED, diff --git a/handlers.js b/handlers.js index 743c435..630e2fe 100644 --- a/handlers.js +++ b/handlers.js @@ -59,9 +59,20 @@ handlers[Language.ClientWelcome] = function (body) { return; } + this._setPersonalStore(null); + for (const subscribed of proto.outofdate_subscribed_caches || []) { + for (const cache of subscribed.objects || []) { + if (cache.type_id === Constants.SO_TYPE_PERSONAL_STORE) { + cache.object_data.forEach((object) => this._decodePersonalStore(object)); + } + } + } if (proto.outofdate_subscribed_caches && proto.outofdate_subscribed_caches.length) { proto.outofdate_subscribed_caches[0].objects.forEach((cache) => { switch (cache.type_id) { + case Constants.SO_TYPE_PERSONAL_STORE: + // Loaded from all subscribed caches above. + break; case Constants.SO_TYPE_ECON_ITEM: // Inventory const items = cache.object_data @@ -145,6 +156,7 @@ handlers[Language.ClientConnectionStatus] = function (body) { ); if (proto.status != NodeCS2.GCConnectionStatus.HAVE_SESSION && this.haveGCSession) { + this._setPersonalStore(null); this.emit('disconnectedFromGC', proto.status); this.haveGCSession = false; this._connect(); // Try to reconnect @@ -446,6 +458,26 @@ NodeCS2.prototype._processSOEconItem = function (item) { } }; +// Personal-store IDs are uint64 values and remain decimal strings, like inventory IDs. +NodeCS2.prototype._setPersonalStore = function (store) { + if (store === null && this.personalStore === null) { + return; + } + this.personalStore = store; + this.emit('personalStoreUpdate', store); +}; + +NodeCS2.prototype._decodePersonalStore = function (body) { + let store; + try { + store = decodeProto(Protos.CSOAccountItemPersonalStore, body); + } catch (err) { + this.emit('debug', `Failed to decode personal store: ${err.message}`); + return; + } + this._setPersonalStore(store); +}; + handlers[Language.SO_Create] = function (body) { let proto; try { @@ -458,6 +490,11 @@ handlers[Language.SO_Create] = function (body) { }; NodeCS2.prototype._handleSOCreate = function (proto) { + if (proto && proto.type_id === Constants.SO_TYPE_PERSONAL_STORE) { + this._decodePersonalStore(proto.object_data); + return; + } + if (!proto || proto.type_id != Constants.SO_TYPE_ECON_ITEM) { return; // Not an item } @@ -492,6 +529,11 @@ handlers[Language.SO_Update] = function (body) { }; NodeCS2.prototype._handleSOUpdate = function (so) { + if (so && so.type_id === Constants.SO_TYPE_PERSONAL_STORE) { + this._decodePersonalStore(so.object_data); + return; + } + if (!so || so.type_id != Constants.SO_TYPE_ECON_ITEM) { return; // Not an item, we don't care } @@ -538,6 +580,11 @@ handlers[Language.SO_Destroy] = function (body) { }; NodeCS2.prototype._handleSODestroy = function (proto) { + if (proto && proto.type_id === Constants.SO_TYPE_PERSONAL_STORE) { + this._setPersonalStore(null); + return; + } + if (!proto || proto.type_id != Constants.SO_TYPE_ECON_ITEM) { return; // Not an item } diff --git a/index.js b/index.js index c729c15..72ce282 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,7 @@ function NodeCS2(steam) { } this._steam = steam; + this.personalStore = null; this.haveGCSession = false; this._isInCSGO = false; @@ -79,6 +80,8 @@ function NodeCS2(steam) { this._helloInterval = null; } + this._setPersonalStore(null); + if (this.haveGCSession && emitDisconnectEvent) { this.emit('disconnectedFromGC', NodeCS2.GCConnectionStatus.NO_SESSION); } @@ -771,7 +774,7 @@ NodeCS2.prototype.acknowledgeXPShopTracks = function () { * Redeem a free reward. * @param {int} generationTime - Generation time of the reward * @param {int} redeemableBalance - Redeemable balance - * @param {int[]} items - Array of item IDs + * @param {Array} items - Reward item IDs; use strings for 64-bit precision * @param {function} callback - Optional callback. If not provided, returns a Promise. * @returns {Promise|undefined} Returns a Promise if no callback is provided */ diff --git a/test/personal-store.test.mjs b/test/personal-store.test.mjs new file mode 100644 index 0000000..24d4b22 --- /dev/null +++ b/test/personal-store.test.mjs @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const SteamUser = require('steam-user'); +const SteamID = require('steamid'); +const NodeCS2 = require('../index.js'); +const Language = require('../language.js'); +const Protos = require('../protobufs/generated/_load.js'); + +const offer = { + generation_time: 1770000000, + redeemable_balance: 2, + items: ['9007199254740993', '18446744073709551615'] +}; + +function setup() { + const steam = new SteamUser({ dataDirectory: null }); + const cs2 = new NodeCS2(steam); + steam.sendToGC = vi.fn(); + const update = vi.fn(); + cs2.on('personalStoreUpdate', update); + const deliver = (type, schema, data) => + steam.emit('receivedFromGC', 730, type, Buffer.from(schema.encode(data).finish())); + const object = (data = offer) => ({ + type_id: 4, + object_data: Protos.CSOAccountItemPersonalStore.encode(data).finish() + }); + const welcome = (stores = [object()]) => + deliver(Language.ClientWelcome, Protos.CMsgClientWelcome, { + outofdate_subscribed_caches: [ + { objects: [] }, + { objects: stores.map(({ type_id, object_data }) => ({ type_id, object_data: [object_data] })) } + ] + }); + return { steam, cs2, update, deliver, object, welcome }; +} + +afterEach(() => vi.useRealTimers()); + +describe('weekly reward personal store', () => { + it('loads from any welcome cache before connectedToGC, preserving uint64 IDs', () => { + const { cs2, welcome, update } = setup(); + expect(cs2.personalStore).toBeNull(); + const connected = vi.fn(() => expect(cs2.personalStore).toEqual(offer)); + cs2.on('connectedToGC', connected); + welcome(); + expect(update).toHaveBeenCalledExactlyOnceWith(offer); + expect(connected).toHaveBeenCalledOnce(); + }); + + it('decodes an independently specified protobuf wire fixture', () => { + const { cs2, welcome } = setup(); + // Fields 1=123, 2=2, 3=9007199254740993; manually encoded varints. + welcome([{ type_id: 4, object_data: Buffer.from('087b1002188180808080808010', 'hex') }]); + expect(cs2.personalStore).toEqual({ generation_time: 123, redeemable_balance: 2, items: ['9007199254740993'] }); + }); + + it('retains inventory handling alongside a personal-store welcome', () => { + const { cs2, deliver, object } = setup(); + const store = object(); + deliver(Language.ClientWelcome, Protos.CMsgClientWelcome, { + outofdate_subscribed_caches: [ + { + objects: [ + { type_id: 1, object_data: [Protos.CSOEconItem.encode({ id: '42', def_index: 7 }).finish()] }, + { type_id: 4, object_data: [store.object_data] } + ] + } + ] + }); + expect(cs2.inventory).toHaveLength(1); + expect(cs2.inventory[0]).toMatchObject({ id: '42', def_index: 7 }); + expect(cs2.personalStore).toEqual(offer); + }); + + it('handles single create/update/destroy without requiring an inventory', () => { + const { cs2, deliver, object, update } = setup(); + deliver(Language.SO_Create, Protos.CMsgSOSingleObject, object()); + expect(cs2.personalStore).toEqual(offer); + const claimed = { ...offer, redeemable_balance: 0, items: [] }; + deliver(Language.SO_Update, Protos.CMsgSOSingleObject, object(claimed)); + expect(cs2.personalStore).toEqual(claimed); + deliver(Language.SO_Destroy, Protos.CMsgSOSingleObject, { type_id: 4 }); + expect(cs2.personalStore).toBeNull(); + expect(update.mock.calls.map(([store]) => store)).toEqual([offer, claimed, null]); + }); + + it('handles batched updates and ignores unrelated types', () => { + const { cs2, deliver, object, update } = setup(); + deliver(Language.SO_UpdateMultiple, Protos.CMsgSOMultipleObjects, { + objects_modified: [object(), { type_id: 999, object_data: Buffer.from([255]) }] + }); + expect(cs2.personalStore).toEqual(offer); + expect(update).toHaveBeenCalledExactlyOnceWith(offer); + }); + + it('keeps the last valid store on malformed updates and continues welcome processing', () => { + const { cs2, deliver, welcome, object } = setup(); + const debug = vi.fn(); + cs2.on('debug', debug); + welcome([{ type_id: 4, object_data: Buffer.from([255]) }, object()]); + expect(cs2.personalStore).toEqual(offer); + deliver(Language.SO_Update, Protos.CMsgSOSingleObject, { type_id: 4, object_data: Buffer.from([255]) }); + expect(cs2.personalStore).toEqual(offer); + expect(debug.mock.calls.filter(([message]) => message.startsWith('Failed to decode personal store:'))).toHaveLength( + 2 + ); + }); + + it('clears stale state on a new welcome without a store', () => { + const { cs2, welcome, update } = setup(); + welcome(); + welcome([]); + expect(cs2.personalStore).toBeNull(); + expect(update).toHaveBeenLastCalledWith(null); + }); + + it.each(['disconnected', 'error', 'appQuit', 'gcDisconnect'])( + 'clears state on %s and reloads on reconnect', + (event) => { + const { steam, cs2, deliver, welcome, update } = setup(); + welcome(); + cs2._isInCSGO = true; + cs2._connect = vi.fn(); + if (event === 'gcDisconnect') { + deliver(Language.ClientConnectionStatus, Protos.CMsgConnectionStatus, { + status: NodeCS2.GCConnectionStatus.NO_SESSION + }); + } else { + steam.emit(event, event === 'appQuit' ? 730 : undefined); + } + expect(cs2.personalStore).toBeNull(); + expect(update).toHaveBeenLastCalledWith(null); + welcome(); + expect(cs2.personalStore).toEqual(offer); + } + ); + + it('represents omitted scalar fields as null and repeated items as an empty array', () => { + const { cs2, welcome, object } = setup(); + welcome([object({})]); + expect(cs2.personalStore).toEqual({ generation_time: null, redeemable_balance: null, items: [] }); + }); +}); + +describe('redeeming server-provided weekly rewards', () => { + it.each(['promise', 'callback'])( + 'encodes precise IDs and resolves the %s API only on reward confirmation', + async (mode) => { + const { steam, cs2, welcome, deliver } = setup(); + steam.steamID = new SteamID('76561198057249394'); + welcome(); + const callback = vi.fn(); + const { generation_time, redeemable_balance, items } = cs2.personalStore; + const result = cs2.redeemFreeReward( + generation_time, + redeemable_balance, + items, + mode === 'callback' ? callback : undefined + ); + const [, type, , payload] = steam.sendToGC.mock.calls[0]; + expect(type).toBe(Language.ClientRedeemFreeReward); + const request = Protos.CMsgGCCstrike15_v2_ClientRedeemFreeReward.decode(payload); + expect(request.generation_time).toBe(offer.generation_time); + expect(request.redeemable_balance).toBe(2); + expect(request.items.map(String)).toEqual(items); + cs2.emit('itemCustomizationNotification', ['11'], NodeCS2.ItemCustomizationNotification.UnlockCrate); + expect(cs2.listenerCount('itemCustomizationNotification')).toBe(1); + expect(callback).not.toHaveBeenCalled(); + deliver(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { + item_id: items, + request: NodeCS2.ItemCustomizationNotification.ClientRedeemFreeReward + }); + if (mode === 'callback') { + expect(result).toBeUndefined(); + expect(callback).toHaveBeenCalledExactlyOnceWith(null, items); + } else { + await expect(result).resolves.toEqual(items); + } + expect(cs2.listenerCount('itemCustomizationNotification')).toBe(0); + } + ); + + it.each(['promise', 'callback'])('cleans up the %s listener on timeout', async (mode) => { + vi.useFakeTimers(); + const { cs2 } = setup(); + cs2._send = vi.fn(); + const callback = vi.fn(); + const result = cs2.redeemFreeReward( + offer.generation_time, + 2, + offer.items, + mode === 'callback' ? callback : undefined + ); + const rejection = mode === 'promise' ? expect(result).rejects.toThrow('Redeeming free reward timed out') : null; + await vi.advanceTimersByTimeAsync(10000); + if (rejection) await rejection; + else + expect(callback).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'Redeeming free reward timed out' }) + ); + expect(cs2.listenerCount('itemCustomizationNotification')).toBe(0); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 6ad798c..eb1bc45 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -46,6 +46,12 @@ declare namespace NodeCS2 { // ─── Data Types ───────────────────────────────────────────────────────────── + interface PersonalStore { + generation_time: number | null; + redeemable_balance: number | null; + items: string[]; + } + interface StickerLike { slot: number; sticker_id: number; @@ -156,6 +162,7 @@ declare namespace NodeCS2 { disconnectedFromGC: (reason: GCConnectionStatus) => void; connectionStatus: (status: GCConnectionStatus, data: unknown) => void; accountData: (data: unknown) => void; + personalStoreUpdate: (store: PersonalStore | null) => void; matchList: (matches: unknown[], data: unknown) => void; inspectItemInfo: (item: ItemInfo) => void; inspectItemTimedOut: (assetid: string) => void; @@ -181,6 +188,7 @@ declare class NodeCS2 extends EventEmitter { haveGCSession: boolean; inventory: NodeCS2.EconItem[]; accountData: unknown; + personalStore: NodeCS2.PersonalStore | null; // Configurable timeouts _inspectTimeout: number; @@ -242,10 +250,10 @@ declare class NodeCS2 extends EventEmitter { redeemFreeReward( generationTime: number, redeemableBalance: number, - items: number[], + items: Array, callback: (error: Error | null, itemIds?: string[]) => void ): void; - redeemFreeReward(generationTime: number, redeemableBalance: number, items: number[]): Promise; + redeemFreeReward(generationTime: number, redeemableBalance: number, items: Array): Promise; redeemMissionReward( campaignId: number, redeemId: number,