Add smart resource transfer

This commit is contained in:
Anton Vakhrushev 2020-05-24 21:05:29 +03:00
parent 301b1a6ca9
commit faed76df4d
17 changed files with 272 additions and 148 deletions

View File

@ -0,0 +1,46 @@
import { ActionController, registerAction } from './ActionController';
import { FailTaskError, taskError, TryLaterError } from '../Errors';
import { Resources } from '../Core/Resources';
import { Coordinates } from '../Core/Village';
import { aroundMinutes, timestamp } from '../utils';
import { Args } from '../Queue/Args';
import { Task } from '../Queue/TaskProvider';
import { clickSendButton, fillSendResourcesForm } from '../Page/BuildingPage/MarketPage';
import { VillageState } from '../VillageState';
import { MerchantsInfo } from '../Core/Market';
import { goToMarketSendResourcesPage, goToResourceViewPage } from '../Task/ActionBundles';
import { ResourceTransferCalculator, ResourceTransferReport } from '../ResourceTransfer';
import { ResourceTransferStorage } from '../Storage/ResourceTransferStorage';
import { path } from '../Helpers/Path';
import { MARKET_ID } from '../Core/Buildings';
@registerAction
export class FindSendResourcesPath extends ActionController {
async run(args: Args, task: Task): Promise<any> {
const reports: Array<ResourceTransferReport> = [];
const calculator = new ResourceTransferCalculator(this.villageFactory);
const villages = this.villageFactory.getAllVillages();
for (let fromVillage of villages) {
for (let toVillage of villages) {
reports.push(calculator.calc(fromVillage.id, toVillage.id));
}
}
reports.sort((r1, r2) => r2.score - r1.score);
const bestReport = reports.shift();
if (!bestReport) {
throw new FailTaskError('No best report for transfer resources');
}
console.log('Best report', bestReport);
const storage = new ResourceTransferStorage();
storage.storeReport(bestReport);
const marketPath = path('/build.php', { newdid: bestReport.fromVillageId, gid: MARKET_ID, t: 5 });
window.location.assign(marketPath);
}
}

View File

