Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: Better SmartSupply #1623

Open
wants to merge 10 commits into
base: dev
Choose a base branch
from
150 changes: 53 additions & 97 deletions src/Corporation/Division.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PartialRecord, getRecordEntries, getRecordKeys, getRecordValues } from
import { Material } from "./Material";
import { getKeyList } from "../utils/helpers/getKeyList";
import { calculateMarkupMultiplier } from "./helpers";
import { smartSupply, getProductionCapacity } from "./SmartSupply";

interface DivisionParams {
name: string;
Expand Down Expand Up @@ -290,114 +291,66 @@ export class Division {
this.getScientificResearchMultiplier();

// Employee pay is an expense even with no warehouse.
expenses += office.totalSalary;
expenses += office.totalSalary; // Ann.: Might need to be multiplied by marketCycles

const warehouse = this.warehouses[city];
if (!warehouse) continue;

switch (state) {
case "PURCHASE": {
const smartBuy: PartialRecord<CorpMaterialName, [buyAmt: number, reqMat: number]> = {};

/* Process purchase of materials, not from smart supply */
for (const [matName, mat] of getRecordEntries(warehouse.materials)) {
const reqMat = this.requiredMaterials[matName];
if (warehouse.smartSupplyEnabled && reqMat) {
// Smart supply
mat.buyAmount = reqMat * warehouse.smartSupplyStore;
let buyAmt = mat.buyAmount * corpConstants.secondsPerMarketCycle * marketCycles;
const maxAmt = Math.floor((warehouse.size - warehouse.sizeUsed) / MaterialInfo[matName].size);
buyAmt = Math.min(buyAmt, maxAmt);
if (buyAmt > 0) smartBuy[matName] = [buyAmt, reqMat];
} else {
// Not smart supply
let buyAmt = 0;
let maxAmt = 0;

buyAmt = mat.buyAmount * corpConstants.secondsPerMarketCycle * marketCycles;

maxAmt = Math.floor((warehouse.size - warehouse.sizeUsed) / MaterialInfo[matName].size);

buyAmt = Math.min(buyAmt, maxAmt);
if (buyAmt > 0) {
mat.quality = Math.max(0.1, (mat.quality * mat.stored + 1 * buyAmt) / (mat.stored + buyAmt));
mat.averagePrice = (mat.stored * mat.averagePrice + buyAmt * mat.marketPrice) / (mat.stored + buyAmt);
mat.stored += buyAmt;
expenses += buyAmt * mat.marketPrice;
}
this.updateWarehouseSizeUsed(warehouse);
}
} //End process purchase of materials

// Find which material were trying to create the least amount of product with.
let worseAmt = 1e99;
for (const [buyAmt, reqMat] of Object.values(smartBuy)) {
const amt = buyAmt / reqMat;
if (amt < worseAmt) worseAmt = amt;
}

// Align all the materials to the smallest amount.
for (const buyArray of Object.values(smartBuy)) {
buyArray[0] = worseAmt * buyArray[1];
}

// Calculate the total size of all things were trying to buy
let totalSize = 0;
for (const [matName, [buyAmt]] of getRecordEntries(smartBuy)) {
if (buyAmt === undefined) throw new Error(`Somehow smartbuy matname is undefined`);
totalSize += buyAmt * MaterialInfo[matName].size;
}

// Shrink to the size of available space.
const freeSpace = warehouse.size - warehouse.sizeUsed;
if (totalSize > freeSpace) {
// Multiplier applied to buy amounts to not overfill warehouse
const buyMult = freeSpace / totalSize;
for (const buyArray of Object.values(smartBuy)) {
buyArray[0] = Math.floor(buyArray[0] * buyMult);
}
}

// Use the materials already in the warehouse if the option is on.
for (const [matName, buyArray] of getRecordEntries(smartBuy)) {
if (warehouse.smartSupplyOptions[matName] === "none") continue;
const buy = (warehouse: Warehouse, matName: CorpMaterialName, buyAmt: number) => {
const mat = warehouse.materials[matName];
if (warehouse.smartSupplyOptions[matName] === "leftovers") {
buyArray[0] = Math.max(0, buyArray[0] - mat.stored);
} else {
buyArray[0] = Math.max(
0,
buyArray[0] - mat.importAmount * corpConstants.secondsPerMarketCycle * marketCycles,
);
const size = MaterialInfo[matName].size;
const maxAmt = Math.floor((warehouse.size - warehouse.sizeUsed) / size);
buyAmt = Math.min(buyAmt, maxAmt);
if (buyAmt > 0) {
mat.quality = Math.max(0.1, (mat.quality * mat.stored + 1 * buyAmt) / (mat.stored + buyAmt));
mat.averagePrice = (mat.stored * mat.averagePrice + buyAmt * mat.marketPrice) / (mat.stored + buyAmt);
mat.stored += buyAmt;
warehouse.updateMaterialSizeUsed();
return buyAmt * mat.marketPrice;
}
}
return 0;
};

// buy them
for (const [matName, [buyAmt]] of getRecordEntries(smartBuy)) {
const mat = warehouse.materials[matName];
if (mat.stored + buyAmt !== 0) {
mat.quality = (mat.quality * mat.stored + 1 * buyAmt) / (mat.stored + buyAmt);
mat.averagePrice = (mat.averagePrice * mat.stored + mat.marketPrice * buyAmt) / (mat.stored + buyAmt);
} else {
mat.quality = 1;
mat.averagePrice = mat.marketPrice;
}
mat.stored += buyAmt;
mat.buyAmount = buyAmt / (corpConstants.secondsPerMarketCycle * marketCycles);
expenses += buyAmt * mat.marketPrice;
const getMaxProd = (office: OfficeSpace, forProduct: boolean) =>
this.getOfficeProductivity(office, { forProduct }) *
this.productionMult *
corporation.getProductionMultiplier() *
this.getProductionMultiplier();
const passedSeconds = corpConstants.secondsPerMarketCycle * marketCycles;

const pBaseOutput = getMaxProd(office, false) * passedSeconds;
const pBaseProduct = getMaxProd(office, true) * passedSeconds;

// Determine average size of materials produced by this city's office
// and calculate production capacity
const [avgSize, pOutput, pProduct] = getProductionCapacity(
warehouse,
this.producedMaterials,
this.products,
pBaseOutput,
pBaseProduct,
);

// Set buy amounts automatically, when smartSupply is enabled
smartSupply(warehouse, this.requiredMaterials, pOutput + pProduct, avgSize, passedSeconds);

/* Process purchase of materials*/
for (const [matName, mat] of getRecordEntries(warehouse.materials)) {
const buyAmt = mat.buyAmount * corpConstants.secondsPerMarketCycle * marketCycles;
expenses += buy(warehouse, matName, buyAmt);
}
break;
}
case "PRODUCTION":
warehouse.smartSupplyStore = 0; //Reset smart supply amount

/* Process production of materials */
if (this.producedMaterials.length > 0) {
// Ann.: This causes the production limit of the first material to be used for all materials
const mat = warehouse.materials[this.producedMaterials[0]];
//Calculate the maximum production of this material based
//on the office's productivity
const maxProd =
this.getOfficeProductivity(office) *
this.getOfficeProductivity(office) * // on the office's productivity
this.productionMult * // Multiplier from materials
corporation.getProductionMultiplier() *
this.getProductionMultiplier(); // Multiplier from Research
Expand All @@ -407,7 +360,6 @@ export class Division {
prod = mat.productionLimit === null ? maxProd : Math.min(maxProd, mat.productionLimit);

prod *= corpConstants.secondsPerMarketCycle * marketCycles; //Convert production from per second to per market cycle

// Calculate net change in warehouse storage making the produced materials will cost
let totalMatSize = 0;
for (let tmp = 0; tmp < this.producedMaterials.length; ++tmp) {
Expand All @@ -418,6 +370,15 @@ export class Division {
}
// If not enough space in warehouse, limit the amount of produced materials
if (totalMatSize > 0) {
/*
TODO: Consider rewriting this to optimize production throughput.
Space freed up by consuming materials can be used to produce more materials.
However, full production might hinder imports if not properly accounted for.
Therefore, it may be beneficial to allow the player to set a limit on the warehouse for production.
For example: Production Space = CAPACITY - IMPORTS.
This needs to be discussed, as smartSupply should use the same logic as production but currently assumes full production.
The calculation logic from smartSupply.ts/getProductionCapacity can be reused here.
*/
const maxAmt = Math.floor((warehouse.size - warehouse.sizeUsed) / totalMatSize);
prod = Math.min(maxAmt, prod);
}
Expand All @@ -426,9 +387,6 @@ export class Division {
prod = 0;
}

// Keep track of production for smart supply (/s)
warehouse.smartSupplyStore += prod / (corpConstants.secondsPerMarketCycle * marketCycles);

// Make sure we have enough resource to make our materials
let producableFrac = 1;
for (const [reqMatName, reqMat] of getRecordEntries(this.requiredMaterials)) {
Expand Down Expand Up @@ -777,12 +735,10 @@ export class Division {

//If there's not enough space in warehouse, limit the amount of Product
if (netStorageSize > 0) {
const maxAmt = Math.floor((warehouse.size - warehouse.sizeUsed) / netStorageSize);
const maxAmt = Math.floor((warehouse.size - warehouse.sizeUsed) / netStorageSize); // ToDO: Use geometric series
prod = Math.min(maxAmt, prod);
}

warehouse.smartSupplyStore += prod / (corpConstants.secondsPerMarketCycle * marketCycles);

//Make sure we have enough resources to make our Products
let producableFrac = 1;
for (const [reqMatName, reqQty] of getRecordEntries(product.requiredMaterials)) {
Expand Down
166 changes: 166 additions & 0 deletions src/Corporation/SmartSupply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { JSONMap } from "../Types/Jsonable";
import { PartialRecord } from "../Types/Record";
import { CorpMaterialName } from "./Enums";
import { MaterialInfo } from "./MaterialInfo";
import { Product } from "./Product";
import { Warehouse } from "./Warehouse";

type RatioData = {
matName: CorpMaterialName | null;
ratio: number;
};

function normalize(ratios: RatioData[]): void {
const total = ratios.reduce((sum, item) => sum + item.ratio, 0);
ratios.forEach((item) => {
item.ratio /= total; // when total is 0, normalize has undefined behavior
});
}

// This function calculates the volume of an input material that shell be used for production.
// When the smart supply option is set to "imports", it returns the amount of materials that are being imported.
// When the smart supply option is set to "leftovers", it returns the amount of materials that are already in the warehouse.
// Otherwise, it returns 0.
function getSmartSupplyUsableMaterial(warehouse: Warehouse, mname: CorpMaterialName, passedSeconds: number): number {
const supplyOption = warehouse.smartSupplyOptions[mname];
const material = warehouse.materials[mname];
let amount = 0;
if (supplyOption === "imports") {
// Use atleast the amount of materials that are being stored, but no more than the amount that is being imported.
amount = Math.min(material.importAmount * passedSeconds, material.stored);
} else if (supplyOption === "leftovers") {
amount = material.stored;
}
return MaterialInfo[mname].size * amount;
}

// This function calculates the production capacity for output materials and products in a city.
// It takes into account the production limits of both the materials and the products.
// Additionally, it calculates the average size of all output materials and products.
// Returns [averageSize, output capacity, product capacity]
export function getProductionCapacity(
warehouse: Warehouse,
producedMaterials: CorpMaterialName[],
products: JSONMap<string, Product>,
prodMultOutput: number,
prodMultProducts: number,
): [number, number, number] {
if (prodMultOutput <= 0 || prodMultProducts <= 0) {
return [0, 0, 0]; // should not happen
}

let totalOutputProdVolume = 0;
let totalProductProdVolume = 0;
let totalOutputProd = 0;
let totalProductProd = 0;

// Production limit is currently broken
// The first lines are how it should be imo, but the third line is how it is currently in the game
// const getMatProductionLimit = (mat: CorpMaterialName) => warehouse.materials[mat].productionLimit ?? Infinity;
// const getMatProductionLimit = (mat: CorpMaterialName) => Math.min(...producedMaterials.map((mat) => warehouse.materials[mat].productionLimit!)) ?? Infinity;
const getMatProductionLimit = (__: any) => warehouse.materials[producedMaterials[0]].productionLimit ?? Infinity;
const getProductProductionLimit = (product: Product) =>
product?.cityData[warehouse.city]?.productionLimit ?? Infinity;

producedMaterials.forEach((mat) => {
const limit = Math.min(getMatProductionLimit(mat), prodMultOutput);
totalOutputProdVolume += limit * MaterialInfo[mat].size;
totalOutputProd += limit / producedMaterials.length; // output materials are produced together
});

products.forEach((product) => {
if (product.finished) {
const limit = Math.min(getProductProductionLimit(product), prodMultProducts);
totalProductProdVolume += limit * product.size;
totalProductProd += limit;
}
});

// avg Out+Prod size = TotalVolume / TotalProduction
const averageSize = (totalOutputProdVolume + totalProductProdVolume) / (totalOutputProd + totalProductProd);
return [averageSize, totalOutputProd, totalProductProd];
}

// Smart Supply Algorithm
// This function calculates the amount of materials to buy for production.
// It takes into account the production capacity, the amount of materials already in the warehouse,
// the available empty space in the warehouse, and the amount of materials that are being imported.
export function smartSupply(
warehouse: Warehouse,
requiredMaterials: PartialRecord<CorpMaterialName, number>,
productionCapacity: number,
averageOutSize: number,
passedSeconds: number,
) {
if (!warehouse.smartSupplyEnabled || Object.keys(requiredMaterials).length === 0) {
return;
}

// 1. Calculate the space that can be used for input materials, considering the space needed for products and imported materials.
const totalImportSpace = Object.keys(warehouse.materials).reduce(
(total, key) =>
total +
warehouse.materials[key as CorpMaterialName].importAmount *
MaterialInfo[key as CorpMaterialName].size *
passedSeconds,
0,
);

const free = warehouse.size - totalImportSpace - warehouse.sizeUsed;
const alreadyUsedByRequiredMaterials = Object.keys(requiredMaterials).reduce(
(sum, mat) => sum + getSmartSupplyUsableMaterial(warehouse, mat as CorpMaterialName, passedSeconds),
0,
);
let availableSpace = free + alreadyUsedByRequiredMaterials;

// 2. Determine the ratios of all input materials.
const ratios: RatioData[] = [];
for (const [matName, amount] of Object.entries(requiredMaterials)) {
if (amount !== undefined && amount !== 0) {
const ratioData: RatioData = {
matName: matName as CorpMaterialName,
ratio: (amount as number) * MaterialInfo[matName as CorpMaterialName].size,
};
ratios.push(ratioData);
}
}
// Add some output space temporarily
ratios.push({ matName: null, ratio: averageOutSize } as RatioData);
normalize(ratios); // Normalize, which yields the output ratio

// When products require more space than their base materials, we need to reserve extra space.
const outputRatio = ratios.pop()!.ratio;
const inputRatio = 1 - outputRatio;
// Adjust the space being available by applying the geometric series formula to find the optimal ratio.
availableSpace *= Math.min(1, inputRatio / outputRatio);
normalize(ratios);

// 3. Determine the ratios of existing materials to needed materials.
const getExistingRatio = (ratioData: RatioData): number => {
const needed = ratioData.ratio * availableSpace;
const existing = getSmartSupplyUsableMaterial(warehouse, ratioData.matName!, passedSeconds);
return existing / needed;
};

ratios.sort((a, b) => getExistingRatio(a) - getExistingRatio(b)); // sort ascending by their existing-ratio

// 4. Purchase the missing materials, starting with the materials that are least needed.
// If there is an excess of a material, reduce the total available space and recalculate the ratios.
while (ratios.length !== 0 && getExistingRatio(ratios[ratios.length - 1])! >= 1) {
const next = ratios.pop()!;
availableSpace -= getExistingRatio(next) * next.ratio * availableSpace;
warehouse.materials[next.matName!].buyAmount = 0; // don't buy
normalize(ratios);
}
while (ratios.length !== 0) {
const next = ratios.pop()!;
const material = warehouse.materials[next.matName!];
const size = MaterialInfo[next.matName!].size;
const amountVolume = next.ratio * availableSpace; // determine missing volume
const amount = amountVolume / size;
const maxAmount = productionCapacity * requiredMaterials[next.matName!]!;
const finalAmount = Math.min(amount, maxAmount); // don't buy over production capacity
const usableAmount = getSmartSupplyUsableMaterial(warehouse, next.matName!, passedSeconds) / size;
material.buyAmount = Math.max(0, finalAmount - usableAmount) / passedSeconds;
}
}
5 changes: 0 additions & 5 deletions src/Corporation/Warehouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ export class Warehouse {
materialNames.map((matName) => [matName, "leftovers"]),
);

// Stores the amount of product to be produced. Used for Smart Supply unlock.
// The production tracked by smart supply is always based on the previous cycle,
// so it will always trail the "true" production by 1 cycle
smartSupplyStore = 0;

constructor(params: IConstructorParams | null = null) {
const corp = Player.corporation;
if (!corp || params === null) return;
Expand Down
Loading