UNPKG

tf2

Version:

Exposes a simple API for interacting with the Team Fortress 2 game coordinator

380 lines (310 loc) 12.1 kB
const ByteBuffer = require('bytebuffer'); const {HttpClient} = require('@doctormckay/stdlib/http'); const SteamID = require('steamid'); const VDF = require('kvparser'); const TeamFortress2 = require('./index.js'); const Language = require('./language.js'); const Schema = require('./protobufs/generated/_load.js'); const handlers = TeamFortress2.prototype._handlers; // ClientWelcome, ServerWelcome, ClientGoodbye, and ServerGoodbye handlers[Language.ClientWelcome] = function(body) { let proto = decodeProto(Schema.CMsgClientWelcome, body); this.haveGCSession = true; this.emit('connectedToGC', proto.version); }; handlers[Language.ServerWelcome] = function(body) { let proto = decodeProto(Schema.CMsgServerWelcome, body); this.haveGCSession = true; this.emit('connectedToGC', proto.active_version); }; handlers[Language.ClientGoodbye] = function(body) { let proto = decodeProto(Schema.CMsgClientGoodbye, body); if (this.haveGCSession) { this._connect(); // Try to reconnect this.haveGCSession = false; } this.emit('disconnectedFromGC', proto.reason); }; handlers[Language.ServerGoodbye] = function(body) { let proto = decodeProto(Schema.CMsgServerGoodbye, body); if (this.haveGCSession) { this._connect(); // Try to reconnect this.haveGCSession = false; } this.emit('disconnectedFromGC', proto.reason); }; // Item schema handlers[Language.UpdateItemSchema] = async function(body) { try { let proto = decodeProto(Schema.CMsgUpdateItemSchema, body); this.emit('itemSchema', proto.item_schema_version.toString(16).toUpperCase(), proto.items_game_url); let client = new HttpClient(); let result = await client.request({ method: 'get', url: proto.items_game_url }); if (result.statusCode != 200) { throw new Error(`HTTP error ${result.statusCode}`); } this.itemSchema = VDF.parse(result.textBody).items_game; this.emit('itemSchemaLoaded'); } catch (err) { this.emit('debug', `Unable to download items_game.txt: ${err.message}`); this.emit('itemSchemaError', err); } }; // Various notifications (why do we need three distinct interfaces??) handlers[Language.SystemMessage] = function(body) { let proto = decodeProto(Schema.CMsgSystemBroadcast, body); this.emit('systemMessage', proto.message); }; handlers[Language.ClientDisplayNotification] = function(body) { if (!this.lang) { // We only handle this if we have a localization file return; } let proto = decodeProto(Schema.CMsgGCClientDisplayNotification, body); let title = this.lang[proto.notification_title_localization_key.substring(1)]; let text = (this.lang[proto.notification_body_localization_key.substring(1)] || '').replace(new RegExp('[\u0001|\u0002]', 'g'), ''); text = text.replace(/\\"/g, '"'); // The vdf parser appears to not properly parse escaped quotes let replacement; for (let i = 0; i < proto.body_substring_keys.length; i++) { replacement = proto.body_substring_values[i]; if (replacement.charAt(0) == '#') { replacement = this.lang[replacement.substring(1)]; } text = text.replace('%' + proto.body_substring_keys[i] + '%', replacement); } this.emit('displayNotification', title, text); }; handlers[Language.TFSpecificItemBroadcast] = function(body) { let proto = decodeProto(Schema.CMsgGCTFSpecificItemBroadcast, body); let defindex = proto.item_def_index; let message = null; if (this.lang && this.itemSchema) { let item = this.itemSchema.items[defindex] || {}; let itemNameKey = item.item_name || ''; let itemName = this.lang[itemNameKey.substring(1)]; let localizationKey = proto.was_destruction ? "TF_Event_Item_Deleted" : "TF_Event_Item_Created"; message = this.lang[localizationKey].replace('%owner%', proto.user_name).replace('%item_name%', itemName); } this.emit('itemBroadcast', message, proto.user_name, proto.was_destruction, defindex); }; // Trading handlers[Language.Trading_InitiateTradeRequest] = function(body) { let tradeID = body.readUint32(); let steamID = new SteamID(body.readUint64().toString()); this.emit('tradeRequest', steamID, tradeID); }; handlers[Language.Trading_InitiateTradeResponse] = function(body) { let response = body.readUint32(); let tradeID = body.readUint32(); this.emit('debug', "Got trade response " + response + " for " + tradeID); this.emit('tradeResponse', response, tradeID); }; // SO handlers[Language.SO_CacheSubscriptionCheck] = function(body) { this.emit('debug', "Requesting SO cache subscription refresh"); this._send(Language.SO_CacheSubscriptionRefresh, Schema.CMsgSOCacheSubscriptionRefresh, {"owner": this._steam.steamID.getSteamID64()}); }; handlers[Language.SO_CacheSubscribed] = function(body) { let proto = decodeProto(Schema.CMsgSOCacheSubscribed, body); proto.objects.forEach((cache) => { switch (cache.type_id) { case 1: // Backpack let items = cache.object_data.map((object) => { let item = decodeProto(Schema.CSOEconItem, object); let isNew = (item.inventory >>> 30) & 1; item.position = (isNew ? 0 : item.inventory & 0xFFFF); return item; }); this.backpack = items; this.emit('backpackLoaded'); break; case 7: // Account metadata let data = decodeProto(Schema.CSOEconGameAccountClient, cache.object_data[0]); this.premium = !data.trial_account; this.backpackSlots = (data.trial_account ? 50 : 300) + data.additional_backpack_slots; this.canSendProfessorSpeks = data.need_to_choose_most_helpful_friend; this.emit('accountLoaded'); break; default: this.emit('debug', "Unknown SO type " + cache.type_id + " with " + cache.object_data.length + " items"); break; } }); }; handlers[Language.SO_Create] = function(body) { let proto = decodeProto(Schema.CMsgSOSingleObject, body); if (proto.type_id != 1) { return; // Not an item } if (!this.backpack) { return; // We don't have our backpack yet! } let item = decodeProto(Schema.CSOEconItem, proto.object_data); item.position = item.inventory & 0x0000FFFF; this.backpack.push(item); this.emit('itemAcquired', item); }; handlers[Language.SO_Update] = function(body) { let proto = decodeProto(Schema.CMsgSOSingleObject, body); this._handleSOUpdate(proto); }; handlers[Language.SO_UpdateMultiple] = function(body) { let items = decodeProto(Schema.CMsgSOMultipleObjects, body).objects; items.forEach((item) => { this._handleSOUpdate(item); }); }; TeamFortress2.prototype._handleSOUpdate = function(so) { switch (so.type_id) { case 1: if (!this.backpack) { return; // We don't have our backpack yet! } let item = decodeProto(Schema.CSOEconItem, so.object_data); item.position = item.inventory & 0x0000FFFF; for (let i = 0; i < this.backpack.length; i++) { if (this.backpack[i].id == item.id) { let oldItem = this.backpack[i]; this.backpack[i] = item; this.emit('itemChanged', oldItem, item); break; } } break; case 7: let data = decodeProto(Schema.CSOEconGameAccountClient, so.object_data); let oldData = { "premium": this.premium, "backpackSlots": this.backpackSlots, "canSendProfessorSpeks": this.canSendProfessorSpeks }; this.premium = !data.trial_account; this.backpackSlots = (data.trial_account ? 50 : 300) + data.additional_backpack_slots; this.canSendProfessorSpeks = data.need_to_choose_most_helpful_friend; let changed = {}; let somethingHasChanged = false; for (let i in oldData) { if (this[i] != oldData[i]) { somethingHasChanged = true; changed[i] = oldData[i]; } } if (somethingHasChanged) { // Only emit the event if a property that we're tracking changes this.emit('accountUpdate', changed); } break; default: this.emit('debug', "Unknown SO type " + so.type_id + " updated"); break; } }; handlers[Language.SO_Destroy] = function(body) { let proto = decodeProto(Schema.CMsgSOSingleObject, body); if (proto.type_id != 1) { return; // Not an item } if (!this.backpack) { return; // We don't have our backpack yet } let item = decodeProto(Schema.CSOEconItem, proto.object_data); let itemData = null; for (let i = 0; i < this.backpack.length; i++) { if (this.backpack[i].id == item.id) { itemData = this.backpack[i]; this.backpack.splice(i, 1); break; } } this.emit('itemRemoved', itemData); }; // Item manipulation handlers[Language.CraftResponse] = function(body) { let blueprint = body.readInt16(); // recipe ID let unknown = body.readUint32(); // always 0 in my experience let idCount = body.readUint16(); let idList = []; // let's form an array of IDs for (let i = 0; i < idCount; i++) { let id = body.readUint64().toString(); // grab the next id idList.push(id); // item id } this.emit('craftingComplete', blueprint, idList); }; // Professor Speks handlers[Language.FreeTrial_ThankedBySomeone] = function(body) { let proto = decodeProto(Schema.CMsgTFThankedBySomeone, body); this.emit('professorSpeksReceived', new SteamID(proto.thanker_steam_id)); }; handlers[Language.FreeTrial_ThankedSomeone] = function(body) { this.emit('professorSpeksSent'); }; // Game Servers handlers[Language.GameServer_CreateIdentityResponse] = function(body) { let proto = decodeProto(Schema.CMsgGC_GameServer_CreateIdentityResponse, body); this.emit('createIdentity', proto.status, proto.account_created, proto.game_server_account_id, proto.game_server_identity_token); }; handlers[Language.GameServer_ListResponse] = function(body) { let proto = decodeProto(Schema.CMsgGC_GameServer_ListResponse, body); this.emit('registeredServers', proto.owned_game_servers || []); }; handlers[Language.GameServer_ResetIdentityResponse] = function(body) { let proto = decodeProto(Schema.CMsgGC_GameServer_ResetIdentityResponse, body); this.emit('resetIdentity', proto.game_server_identity_token_reset, proto.game_server_account_id, proto.game_server_identity_token); }; // Spy vs. Engi War handlers[Language.War_GlobalStatsResponse] = function(body) { let proto = decodeProto(Schema.CGCMsgGC_War_GlobalStatsResponse, body); let mySides = {}; // they are in orbit (proto.side_scores || []).forEach((side) => { mySides[side.side] = side.score; }); this.emit('warStats', mySides); }; function decodeProto(proto, encoded) { if (ByteBuffer.isByteBuffer(encoded)) { encoded = encoded.toBuffer(); } let decoded = proto.decode(encoded); let objNoDefaults = proto.toObject(decoded, {"longs": String}); let objWithDefaults = proto.toObject(decoded, {"defaults": true, "longs": String}); return replaceDefaults(objNoDefaults, objWithDefaults); function replaceDefaults(noDefaults, withDefaults) { if (Array.isArray(withDefaults)) { return withDefaults.map((val, idx) => replaceDefaults(noDefaults[idx], val)); } for (let i in withDefaults) { if (!withDefaults.hasOwnProperty(i)) { continue; } if (withDefaults[i] && typeof withDefaults[i] === 'object' && !Buffer.isBuffer(withDefaults[i])) { // Covers both object and array cases, both of which will work // Won't replace empty arrays, but that's desired behavior withDefaults[i] = replaceDefaults(noDefaults[i], withDefaults[i]); } else if (typeof noDefaults[i] === 'undefined' && isReplaceableDefaultValue(withDefaults[i])) { withDefaults[i] = null; } } return withDefaults; } function isReplaceableDefaultValue(val) { if (Buffer.isBuffer(val) && val.length == 0) { // empty buffer is replaceable return true; } if (Array.isArray(val)) { // empty array is not replaceable (empty repeated fields) return false; } if (val === '0') { // Zero as a string is replaceable (64-bit integer) return true; } // Anything falsy is true return !val; } }