@ -1,123 +1,33 @@
import { ActionController, registerAction } from './ActionController';
import { taskError, TryLaterError } from '../Errors';
import { Resources } from '../Core/Resources';
import { Coordinates } from '../Core/Village';
import { aroundMinutes, timestamp } from '../utils';
import { Args } from '../Queue/Args';
import { Task } from '../Queue/TaskProvider';
import { clickSendButton, fillSendResourcesForm, grabMerchantsInfo } from '../Page/BuildingPage/MarketPage';
import { VillageState } from '../VillageState';
import { clickSendButton, fillSendResourcesForm } from '../Page/BuildingPage/MarketPage';
import { ResourceTransferCalculator } from '../ResourceTransfer';
import { ResourceTransferStorage } from '../Storage/ResourceTransferStorage';
import { Resources } from '../Core/Resources';
import { AbortTaskError } from '../Errors';
@registerAction
export class SendResourcesAction extends ActionController {
async run(args: Args, task: Task): Promise<any> {
this.ensureSameVillage(args, task);
const storage = new ResourceTransferStorage();
const savedReport = storage.getReport();
const senderVillageId = args.villageId || taskError('No source village id');
const targetVillageId = args.targetVillageId || taskError('No target village id');
const fromVillage = this.villageFactory.getVillage(savedReport.fromVillageId);
const toVillage = this.villageFactory.getVillage(savedReport.toVillageId);
const coordinates = Coordinates.fromObject(args.coordinates || taskError('No coordinates'));
const coordinates = toVillage.crd;
const senderVillage = this.villageFactory.createState(senderVillageId);
const recipientVillage = this.villageFactory.createState(targetVillageId);
const calculator = new ResourceTransferCalculator(this.villageFactory);
const report = calculator.calc(fromVillage.id, toVillage.id);
const readyToTransfer = this.getResourcesForTransfer(senderVillage, recipientVillage);
console.log('To transfer report', report);
console.log('To transfer res', readyToTransfer);
if (Resources.fromObject(report.resources).empty()) {
throw new AbortTaskError('No resources to transfer');
}
// Schedule recurrent task
const timeout = senderVillage.settings.sendResourcesTimeout;
this.scheduler.scheduleTask(task.name, task.args, timestamp() + aroundMinutes(timeout));
fillSendResourcesForm(readyToTransfer, coordinates);
fillSendResourcesForm(report.resources, coordinates);
clickSendButton();
}
private getMerchantsCapacity(timeout: number): number {
const merchants = grabMerchantsInfo();
const capacity = merchants.available * merchants.carry;
if (!capacity) {
throw new TryLaterError(aroundMinutes(timeout), 'No merchants');
}
return capacity;
}
private getSenderAvailableResources(senderState: VillageState): Resources {
const balance = senderState.required.balance;
const free = balance.max(Resources.zero());
console.table([
{ name: 'Sender balance', ...balance },
{ name: 'Sender free', ...free },
]);
const amount = free.amount();
const threshold = senderState.settings.sendResourcesThreshold;
const timeout = senderState.settings.sendResourcesTimeout;
if (amount < threshold) {
throw new TryLaterError(
aroundMinutes(timeout),
`No free resources (amount ${amount} < threshold ${threshold})`
);
}
return free;
}
private getRecipientRequirements(recipientState: VillageState, timeout: number): Resources {
const maxPossibleToStore = recipientState.storage.capacity.sub(recipientState.performance);
const currentResources = recipientState.resources;
const incomingResources = recipientState.incomingResources;
const requirementResources = recipientState.required.resources;
const missingResources = requirementResources
.min(maxPossibleToStore)
.sub(incomingResources)
.sub(currentResources)
.max(Resources.zero());
console.table([
{ name: 'Recipient max possible', ...maxPossibleToStore },
{ name: 'Recipient resources', ...currentResources },
{ name: 'Recipient incoming', ...incomingResources },
{ name: 'Recipient requirements', ...requirementResources },
{ name: 'Recipient missing', ...missingResources },
]);
if (missingResources.empty()) {
throw new TryLaterError(aroundMinutes(timeout), 'No missing resources');
}
return missingResources;
}
private getResourcesForTransfer(senderState: VillageState, recipientState: VillageState): Resources {
const multiplier = senderState.settings.sendResourcesMultiplier;
const timeout = senderState.settings.sendResourcesTimeout;
const senderReadyResources = this.getSenderAvailableResources(senderState).downTo(multiplier);
const recipientNeedResources = this.getRecipientRequirements(recipientState, timeout).upTo(multiplier);
const contractResources = senderReadyResources.min(recipientNeedResources);
const merchantsCapacity = this.getMerchantsCapacity(timeout);
let readyToTransfer = contractResources;
if (contractResources.amount() > merchantsCapacity) {
const merchantScale = merchantsCapacity / contractResources.amount();
readyToTransfer = contractResources.scale(merchantScale).downTo(multiplier);
}
if (readyToTransfer.empty()) {
throw new TryLaterError(aroundMinutes(timeout), 'Not enough resources to transfer');
}
console.log('Merchants capacity', merchantsCapacity);
console.table([
{ name: 'Sender', ...senderReadyResources },
{ name: 'Recipient', ...recipientNeedResources },
{ name: 'Prepared', ...contractResources },
{ name: 'Ready to transfer', ...readyToTransfer },
]);
return readyToTransfer;
}
}

View File

@ -8,3 +8,8 @@ export class IncomingMerchant {
this.ts = ts;
}
}
export interface MerchantsInfo {
available: number;
carry: number;
}

View File

