src/process/trade.ts
/* global RESOURCES_ALL RESOURCE_ENERGY RESOURCE_POWER OK RESOURCE_OPS
ORDER_BUY ORDER_SELL PIXEL STORAGE_CAPACITY INTERSHARD_RESOURCES
REACTION_TIME */
import cache from 'utils/cache';
import hivemind from 'hivemind';
import Process from 'process/process';
import utilities from 'utilities';
import {ENEMY_STRENGTH_NORMAL} from 'room-defense';
// Minimum value for a trade. Would be cool if this was a game constant.
const minTradeValue = 0.001;
// Amount of credits to keep in reserve for creating orders.
const creditReserve = 10_000;
// Lookup for lab reaction recipes.
const recipes = utilities.getReactionRecipes();
type TradeResource = ResourceConstant | InterShardResourceConstant;
interface ResourceStates {
rooms: Record<string, RoomResourceState>;
total: {
rooms: number;
resources: Partial<Record<ResourceConstant, number>>;
sources: Partial<Record<ResourceConstant, number>>;
};
}
function isIntershardResource(resourceType: TradeResource): resourceType is InterShardResourceConstant {
return (INTERSHARD_RESOURCES as string[]).includes(resourceType);
}
/**
* Automatically trades resources on the open market.
*/
export default class TradeProcess extends Process {
availableCredits: number;
/**
* Buys and sells resources on the global market.
*/
run() {
if (!hivemind.settings.get('enableTradeManagement')) return;
// Only trade if we have a terminal to trade with.
if (_.size(_.filter(Game.myRooms, room => room.terminal)) === 0) return;
this.removeOldTrades();
this.availableCredits = Math.max(0, Game.market.credits - creditReserve);
const resources = this.getRoomResourceStates();
const total = resources.total;
const maxStorage = total.rooms * STORAGE_CAPACITY / 20;
const highStorage = total.rooms * STORAGE_CAPACITY / 50;
const lowStorage = total.rooms * Math.min(STORAGE_CAPACITY / 100, 20_000);
const minStorage = total.rooms * Math.min(STORAGE_CAPACITY / 200, 10_000);
for (const resourceType of RESOURCES_ALL) {
const tier = this.getResourceTier(resourceType);
if (resourceType === RESOURCE_ENERGY) {
// Buy energy for rooms under attack so we can hold out longer.
for (const room of Game.myRooms) {
if (room.getEffectiveAvailableEnergy() < 30_000 && room.defense.getEnemyStrength() >= ENEMY_STRENGTH_NORMAL) {
if (room.factory && room.terminal.store.getUsedCapacity(RESOURCE_ENERGY) > 500) {
this.instaBuyResources(RESOURCE_BATTERY, {[room.name]: resources.rooms[room.name]}, true);
}
else {
this.instaBuyResources(RESOURCE_ENERGY, {[room.name]: resources.rooms[room.name]}, true);
}
}
}
}
else if (tier === 1) {
// Check for base resources we have too much of.
if ((total.resources[resourceType] || 0) > maxStorage) {
this.instaSellResources(resourceType, resources.rooms);
}
if ((total.resources[resourceType] || 0) > highStorage) {
this.trySellResources(resourceType, resources.rooms);
}
// Check for base resources we're missing.
if ((total.resources[resourceType] || 0) < lowStorage && this.availableCredits > 0) {
this.tryBuyResources(resourceType, resources.rooms);
}
if ((total.resources[resourceType] || 0) < minStorage && this.availableCredits > 0) {
this.instaBuyResources(resourceType, resources.rooms);
}
}
else if (recipes[resourceType]) {
// Check if we can make a nice profit selling some boost compounds.
const resourceWorth = this.calculateWorth(resourceType);
if (resourceWorth) {
const history = this.getPriceData(resourceType);
if (history && history.average && history.average > resourceWorth) {
// Alright, looks like we can make a profit by selling this!
if ((total.resources[resourceType] || 0) > minStorage) {
this.instaSellResources(resourceType, resources.rooms);
}
if ((total.resources[resourceType] || 0) > minStorage) {
this.trySellResources(resourceType, resources.rooms);
}
}
}
}
}
if (this.availableCredits > 0 && hivemind.settings.get('allowBuyingEnergy')) {
// Also try to cheaply buy some energy for rooms that are low on it.
_.each(resources.rooms, (roomState: any, roomName: string) => {
if (!roomState.canTrade) return;
if (roomState.isEvacuating) return;
if ((roomState.totalResources[RESOURCE_ENERGY] || 0) > STORAGE_CAPACITY / 10) return;
// @todo Force creating a buy order for every affected room.
const temporary = {
[roomName]: roomState,
};
this.tryBuyResources(RESOURCE_ENERGY, temporary, true);
});
}
if (this.availableCredits > 0 && hivemind.settings.get('allowBuyingPixels')) {
// Try to buy pixels when price is low.
this.tryBuyResources(PIXEL);
this.instaBuyResources(PIXEL);
}
if (hivemind.settings.get('allowSellingPower')) {
// Sell excess power we can't apply to our account.
if ((total.resources[RESOURCE_POWER] || 0) > highStorage) {
this.instaSellResources(RESOURCE_POWER, resources.rooms);
}
if ((total.resources[RESOURCE_POWER] || 0) > lowStorage) {
this.trySellResources(RESOURCE_POWER, resources.rooms);
}
}
if (hivemind.settings.get('allowSellingOps')) {
// Sell excess ops.
if ((total.resources[RESOURCE_OPS] || 0) > lowStorage) {
this.instaSellResources(RESOURCE_OPS, resources.rooms);
}
if ((total.resources[RESOURCE_OPS] || 0) > minStorage) {
this.trySellResources(RESOURCE_OPS, resources.rooms);
}
}
}
/**
* Determines the amount of available resources in each room.
*
* @return {object}
* An object containing the following keys:
* - rooms: An array of objects containing resource states for each room.
* - roral: Sum of all resource levels of each room.
*/
getRoomResourceStates(): ResourceStates {
const rooms = {};
const total = {
resources: {},
sources: {},
rooms: 0,
};
for (const room of Game.myRooms) {
const roomData = room.getResourceState();
if (!roomData) continue;
total.rooms++;
for (const resourceType of _.keys(roomData.totalResources)) {
total.resources[resourceType] = (total.resources[resourceType] || 0) + roomData.totalResources[resourceType];
}
for (const mineralType of roomData.mineralTypes) {
total.sources[mineralType] = (total.sources[mineralType] || 0) + 1;
}
rooms[room.name] = roomData;
}
return {
rooms,
total,
};
}
/**
* Tries to find a reasonable buy order for instantly getting rid of some resources.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} rooms
* Resource states for rooms to check, keyed by room name.
*/
instaSellResources(resourceType: ResourceConstant, rooms: Record<string, RoomResourceState>) {
// Find room with highest amount of this resource.
const roomName = this.getHighestResourceState(resourceType, rooms);
if (!roomName) return;
const room = Game.rooms[roomName];
const bestOrder = this.findBestBuyOrder(resourceType, roomName);
const history = this.getPriceData(resourceType);
if (!bestOrder) return;
if (!history) return;
const minPrice = history.average + (history.stdDev / 2);
hivemind.log('trade', roomName).debug('Could sell', resourceType, 'for', bestOrder.price, '- we want at least', minPrice);
if (bestOrder.price < minPrice) return;
const amount = Math.min(this.getMaxOrderAmount(resourceType), bestOrder.amount);
const transactionCost = Game.market.calcTransactionCost(amount, roomName, bestOrder.roomName);
if (amount > (room.terminal.store[resourceType] || 0)) {
if (room.memory.fillTerminal) {
hivemind.log('trade', roomName).info('Busy, can\'t prepare', amount, resourceType, 'for selling.');
}
else {
room.prepareForTrading(resourceType, amount);
hivemind.log('trade', roomName).info('Preparing', amount, resourceType, 'for selling to', bestOrder.roomName, 'at', bestOrder.price, 'credits each, costing', transactionCost, 'energy');
}
return;
}
if (transactionCost > room.terminal.store.energy) {
if (room.memory.fillTerminal) {
hivemind.log('trade', roomName).info('Busy, can\'t prepare', transactionCost, 'energy for selling', amount, resourceType);
}
else {
room.prepareForTrading(RESOURCE_ENERGY, transactionCost);
hivemind.log('trade', roomName).info('Preparing', transactionCost, 'energy for selling', amount, resourceType, 'to', bestOrder.roomName, 'at', bestOrder.price, 'credits each');
}
return;
}
hivemind.log('trade', roomName).info('Selling', amount, resourceType, 'to', bestOrder.roomName, 'for', bestOrder.price, 'credits each, costing', transactionCost, 'energy');
const result = Game.market.deal(bestOrder.id, amount, roomName);
if (result !== OK) {
hivemind.log('trade', roomName).info('Transaction failed:', result);
}
}
/**
* Tries to find a reasonable sell order for instantly acquiring some resources.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} rooms
* Resource states for rooms to check, keyed by room name.
*/
instaBuyResources(resourceType: TradeResource, rooms?: Record<string, RoomResourceState>, force?: boolean) {
// Find room with lowest amount of this resource.
const roomName = isIntershardResource(resourceType) ? null : this.getLowestResourceState(resourceType, rooms);
if (!roomName && !isIntershardResource(resourceType)) return;
const room = Game.rooms[roomName];
const bestOrder = this.findBestSellOrder(resourceType, roomName);
const history = this.getPriceData(resourceType);
if (!bestOrder) return;
if (!history) return;
const maxPrice = history.average - Math.min(history.stdDev / 5, history.average * 0.1);
hivemind.log('trade', roomName).debug('Could buy', resourceType, 'for', bestOrder.price, '- we want to spend at most', maxPrice);
if (bestOrder.price > maxPrice && !force) return;
let amount = Math.min(force ? 10_000 : this.getMaxOrderAmount(resourceType), bestOrder.amount);
if (isIntershardResource(resourceType)) {
hivemind.log('trade', roomName).info('Buying', amount, resourceType, 'from', bestOrder.roomName, 'for', bestOrder.price, 'credits each.');
}
else {
let transactionCost = Game.market.calcTransactionCost(amount, roomName, bestOrder.roomName);
if (transactionCost > room.terminal.store.energy) {
if (room.memory.fillTerminal) {
hivemind.log('trade', roomName).info('Busy, can\'t prepare', transactionCost, 'energy for buying', amount, resourceType);
}
else {
room.prepareForTrading(RESOURCE_ENERGY, transactionCost);
hivemind.log('trade', roomName).info('Preparing', transactionCost, 'energy for buying', amount, resourceType, 'from', bestOrder.roomName, 'at', bestOrder.price, 'credits each');
}
if (force) {
amount = Math.floor(amount * room.terminal.store.energy / transactionCost);
transactionCost = Game.market.calcTransactionCost(amount, roomName, bestOrder.roomName);
}
else return;
}
hivemind.log('trade', roomName).info('Buying', amount, resourceType, 'from', bestOrder.roomName, 'for', bestOrder.price, 'credits each, costing', transactionCost, 'energy.');
}
const result = Game.market.deal(bestOrder.id, amount, roomName);
if (result !== OK) {
hivemind.log('trade', roomName).info('Transaction failed:', result);
}
}
/**
* Creates a buy order at a reasonable price.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} rooms
* Resource states for rooms to check, keyed by room name.
* @param {boolean} ignoreOtherRooms
* If set, only check agains orders from rooms given by `rooms` parameter.
*/
tryBuyResources(resourceType: TradeResource, rooms?: Record<string, RoomResourceState>, ignoreOtherRooms?: boolean) {
if (_.some(Game.market.orders, order => {
if (order.type === ORDER_BUY && order.resourceType === resourceType) {
if (ignoreOtherRooms && !rooms[order.roomName]) {
return false;
}
return true;
}
return false;
})) {
return;
}
// Find room with lowest amount of this resource.
const roomName = isIntershardResource(resourceType) ? null : this.getLowestResourceState(resourceType, rooms);
if (!roomName && !isIntershardResource(resourceType)) return;
// Find comparable deals for buying this resource.
const bestBuyOrder = this.findBestBuyOrder(resourceType, roomName);
const history = this.getPriceData(resourceType);
if (!history) return;
const maxPrice = history.average - Math.min(history.stdDev / 2, history.average * 0.2);
let offerPrice = maxPrice;
if (bestBuyOrder) {
// Adapt to the current buy price, if it's to our benefit.
hivemind.log('trade', roomName).info(resourceType, 'is currently being bought for', bestBuyOrder.price);
offerPrice = Math.min(offerPrice, bestBuyOrder.price * 1.01);
}
else {
// Nobody is buying this resource, try to get it for very cheap.
hivemind.log('trade', roomName).info('Nobody else is currently buying', resourceType);
offerPrice = history.average - Math.min(history.stdDev, history.average * 0.8);
}
hivemind.log('trade', roomName).debug('Could offer to buy', resourceType, 'for', offerPrice, '- we want to spend at most', maxPrice);
if (offerPrice > maxPrice) return;
if (offerPrice < minTradeValue) offerPrice = minTradeValue;
const amount = this.getMaxOrderAmount(resourceType);
// Make sure we have enough credits to actually buy this.
if (this.availableCredits < amount * offerPrice) return;
hivemind.log('trade', roomName).debug('Offering to buy for', offerPrice);
const result = Game.market.createOrder({
type: ORDER_BUY,
resourceType,
price: offerPrice,
totalAmount: amount,
roomName,
});
if (result !== OK) {
hivemind.log('trade', roomName).error('Could not create buy order:', result);
}
}
/**
* Creates a sell order at a reasonable price.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} rooms
* Resource states for rooms to check, keyed by room name.
*/
trySellResources(resourceType: ResourceConstant, rooms: Record<string, RoomResourceState>) {
if (_.some(Game.market.orders, order => order.type === ORDER_SELL && order.resourceType === resourceType)) {
return;
}
// Find room with highest amount of this resource.
const roomName = this.getHighestResourceState(resourceType, rooms);
if (!roomName) return;
// Find comparable deals for selling this resource.
const bestSellOrder = this.findBestSellOrder(resourceType, roomName);
const history = this.getPriceData(resourceType);
if (!history) return;
const minPrice = history.average + (history.stdDev / 2);
let offerPrice = minPrice;
if (bestSellOrder) {
// Adapt to the current sale price if it's to our benefit.
hivemind.log('trade', roomName).info(resourceType, 'is currently being sold for', bestSellOrder.price);
offerPrice = Math.min(Math.max(offerPrice, bestSellOrder.price * 0.99), (history.average * 1.5) + (history.stdDev * 2));
}
else {
// Nobody is selling this resource, try to get a greedy price for it.
hivemind.log('trade', roomName).info('Nobody else is currently selling', resourceType);
offerPrice = (history.average * 1.5) + (history.stdDev * 2);
}
hivemind.log('trade', roomName).debug('Could offer to sell', resourceType, 'for', offerPrice, '- we want at least', minPrice);
if (offerPrice < minPrice) return;
const amount = this.getMaxOrderAmount(resourceType);
// Make sure we have enough credits to actually sell this, otherwise try
// filling other player's orders.
if (Game.market.credits < amount * offerPrice * 0.05) {
this.instaSellResources(resourceType, rooms);
return;
}
hivemind.log('trade', roomName).debug('Offering to sell for', offerPrice);
Game.market.createOrder({
type: ORDER_SELL,
resourceType,
price: offerPrice,
totalAmount: amount,
roomName,
});
}
/**
* Finds the room in a list that has the lowest amount of a resource.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} rooms
* Resource states for rooms to check, keyed by room name.
*
* @return {string}
* Name of the room with the lowest resource amount.
*/
getLowestResourceState(resourceType: ResourceConstant, rooms: Record<string, RoomResourceState>) {
let minAmount;
let bestRoom;
_.each(rooms, (roomState: RoomResourceState, roomName: string) => {
if (!roomState.canTrade) return;
if (Game.rooms[roomName] && Game.rooms[roomName].isFullOn(resourceType)) return;
if (!minAmount || (roomState.totalResources[resourceType] || 0) < minAmount) {
minAmount = roomState.totalResources[resourceType];
bestRoom = roomName;
}
});
return bestRoom;
}
/**
* Finds the room in a list that has the highest amount of a resource.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} rooms
* Resource states for rooms to check, keyed by room name.
*
* @return {string}
* Name of the room with the highest resource amount.
*/
getHighestResourceState(resourceType: ResourceConstant, rooms: Record<string, RoomResourceState>) {
let maxAmount;
let bestRoom;
_.each(rooms, (roomState: RoomResourceState, roomName: string) => {
if (!roomState.canTrade) return;
if (!maxAmount || (roomState.totalResources[resourceType] || 0) > maxAmount) {
maxAmount = roomState.totalResources[resourceType];
bestRoom = roomName;
}
});
return bestRoom;
}
/**
* Finds best buy order of another player to sell a certain resource.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} roomName
* Name of the room that serves as a base for this transaction.
*
* @return {object}
* The order as returned from Game.market.
*/
findBestBuyOrder(resourceType: TradeResource, roomName: string): Order {
// Find best deal for selling this resource.
const orders = Game.market.getAllOrders(order => order.type === ORDER_BUY && order.resourceType === resourceType);
let maxScore: number;
let bestOrder: Order;
_.each(orders, order => {
if (order.amount < 100) return;
const transactionCost = isIntershardResource(resourceType) ? 0 : Game.market.calcTransactionCost(1000, roomName, order.roomName);
const credits = 1000 * order.price;
const score = credits - (0.3 * transactionCost);
if (!maxScore || score > maxScore) {
maxScore = score;
bestOrder = order;
}
});
return bestOrder;
}
/**
* Finds best sell order of another player to buy a certain resource.
*
* @param {string} resourceType
* The type of resource to trade.
* @param {object} roomName
* Name of the room that serves as a base for this transaction.
*
* @return {object}
* The order as returned from Game.market.
*/
findBestSellOrder(resourceType: TradeResource, roomName: string) {
// Find best deal for buying this resource.
const orders = Game.market.getAllOrders(order => order.type === ORDER_SELL && order.resourceType === resourceType);
let minScore;
let bestOrder;
_.each(orders, order => {
if (order.amount < 100) return;
const transactionCost = isIntershardResource(resourceType) ? 0 : Game.market.calcTransactionCost(1000, roomName, order.roomName);
const credits = 1000 * order.price;
const score = credits + (0.3 * transactionCost);
if (!minScore || score < minScore) {
minScore = score;
bestOrder = order;
}
});
return bestOrder;
}
/**
* Removes outdated orders from the market.
*/
removeOldTrades() {
_.each(Game.market.orders, order => {
const age = Game.time - order.created;
if (age > 100_000 || order.remainingAmount === 0) {
// Nobody seems to be buying or selling this order, cancel it.
hivemind.log('trade', order.roomName).debug('Cancelling old trade', order.type + 'ing', order.remainingAmount, order.resourceType, 'for', order.price, 'each after', age, 'ticks.');
Game.market.cancelOrder(order.id);
}
});
}
/**
* Assigns a "tier" to a resource, giving it a base value.
*
* @param {string} resourceType
* The type of resource to check.
*
* @return {number}
* The general trade value we assign the given resource.
*/
getResourceTier(resourceType: ResourceConstant) {
if (resourceType === RESOURCE_ENERGY) return 0;
if (resourceType === RESOURCE_POWER) return 10;
const tier = resourceType.length;
if (resourceType.includes('G')) {
return tier + 3;
}
return tier;
}
/**
* Decides how much resource should be traded at once.
*
* This is to make sure we don't pay huge amounts on market fees for trades
* that will never be completed.
*
* @param {String} resourceType
* The resource type for which we need information.
*
* @return {Number}
* Maximum amount of this resource to trade in a single transaction.
*/
getMaxOrderAmount(resourceType: TradeResource): number {
const history = this.getPriceData(resourceType);
if (!history) return 0;
if (history.average < 10 && history.total > 10_000) return 10_000;
if (history.average < 100 && history.total > 1000) return 1000;
if (history.average < 1000 && history.total > 100) return 100;
if (history.average < 10_000 && history.total > 10) return 10;
return 1;
}
/**
* Analyzes market history to decide on resource price.
*
* @param {String} resourceType
* The resource type for which we need information.
*
* @return {Object}
* An object with price data containing the following keys:
* - total: Amount of this resource traded recently.
* - average: Adjusted average price of this resource.
* - stdDev: Adjusted standard deviation for this resource's price.
*/
getPriceData(resourceType: TradeResource): {total: number; average: number; stdDev: number} {
return cache.inHeap('price:' + resourceType, 5000, () => {
const history = Game.market.getHistory(resourceType);
// There needs to be a few days of price data before we consider dealing.
if (history.length < 4) return null;
// Find days with highest and lowest deal values.
const minDay = _.min(history, 'avgPrice');
const maxDay = _.max(history, 'avgPrice');
const maxDev = _.max(history, 'stddevPrice');
let count = 0;
let totalValue = 0;
let totalDev = 0;
_.each(history, day => {
// Skip days with highest and lowest deal values as outliers.
if (day.date === minDay.date) return;
if (day.date === maxDay.date) return;
if (day.date === maxDev.date) return;
if (day.resourceType !== resourceType) return;
count += day.volume;
totalValue += day.volume * day.avgPrice;
totalDev += day.volume * day.stddevPrice;
});
return {
total: count,
average: totalValue / count,
stdDev: totalDev / count,
};
});
}
/**
* Calculates estimated worth of lab reaction compounds.
*
* @param {String} resourceType
* The resource type for which we need information.
*
* @return {Number}
* Estimated worth of the given resource in credits.
*/
calculateWorth(resourceType: TradeResource): number {
return cache.inHeap('resourceWorth:' + resourceType, 5000, () => {
const history = this.getPriceData(resourceType);
if (!recipes[resourceType]) {
return history ? history.average : 0;
}
const reagentWorth = _.reduce(recipes[resourceType], (total: number, componentType: ResourceConstant) => {
const componentWorth = this.calculateWorth(componentType);
const componentHistory = this.getPriceData(componentType);
return total + Math.max(componentWorth, componentHistory ? componentHistory.average : 0);
}, 0);
// Add 0.1% to price for each tick needed to produce this reagent.
return reagentWorth * (1 + (0.001 * (REACTION_TIME[resourceType] || 0)));
});
}
}