Hunt optimizer is working but isn't very user-friendly yet.

This commit is contained in:
Daan Vanden Bosch 2019-06-05 16:49:00 +02:00
parent 63a703e708
commit 45c9df039a
10 changed files with 187 additions and 56 deletions

View File

@ -324,6 +324,10 @@ export class EnemyDrop implements ItemDrop {
export class HuntMethod {
constructor(
/**
* Time taken in hours.
*/
public time: number,
public quest: SimpleQuest
) { }
}

View File

@ -20,17 +20,11 @@ export class EnumMap<K, V> {
private keys: K[];
private values = new Map<K, V>();
constructor(enum_: any, initialValue: V | ((key: K) => V)) {
constructor(enum_: any, initialValue: (key: K) => V) {
this.keys = enumValues(enum_);
if (!(initialValue instanceof Function)) {
for (const key of this.keys) {
this.values.set(key, initialValue);
}
} else {
for (const key of this.keys) {
this.values.set(key, initialValue(key));
}
for (const key of this.keys) {
this.values.set(key, initialValue(key));
}
}

View File

@ -20,30 +20,46 @@ class HuntMethodStore {
return NpcType.byNameAndEpisode(enemy, parseInt(episode, 10))!;
});
return rows.slice(2).map(row => {
const questName = row[0];
const npcs = row.slice(2, -2).flatMap((cell, cellI) => {
const amount = parseInt(cell, 10);
const type = npcTypeByIndex[cellI];
const enemies = [];
if (type) {
for (let i = 0; i < amount; i++) {
enemies.push(new SimpleNpc(type));
}
return rows.slice(2)
.filter(row => {
const questName = row[0];
// TODO: let's not hard code this...
switch (questName) {
case 'MAXIMUM ATTACK 3 Ver2':
case 'LOGiN presents 勇場のマッチレース':
return false;
default:
return true;
}
})
.map(row => {
const questName = row[0];
const time = parseFloat(row[1]);
return enemies;
const npcs = row.slice(2, -2).flatMap((cell, cellI) => {
const amount = parseInt(cell, 10);
const type = npcTypeByIndex[cellI];
const enemies = [];
if (type) {
for (let i = 0; i < amount; i++) {
enemies.push(new SimpleNpc(type));
}
} else {
console.error(`Couldn't get type for cellI ${cellI}.`);
}
return enemies;
});
return new HuntMethod(
time,
new SimpleQuest(
questName,
npcs
)
);
});
return new HuntMethod(
new SimpleQuest(
questName,
npcs
)
);
});
}
}

View File

@ -1,11 +1,11 @@
import solver from 'javascript-lp-solver';
import { observable } from "mobx";
import { Difficulties, Item, NpcType, SectionIds } from "../domain";
import { IObservableArray, observable, runInAction } from "mobx";
import { Difficulties, Difficulty, Item, NpcType, SectionId, SectionIds } from "../domain";
import { huntMethodStore } from "./HuntMethodStore";
import { itemDropStore } from './ItemDropStore';
export class WantedItem {
@observable item: Item;
@observable readonly item: Item;
@observable amount: number;
constructor(item: Item, amount: number) {
@ -14,8 +14,27 @@ export class WantedItem {
}
}
export class OptimizationResult {
public readonly totalTime: number;
constructor(
public readonly difficulty: Difficulty,
public readonly sectionId: SectionId,
public readonly methodName: string,
public readonly methodTime: number,
public readonly runs: number,
public readonly itemCounts: Map<Item, number>
) {
this.totalTime = runs * methodTime;
}
}
// TODO: group similar methods (e.g. same difficulty, same quest and similar ID).
// This way people can choose their preferred section ID.
// TODO: Cutter doesn't seem to work.
class HuntOptimizerStore {
@observable wantedItems: Array<WantedItem> = [];
@observable readonly wantedItems: Array<WantedItem> = [];
@observable readonly result: IObservableArray<OptimizationResult> = observable.array();
optimize = async () => {
if (!this.wantedItems.length) return;
@ -23,14 +42,23 @@ class HuntOptimizerStore {
const methods = await huntMethodStore.methods.current.promise;
const dropTable = await itemDropStore.enemyDrops.current.promise;
// Add a constraint per wanted item.
const constraints: { [itemName: string]: { min: number } } = {};
for (const wanted of this.wantedItems) {
constraints[wanted.item.name] = { min: wanted.amount };
}
const items = new Set(this.wantedItems.map(i => i.item));
const variables: { [methodName: string]: { [itemName: string]: number } } = {};
// Add a variable to the LP model per method per difficulty per section ID.
// Each variable has a time property to minimize and a property per item with the number
// of enemies that drop the item multiplied by the corresponding drop rate as its value.
type Variable = {
time: number,
[itemName: string]: number
}
const variables: { [methodName: string]: Variable } = {};
const wantedItems = new Set(this.wantedItems.map(i => i.item));
for (const method of methods) {
const counts = new Map<NpcType, number>();
@ -42,40 +70,75 @@ class HuntOptimizerStore {
for (const diff of Difficulties) {
for (const sectionId of SectionIds) {
const variable: { [itemName: string]: number } = {
time: 0.5
const variable: Variable = {
time: method.time
};
let addVariable = false;
for (const [npcType, count] of counts.entries()) {
const drop = dropTable.getDrop(diff, sectionId, npcType);
if (drop && items.has(drop.item)) {
variable[drop.item.name] = count * drop.rate;
if (drop && wantedItems.has(drop.item)) {
const value = variable[drop.item.name] || 0;
variable[drop.item.name] = value + count * drop.rate;
addVariable = true;
}
}
if (Object.keys(variable).length) {
variables[`${diff} ${sectionId} ${method.quest.name}`] = variable;
if (addVariable) {
variables[`${diff}\t${sectionId}\t${method.quest.name}`] = variable;
}
}
}
}
const result = solver.Solve({
const result: {
feasible: boolean,
bounded: boolean,
result: number,
[method: string]: number | boolean
} = solver.Solve({
optimize: 'time',
opType: 'min',
constraints,
variables
});
console.log(result);
runInAction(() => {
this.result.splice(0);
for (const [method, runsOrOther] of Object.entries(result)) {
const [diffStr, sIdStr, methodName] = method.split('\t', 3);
if (sIdStr && methodName) {
const runs = runsOrOther as number;
const variable = variables[method];
const diff = (Difficulty as any)[diffStr];
const sectionId = (SectionId as any)[sIdStr];
const items = new Map<Item, number>();
for (const [itemName, expectedValue] of Object.entries(variable)) {
for (const item of wantedItems) {
if (itemName === item.name) {
items.set(item, runs * expectedValue);
break;
}
}
}
this.result.push(new OptimizationResult(
diff,
sectionId,
methodName,
0.5,
runs,
items
));
}
}
});
}
}
export const huntOptimizerStore = new HuntOptimizerStore();
type MethodWithDropRates = {
name: string
time: number
[itemName: string]: any
}

View File

@ -7,7 +7,7 @@ import { ServerMap } from "./ServerMap";
class EnemyDropTable {
private map: EnumMap<Difficulty, EnumMap<SectionId, Map<NpcType, EnemyDrop>>> =
new EnumMap(Difficulty, new EnumMap(SectionId, new Map()));
new EnumMap(Difficulty, () => new EnumMap(SectionId, () => new Map()));
getDrop(difficulty: Difficulty, sectionId: SectionId, npcType: NpcType): EnemyDrop | undefined {
return this.map.get(difficulty).get(sectionId).get(npcType);
@ -74,7 +74,7 @@ class ItemDropStore {
continue;
}
const rareRate = parseFloat(cells[5]);
const rareRate = parseFloat(cells[6]);
if (!rareRate) {
console.error(`Couldn't parse rare_rate for line ${lineNo}.`);

View File

@ -10,7 +10,7 @@ class ItemStore {
new Loadable([], () => this.loadItems(server))
);
dedupItem(name: string): Item {
dedupItem = (name: string): Item => {
let item = this.itemMap.get(name);
if (!item) {
@ -25,7 +25,7 @@ class ItemStore {
`${process.env.PUBLIC_URL}/items.${Server[server].toLowerCase()}.tsv`
);
const data = await response.text();
return data.split('\n').slice(1).map(name => new Item(name));
return data.split('\n').slice(1).map(name => this.dedupItem(name));
}
}

View File

@ -4,7 +4,7 @@ import { applicationStore } from "./ApplicationStore";
import { EnumMap } from "../enums";
export class ServerMap<V> extends EnumMap<Server, V> {
constructor(initialValue: V | ((server: Server) => V)) {
constructor(initialValue: (server: Server) => V) {
super(Server, initialValue)
}

View File

@ -42,7 +42,7 @@ export class ApplicationComponent extends React.Component {
</Menu.Item>
<Menu.Item key="huntOptimizer">
Hunt Optimizer
</Menu.Item>
</Menu.Item>
</Menu>
</div>
<div className="ApplicationComponent-main">

View File

@ -1,11 +1,13 @@
import React from "react";
import './HuntOptimizerComponent.css';
import { WantedItemsComponent } from "./WantedItemsComponent";
import { OptimizationResultComponent } from "./OptimizationResultComponent";
export function HuntOptimizerComponent() {
return (
<section className="ho-HuntOptimizerComponent">
<WantedItemsComponent />
<OptimizationResultComponent />
</section>
);
}

View File

@ -0,0 +1,52 @@
import { Table } from "antd";
import { observer } from "mobx-react";
import React from "react";
import { Item } from "../../domain";
import { huntOptimizerStore, OptimizationResult } from "../../stores/HuntOptimizerStore";
@observer
export class OptimizationResultComponent extends React.Component {
render() {
const items = new Set<Item>();
for (const r of huntOptimizerStore.result) {
for (const i of r.itemCounts.keys()) {
items.add(i);
}
}
return (
<section>
<h2>Optimization Result</h2>
<Table
dataSource={huntOptimizerStore.result}
pagination={false}
rowKey={(_, index) => index.toString()}
size="small"
scroll={{ x: true, y: true }}
>
<Table.Column title="Difficulty" dataIndex="difficulty" />
<Table.Column title="Method" dataIndex="methodName" />
<Table.Column title="Section ID" dataIndex="sectionId" />
<Table.Column title="Hours/Run" dataIndex="methodTime" render={this.fixed1} />
<Table.Column title="Runs" dataIndex="runs" render={this.fixed1} />
<Table.Column title="Total Hours" dataIndex="totalTime" render={this.fixed1} />
{[...items].map(item =>
<Table.Column<OptimizationResult>
title={item.name}
key={item.name}
render={(_, result) => {
const count = result.itemCounts.get(item);
return count && count.toFixed(2);
}}
/>
)}
</Table>
</section>
);
}
private fixed1(time: number): string {
return time.toFixed(1);
}
}