@ -10,7 +10,6 @@ import { ExecutionStorage } from './Storage/ExecutionStorage';
import { Action } from './Queue/ActionQueue';
import { Task } from './Queue/TaskProvider';
import { createTaskHandler } from './Task/TaskMap';
import { VillageStateFactory } from './VillageState';
import { VillageFactory } from './VillageFactory';
export interface ExecutionSettings {
@ -79,8 +78,6 @@ export class Executor {
}
private async doTaskProcessingStep() {
this.runGrabbers();
const currentTs = timestamp();
const { task, action } = this.scheduler.nextTask(currentTs);
@ -92,6 +89,8 @@ export class Executor {
this.logger.info('CURRENT JOB', 'TASK', task, 'ACTION', action);
this.runGrabbers();
try {
if (task && action) {
return await this.processActionCommand(action, task);
@ -117,7 +116,7 @@ export class Executor {
}
private async processTaskCommand(task: Task) {
const taskHandler = createTaskHandler(task.name, this.scheduler);
const taskHandler = createTaskHandler(task.name, this.scheduler, this.villageFactory);
this.logger.info('Process task', task.name, task, taskHandler);
if (taskHandler) {
await taskHandler.run(task);

View File

@ -1,6 +1,6 @@
import { Grabber } from './Grabber';
import { isMarketSendResourcesPage } from '../Page/PageDetectors';
import { grabIncomingMerchants } from '../Page/BuildingPage/MarketPage';
import { grabIncomingMerchants, grabMerchantsInfo } from '../Page/BuildingPage/MarketPage';
export class MarketPageGrabber extends Grabber {
grab(): void {
@ -9,5 +9,6 @@ export class MarketPageGrabber extends Grabber {
}
this.storage.storeIncomingMerchants(grabIncomingMerchants());
this.storage.storeMerchantsInfo(grabMerchantsInfo());
}
}

View File

@ -1,7 +1,7 @@
import { getNumber, uniqId } from '../../utils';
import { Resources } from '../../Core/Resources';
import { Resources, ResourcesInterface } from '../../Core/Resources';
import { Coordinates } from '../../Core/Village';
import { IncomingMerchant } from '../../Core/Market';
import { IncomingMerchant, MerchantsInfo } from '../../Core/Market';
import { grabResourcesFromList } from './BuildingPage';
interface SendResourcesClickHandler {
@ -31,13 +31,13 @@ export function createSendResourcesButton(onClickHandler: SendResourcesClickHand
jQuery(`#${id}`).on('click', createHandler());
}
export function grabMerchantsInfo() {
export function grabMerchantsInfo(): MerchantsInfo {
const available = getNumber(jQuery('.merchantsAvailable').text());
const carry = getNumber(jQuery('.carry b').text());
return { available, carry };
}
export function fillSendResourcesForm(resources: Resources, crd: Coordinates) {
export function fillSendResourcesForm(resources: ResourcesInterface, crd: Coordinates) {
const sendSelect = jQuery('#send_select');
sendSelect.find('#r1').val(resources.lumber);
sendSelect.find('#r2').val(resources.clay);

49
src/ResourceTransfer.ts Normal file
View File

@ -0,0 +1,49 @@
import { VillageFactory } from './VillageFactory';
import { ResourcesInterface } from './Core/Resources';
export interface ResourceTransferReport {
fromVillageId: number;
toVillageId: number;
resources: ResourcesInterface;
score: number;
}
export class ResourceTransferCalculator {
private factory: VillageFactory;
constructor(factory: VillageFactory) {
this.factory = factory;
}
calc(fromVillageId: number, toVillageId: number): ResourceTransferReport {
const senderState = this.factory.createState(fromVillageId);
const senderController = this.factory.createController(fromVillageId);
const senderStorage = this.factory.createStorage(fromVillageId);
const recipientController = this.factory.createController(toVillageId);
const multiplier = senderState.settings.sendResourcesMultiplier;
const senderReadyResources = senderController.getAvailableForSendResources().downTo(multiplier);
const recipientNeedResources = recipientController.getRequiredResources().upTo(multiplier);
const contractResources = senderReadyResources.min(recipientNeedResources);
const merchantsInfo = senderStorage.getMerchantsInfo();
const merchantsCapacity = merchantsInfo.available * merchantsInfo.carry;
let readyToTransfer = contractResources;
if (contractResources.amount() && contractResources.amount() > merchantsCapacity) {
const merchantScale = merchantsCapacity / contractResources.amount();
readyToTransfer = contractResources.scale(merchantScale).downTo(multiplier);
}
console.log('Merchants capacity', merchantsCapacity);
console.table([
{ name: 'Sender', ...senderReadyResources },
{ name: 'Recipient', ...recipientNeedResources },
{ name: 'Prepared', ...contractResources },
{ name: 'Ready to transfer', ...readyToTransfer },
]);
return { fromVillageId, toVillageId, resources: readyToTransfer, score: readyToTransfer.amount() };
}
}

View File

@ -1,4 +1,4 @@
import { timestamp } from './utils';
import { around, timestamp } from './utils';
import { TaskQueue } from './Queue/TaskQueue';
import { SendOnAdventureTask } from './Task/SendOnAdventureTask';
import { BalanceHeroResourcesTask } from './Task/BalanceHeroResourcesTask';
@ -22,11 +22,11 @@ export interface NextExecution {
}
export class Scheduler {
private taskQueue: TaskQueue;
private actionQueue: ActionQueue;
private villageRepository: VillageRepositoryInterface;
private villageControllerFactory: VillageFactory;
private logger: Logger;
private readonly taskQueue: TaskQueue;
private readonly actionQueue: ActionQueue;
private readonly villageRepository: VillageRepositoryInterface;
private readonly villageControllerFactory: VillageFactory;
private readonly logger: Logger;
constructor(
taskQueue: TaskQueue,
@ -51,14 +51,16 @@ export class Scheduler {
}
this.createUniqTaskTimer(5 * 60, GrabVillageState.name);
this.createUniqTaskTimer(10 * 60, SendResourcesTask.name);
this.createUniqTaskTimer(10 * 60, BalanceHeroResourcesTask.name);
this.createUniqTaskTimer(20 * 60, UpdateResourceContracts.name);
this.createUniqTaskTimer(60 * 60, SendOnAdventureTask.name);
// this.createUniqTaskTimer(60 * 60, SendOnAdventureTask.name);
}
private createUniqTaskTimer(seconds: number, name: string, args: Args = {}) {
this.scheduleUniqTask(name, args, timestamp() + seconds - 10);
setInterval(() => this.scheduleUniqTask(name, args, timestamp()), seconds * 1000);
const intervalTime = around(seconds, 0.2) * 1000;
setInterval(() => this.scheduleUniqTask(name, args, timestamp()), intervalTime);
}
getTaskItems(): ImmutableTaskList {

View File

@ -13,20 +13,20 @@ export interface StatisticsStorageInterface {
}
export class Statistics {
private state: StatisticsStorageInterface;
private readonly storage: StatisticsStorageInterface;
static readonly keepRecords = KEEP_RECORD_COUNT;
constructor(storage: StatisticsStorageInterface) {
this.state = storage;
this.storage = storage;
}
incrementAction(ts: number): void {
const stat = this.state.getActionStatistics();
const stat = this.storage.getActionStatistics();
const key = dateFormat(ts * 1000, KEY_FORMAT);
stat[key] = (stat[key] || 0) + 1;
this.trimStatistics(stat);
this.state.setActionStatistics(stat);
this.storage.setActionStatistics(stat);
}
private trimStatistics(stat: ActionStatistics) {
@ -49,6 +49,6 @@ export class Statistics {
}
getActionStatistics(): ActionStatistics {
return this.state.getActionStatistics();
return this.storage.getActionStatistics();
}
}

View File

@ -0,0 +1,33 @@
import { DataStorage } from '../DataStorage';
import { ResourceTransferReport } from '../ResourceTransfer';
const NAMESPACE = 'resource_transfer.v1';
const REPORT_KEY = 'report';
export class ResourceTransferStorage {
private storage: DataStorage;
constructor() {
this.storage = new DataStorage(NAMESPACE);
}
storeReport(report: ResourceTransferReport): void {
this.storage.set(REPORT_KEY, report);
}
getReport(): ResourceTransferReport {
return this.storage.getTyped<ResourceTransferReport>(REPORT_KEY, {
factory: () => ({
fromVillageId: 0,
toVillageId: 0,
resources: {
lumber: 0,
clay: 0,
iron: 0,
crop: 0,
},
score: 0,
}),
});
}
}

View File

@ -2,7 +2,7 @@ import { DataStorage } from '../DataStorage';
import { BuildingQueueInfo } from '../Game';
import { Resources, ResourcesInterface } from '../Core/Resources';
import { ResourceStorage } from '../Core/ResourceStorage';
import { IncomingMerchant } from '../Core/Market';
import { IncomingMerchant, MerchantsInfo } from '../Core/Market';
import { VillageSettings, VillageSettingsDefaults } from '../Core/Village';
import { ProductionQueue } from '../Core/ProductionQueue';
import { getNumber } from '../utils';
@ -13,6 +13,7 @@ const CAPACITY_KEY = 'capacity';
const PERFORMANCE_KEY = 'performance';
const BUILDING_QUEUE_INFO_KEY = 'building_queue_info';
const INCOMING_MERCHANTS_KEY = 'incoming_merchants';
const MERCHANTS_INFO_KEY = 'merchants_info';
const SETTINGS_KEY = 'settings';
const QUEUE_ENDING_TIME_KEY = 'queue_ending_time';
const TASK_LIST_KEY = 'tasks';
@ -63,6 +64,16 @@ export class VillageStorage {
return Object.assign(res, plain) as BuildingQueueInfo;
}
storeMerchantsInfo(info: MerchantsInfo): void {
this.storage.set(MERCHANTS_INFO_KEY, info);
}
getMerchantsInfo(): MerchantsInfo {
return this.storage.getTyped<MerchantsInfo>(MERCHANTS_INFO_KEY, {
factory: () => ({ available: 0, carry: 0 }),
});
}
storeIncomingMerchants(merchants: ReadonlyArray<IncomingMerchant>): void {
this.storage.set(
INCOMING_MERCHANTS_KEY,

View File

@ -4,21 +4,23 @@ import { ClickButtonAction } from '../Action/ClickButtonAction';
import { goToMarketSendResourcesPage, goToResourceViewPage } from './ActionBundles';
import { Task } from '../Queue/TaskProvider';
import { registerTask } from './TaskMap';
import { taskError } from '../Errors';
import { FindSendResourcesPath } from '../Action/FindSendResourcesPath';
@registerTask()
export class SendResourcesTask extends TaskController {
defineActions(task: Task): Array<ActionDefinition> {
const targetVillageId = task.args.targetVillageId || taskError('Empty target village id');
const villageId = task.args.villageId || taskError('Empty village id');
const actions: Array<ActionDefinition> = [];
return [
goToResourceViewPage(targetVillageId),
goToMarketSendResourcesPage(targetVillageId),
goToResourceViewPage(villageId),
goToMarketSendResourcesPage(villageId),
[SendResourcesAction.name],
[ClickButtonAction.name, { selector: '#enabledButton.green.sendRessources' }],
];
const villages = this.factory.getAllVillages();
for (let village of villages) {
actions.push(goToResourceViewPage(village.id));
actions.push(goToMarketSendResourcesPage(village.id));
}
actions.push([FindSendResourcesPath.name]);
actions.push([SendResourcesAction.name]);
actions.push([ClickButtonAction.name, { selector: '#enabledButton.green.sendRessources' }]);
return actions;
}
}

View File

@ -3,14 +3,17 @@ import { CompleteTaskAction } from '../Action/CompleteTaskAction';
import { Action } from '../Queue/ActionQueue';
import { Args } from '../Queue/Args';
import { Task } from '../Queue/TaskProvider';
import { VillageFactory } from '../VillageFactory';
export type ActionDefinition = [string] | [string, Args];
export class TaskController {
protected scheduler: Scheduler;
protected readonly scheduler: Scheduler;
protected readonly factory: VillageFactory;
constructor(scheduler: Scheduler) {
constructor(scheduler: Scheduler, factory: VillageFactory) {
this.scheduler = scheduler;
this.factory = factory;
}
async run(task: Task) {

View File

@ -1,6 +1,7 @@
import { Scheduler } from '../Scheduler';
import { TaskController } from './TaskController';
import { ProductionQueue } from '../Core/ProductionQueue';
import { VillageFactory } from '../VillageFactory';
interface TaskOptions {
queue?: ProductionQueue;
@ -34,11 +35,15 @@ export function getProductionQueue(name: string): ProductionQueue | undefined {
return taskDescription.queue;
}
export function createTaskHandler(name: string, scheduler: Scheduler): TaskController | undefined {
export function createTaskHandler(
name: string,
scheduler: Scheduler,
factory: VillageFactory
): TaskController | undefined {
const taskDescription = taskMap[name];
if (taskDescription === undefined) {
return undefined;
}
const constructor = (taskDescription.ctor as unknown) as typeof TaskController;
return new constructor(scheduler);
return new constructor(scheduler, factory);
}

View File

@ -2,10 +2,13 @@ import { VillageTaskCollection } from './VillageTaskCollection';
import { Task, TaskId } from './Queue/TaskProvider';
import { Args } from './Queue/Args';
import { VillageState } from './VillageState';
import { Resources } from './Core/Resources';
import { TryLaterError } from './Errors';
import { aroundMinutes } from './utils';
export class VillageController {
private readonly villageId: number;
private taskCollection: VillageTaskCollection;
private readonly taskCollection: VillageTaskCollection;
private readonly state: VillageState;
constructor(villageId: number, taskCollection: VillageTaskCollection, state: VillageState) {
@ -33,4 +36,46 @@ export class VillageController {
postponeTask(taskId: TaskId, seconds: number) {
this.taskCollection.postponeTask(taskId, seconds);
}
getAvailableForSendResources(): Resources {
const balance = this.state.required.balance;
const free = balance.max(Resources.zero());
console.table([
{ name: 'Sender balance', ...balance },
{ name: 'Sender free', ...free },
]);
const amount = free.amount();
const threshold = this.state.settings.sendResourcesThreshold;
if (amount < threshold) {
return Resources.zero();
}
return free;
}
getRequiredResources(): Resources {
const performance = this.state.performance;
const maxPossibleToStore = this.state.storage.capacity.sub(performance);
const currentResources = this.state.resources;
const incomingResources = this.state.incomingResources;
const requirementResources = this.state.required.resources;
const missingResources = requirementResources
.min(maxPossibleToStore)
.sub(incomingResources)
.sub(currentResources)
.max(Resources.zero());
console.table([
{ name: 'Recipient max possible', ...maxPossibleToStore },
{ name: 'Recipient resources', ...currentResources },
{ name: 'Recipient incoming', ...incomingResources },
{ name: 'Recipient requirements', ...requirementResources },
{ name: 'Recipient missing', ...missingResources },
]);
return missingResources;
}
}

View File

@ -3,6 +3,7 @@ import { VillageStorage } from './Storage/VillageStorage';
import { VillageRepository } from './VillageRepository';
import { VillageTaskCollection } from './VillageTaskCollection';
import { VillageState, VillageStateFactory } from './VillageState';
import { Village } from './Core/Village';
export class VillageFactory {
private readonly villageRepository: VillageRepository;
@ -11,6 +12,14 @@ export class VillageFactory {
this.villageRepository = villageRepository;
}
getAllVillages(): Array<Village> {
return this.villageRepository.all();
}
getVillage(villageId: number): Village {
return this.villageRepository.get(villageId);
}
createStorage(villageId: number): VillageStorage {
const village = this.villageRepository.get(villageId);
return new VillageStorage(village.id);

View File

@ -10,6 +10,11 @@ export function randomInRange(from: number, to: number): number {
return Math.floor(from + variation);
}
export function around(value: number, koeff: number): number {
const delta = Math.floor(value * koeff);
return randomInRange(value - delta, value + delta);
}
export async function sleepMicro() {
const timeInMs = randomInRange(1500, 2500);
return await sleep(timeInMs);
@ -17,8 +22,7 @@ export async function sleepMicro() {
export function aroundMinutes(minutes: number) {
const seconds = minutes * 60;
const delta = Math.floor(seconds * 0.1);
return randomInRange(seconds - delta, seconds + delta);
return around(seconds, 0.1);
}
export async function waitForLoad() {