mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 22:58:29 +08:00
Ported part of the hunt optimizer to the new GUI system. The methods tab is now working except for sorting.
This commit is contained in:
parent
c743cba13b
commit
bb7bf16f9f
@ -3,22 +3,23 @@ import { writeFileSync } from "fs";
|
||||
import "isomorphic-fetch";
|
||||
import Logger from "js-logger";
|
||||
import { ASSETS_DIR } from ".";
|
||||
import { Difficulty, SectionId, SectionIds } from "../src/core/domain";
|
||||
import { BoxDropDto, EnemyDropDto, ItemTypeDto } from "../src/old/core/dto";
|
||||
import { DifficultyModel, SectionIdModel, SectionIdModels } from "../src/core/model";
|
||||
import {
|
||||
name_and_episode_to_npc_type,
|
||||
NpcType,
|
||||
} from "../src/core/data_formats/parsing/quest/npc_types";
|
||||
import { ItemTypeDto } from "../src/core/dto/ItemTypeDto";
|
||||
import { BoxDropDto, EnemyDropDto } from "../src/hunt_optimizer/dto/drops";
|
||||
|
||||
const logger = Logger.get("assets_generation/update_drops_ephinea");
|
||||
|
||||
export async function update_drops_from_website(item_types: ItemTypeDto[]): Promise<void> {
|
||||
logger.info("Updating item drops.");
|
||||
|
||||
const normal = await download(item_types, Difficulty.Normal);
|
||||
const hard = await download(item_types, Difficulty.Hard);
|
||||
const vhard = await download(item_types, Difficulty.VHard, "very-hard");
|
||||
const ultimate = await download(item_types, Difficulty.Ultimate);
|
||||
const normal = await download(item_types, DifficultyModel.Normal);
|
||||
const hard = await download(item_types, DifficultyModel.Hard);
|
||||
const vhard = await download(item_types, DifficultyModel.VHard, "very-hard");
|
||||
const ultimate = await download(item_types, DifficultyModel.Ultimate);
|
||||
|
||||
const enemy_json = JSON.stringify(
|
||||
[...normal.enemy_drops, ...hard.enemy_drops, ...vhard.enemy_drops, ...ultimate.enemy_drops],
|
||||
@ -41,8 +42,8 @@ export async function update_drops_from_website(item_types: ItemTypeDto[]): Prom
|
||||
|
||||
async function download(
|
||||
item_types: ItemTypeDto[],
|
||||
difficulty: Difficulty,
|
||||
difficulty_url: string = Difficulty[difficulty].toLowerCase(),
|
||||
difficulty: DifficultyModel,
|
||||
difficulty_url: string = DifficultyModel[difficulty].toLowerCase(),
|
||||
): Promise<{ enemy_drops: EnemyDropDto[]; box_drops: BoxDropDto[]; items: Set<string> }> {
|
||||
const response = await fetch(`https://ephinea.pioneer2.net/drop-charts/${difficulty_url}/`);
|
||||
const body = await response.text();
|
||||
@ -74,7 +75,7 @@ async function download(
|
||||
|
||||
try {
|
||||
let enemy_or_box =
|
||||
enemy_or_box_text.split("/")[difficulty === Difficulty.Ultimate ? 1 : 0] ||
|
||||
enemy_or_box_text.split("/")[difficulty === DifficultyModel.Ultimate ? 1 : 0] ||
|
||||
enemy_or_box_text;
|
||||
|
||||
if (enemy_or_box === "Halo Rappy") {
|
||||
@ -94,7 +95,7 @@ async function download(
|
||||
return;
|
||||
}
|
||||
|
||||
const section_id = SectionIds[td_i - 1];
|
||||
const section_id = SectionIdModels[td_i - 1];
|
||||
|
||||
if (is_box) {
|
||||
// TODO:
|
||||
@ -150,9 +151,9 @@ async function download(
|
||||
] = /Rare Rate: (\d+)\/(\d+(\.\d+)?)/g.exec(title)!.map(parseFloat);
|
||||
|
||||
data.enemy_drops.push({
|
||||
difficulty: Difficulty[difficulty],
|
||||
difficulty: DifficultyModel[difficulty],
|
||||
episode,
|
||||
sectionId: SectionId[section_id],
|
||||
sectionId: SectionIdModel[section_id],
|
||||
enemy: NpcType[npc_type],
|
||||
itemTypeId: item_type.id,
|
||||
dropRate: drop_rate_num / drop_rate_denom,
|
||||
@ -162,7 +163,7 @@ async function download(
|
||||
data.items.add(item);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Error while processing item ${item} of ${enemy_or_box} in episode ${episode} ${Difficulty[difficulty]}.`,
|
||||
`Error while processing item ${item} of ${enemy_or_box} in episode ${episode} ${DifficultyModel[difficulty]}.`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
@ -5,12 +5,14 @@ import { BufferCursor } from "../src/core/data_formats/cursor/BufferCursor";
|
||||
import { ItemPmt, parse_item_pmt } from "../src/core/data_formats/parsing/itempmt";
|
||||
import { parse_quest } from "../src/core/data_formats/parsing/quest";
|
||||
import { parse_unitxt, Unitxt } from "../src/core/data_formats/parsing/unitxt";
|
||||
import { Difficulties, Difficulty, SectionId, SectionIds } from "../src/core/domain";
|
||||
import { BoxDropDto, EnemyDropDto, ItemTypeDto, QuestDto } from "../src/old/core/dto";
|
||||
import { DifficultyModels, DifficultyModel, SectionIdModel, SectionIdModels } from "../src/core/model";
|
||||
import { update_drops_from_website } from "./update_drops_ephinea";
|
||||
import { Episode, EPISODES } from "../src/core/data_formats/parsing/quest/Episode";
|
||||
import { npc_data, NPC_TYPES, NpcType } from "../src/core/data_formats/parsing/quest/npc_types";
|
||||
import { Endianness } from "../src/core/data_formats/Endianness";
|
||||
import { ItemTypeDto } from "../src/core/dto/ItemTypeDto";
|
||||
import { QuestDto } from "../src/hunt_optimizer/dto/QuestDto";
|
||||
import { BoxDropDto, EnemyDropDto } from "../src/hunt_optimizer/dto/drops";
|
||||
|
||||
const logger = Logger.get("assets_generation/update_ephinea_data");
|
||||
|
||||
@ -265,9 +267,9 @@ function update_drops(item_pt: ItemPt): void {
|
||||
|
||||
const enemy_drops = new Array<EnemyDropDto>();
|
||||
|
||||
for (const diff of Difficulties) {
|
||||
for (const diff of DifficultyModels) {
|
||||
for (const ep of EPISODES) {
|
||||
for (const sid of SectionIds) {
|
||||
for (const sid of SectionIdModels) {
|
||||
enemy_drops.push(...load_enemy_drops(item_pt, diff, ep, sid));
|
||||
}
|
||||
}
|
||||
@ -277,9 +279,9 @@ function update_drops(item_pt: ItemPt): void {
|
||||
|
||||
const box_drops = new Array<BoxDropDto>();
|
||||
|
||||
for (const diff of Difficulties) {
|
||||
for (const diff of DifficultyModels) {
|
||||
for (const ep of EPISODES) {
|
||||
for (const sid of SectionIds) {
|
||||
for (const sid of SectionIdModels) {
|
||||
box_drops.push(...load_box_drops(diff, ep, sid));
|
||||
}
|
||||
}
|
||||
@ -309,10 +311,10 @@ function load_item_pt(): ItemPt {
|
||||
for (const episode of [Episode.I, Episode.II]) {
|
||||
table[episode] = [];
|
||||
|
||||
for (const diff of Difficulties) {
|
||||
for (const diff of DifficultyModels) {
|
||||
table[episode][diff] = [];
|
||||
|
||||
for (const sid of SectionIds) {
|
||||
for (const sid of SectionIdModels) {
|
||||
const dar_table = new Map<NpcType, number>();
|
||||
|
||||
table[episode][diff][sid] = {
|
||||
@ -356,10 +358,10 @@ function load_item_pt(): ItemPt {
|
||||
|
||||
table[Episode.IV] = [];
|
||||
|
||||
for (const diff of Difficulties) {
|
||||
for (const diff of DifficultyModels) {
|
||||
table[Episode.IV][diff] = [];
|
||||
|
||||
for (const sid of SectionIds) {
|
||||
for (const sid of SectionIdModels) {
|
||||
const dar_table = new Map<NpcType, number>();
|
||||
|
||||
table[Episode.IV][diff][sid] = {
|
||||
@ -507,9 +509,9 @@ function load_item_pt(): ItemPt {
|
||||
|
||||
function load_enemy_drops(
|
||||
item_pt: ItemPt,
|
||||
difficulty: Difficulty,
|
||||
difficulty: DifficultyModel,
|
||||
episode: Episode,
|
||||
section_id: SectionId,
|
||||
section_id: SectionIdModel,
|
||||
): EnemyDropDto[] {
|
||||
const drops: EnemyDropDto[] = [];
|
||||
const drops_buf = readFileSync(
|
||||
@ -535,9 +537,9 @@ function load_enemy_drops(
|
||||
logger.error(`No DAR found for ${NpcType[enemy]}.`);
|
||||
} else if (rare_rate > 0 && item_type_id) {
|
||||
drops.push({
|
||||
difficulty: Difficulty[difficulty],
|
||||
difficulty: DifficultyModel[difficulty],
|
||||
episode,
|
||||
sectionId: SectionId[section_id],
|
||||
sectionId: SectionIdModel[section_id],
|
||||
enemy: NpcType[enemy],
|
||||
itemTypeId: item_type_id,
|
||||
dropRate: dar,
|
||||
@ -555,9 +557,9 @@ function load_enemy_drops(
|
||||
}
|
||||
|
||||
function load_box_drops(
|
||||
difficulty: Difficulty,
|
||||
difficulty: DifficultyModel,
|
||||
episode: Episode,
|
||||
section_id: SectionId,
|
||||
section_id: SectionIdModel,
|
||||
): BoxDropDto[] {
|
||||
const drops: BoxDropDto[] = [];
|
||||
const drops_buf = readFileSync(
|
||||
@ -579,9 +581,9 @@ function load_box_drops(
|
||||
|
||||
if (drop_rate > 0 && item_type_id) {
|
||||
drops.push({
|
||||
difficulty: Difficulty[difficulty],
|
||||
difficulty: DifficultyModel[difficulty],
|
||||
episode,
|
||||
sectionId: SectionId[section_id],
|
||||
sectionId: SectionIdModel[section_id],
|
||||
areaId: area_id,
|
||||
itemTypeId: item_type_id,
|
||||
dropRate: drop_rate,
|
||||
|
@ -5,6 +5,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "^4.14.132",
|
||||
"@types/luxon": "^1.15.2",
|
||||
"@types/react": "16.8.20",
|
||||
"@types/react-dom": "16.8.4",
|
||||
"@types/react-virtualized": "^9.21.2",
|
||||
@ -15,6 +16,7 @@
|
||||
"javascript-lp-solver": "^0.4.5",
|
||||
"js-logger": "^1.6.0",
|
||||
"lodash": "^4.17.14",
|
||||
"luxon": "^1.17.2",
|
||||
"mobx": "^5.11.0",
|
||||
"mobx-react": "^6.1.1",
|
||||
"moment": "^2.24.0",
|
||||
|
@ -10,6 +10,11 @@ const TOOLS: [GuiTool, () => Promise<ResizableWidget>][] = [
|
||||
GuiTool.QuestEditor,
|
||||
async () => new (await import("../../quest_editor/gui/QuestEditorView")).QuestEditorView(),
|
||||
],
|
||||
[
|
||||
GuiTool.HuntOptimizer,
|
||||
async () =>
|
||||
new (await import("../../hunt_optimizer/gui/HuntOptimizerView")).HuntOptimizerView(),
|
||||
],
|
||||
];
|
||||
|
||||
export class MainContentView extends ResizableWidget {
|
||||
|
@ -57,29 +57,3 @@ export type ToolItemTypeDto = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type EnemyDropDto = {
|
||||
difficulty: string;
|
||||
episode: number;
|
||||
sectionId: string;
|
||||
enemy: string;
|
||||
itemTypeId: number;
|
||||
dropRate: number;
|
||||
rareRate: number;
|
||||
};
|
||||
|
||||
export type BoxDropDto = {
|
||||
difficulty: string;
|
||||
episode: number;
|
||||
sectionId: string;
|
||||
areaId: number;
|
||||
itemTypeId: number;
|
||||
dropRate: number;
|
||||
};
|
||||
|
||||
export type QuestDto = {
|
||||
id: number;
|
||||
name: string;
|
||||
episode: 1 | 2 | 4;
|
||||
enemyCounts: { [npcTypeCode: string]: number };
|
||||
};
|
@ -13,8 +13,8 @@ export function enum_values<E>(e: any): E[] {
|
||||
* Map with a guaranteed value per enum key.
|
||||
*/
|
||||
export class EnumMap<K, V> {
|
||||
private keys: K[];
|
||||
private values = new Map<K, V>();
|
||||
private readonly keys: K[];
|
||||
private readonly values = new Map<K, V>();
|
||||
|
||||
constructor(enum_: any, initial_value: (key: K) => V) {
|
||||
this.keys = enum_values(enum_);
|
||||
|
3
src/core/gui/DurationInput.css
Normal file
3
src/core/gui/DurationInput.css
Normal file
@ -0,0 +1,3 @@
|
||||
.core_DurationInput input {
|
||||
text-align: center;
|
||||
}
|
43
src/core/gui/DurationInput.ts
Normal file
43
src/core/gui/DurationInput.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Input, InputOptions } from "./Input";
|
||||
import { Duration } from "luxon";
|
||||
import "./DurationInput.css";
|
||||
|
||||
export type DurationInputOptions = InputOptions;
|
||||
|
||||
export class DurationInput extends Input<Duration> {
|
||||
readonly preferred_label_position = "left";
|
||||
|
||||
constructor(value = Duration.fromMillis(0), options?: DurationInputOptions) {
|
||||
super(value, "core_DurationInput", "text", "core_DurationInput_inner", options);
|
||||
|
||||
this.input_element.pattern = "(60|[0-5][0-9]):(60|[0-5][0-9])";
|
||||
|
||||
this.set_value(value);
|
||||
}
|
||||
|
||||
protected get_value(): Duration {
|
||||
const str = this.input_element.value;
|
||||
|
||||
if (this.input_element.validity.valid) {
|
||||
return Duration.fromObject({
|
||||
hours: parseInt(str.slice(0, 2), 10),
|
||||
minutes: parseInt(str.slice(3), 10),
|
||||
});
|
||||
} else {
|
||||
const colon_pos = str.indexOf(":");
|
||||
|
||||
if (colon_pos === -1) {
|
||||
return Duration.fromObject({ minutes: parseInt(str, 10) });
|
||||
} else {
|
||||
return Duration.fromObject({
|
||||
hours: parseInt(str.slice(0, colon_pos), 10),
|
||||
minutes: parseInt(str.slice(colon_pos + 1), 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected set_value(value: Duration): void {
|
||||
this.input_element.value = value.toFormat("hh:mm");
|
||||
}
|
||||
}
|
@ -18,11 +18,11 @@
|
||||
}
|
||||
|
||||
.core_Input:hover {
|
||||
border-color: var(--input-border-hover);
|
||||
border: var(--input-border-hover);
|
||||
}
|
||||
|
||||
.core_Input:focus-within {
|
||||
border-color: var(--input-border-focus);
|
||||
border: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.core_Input.disabled {
|
||||
|
@ -14,7 +14,6 @@ export abstract class Input<T> extends LabelledControl<HTMLElement> {
|
||||
protected readonly input_element: HTMLInputElement;
|
||||
|
||||
private readonly _value: WidgetProperty<T>;
|
||||
private ignore_input_change = false;
|
||||
|
||||
protected constructor(
|
||||
value: T,
|
||||
@ -48,11 +47,6 @@ export abstract class Input<T> extends LabelledControl<HTMLElement> {
|
||||
|
||||
protected abstract set_value(value: T): void;
|
||||
|
||||
protected ignore_change(f: () => void): void {
|
||||
this.ignore_input_change = true;
|
||||
f();
|
||||
}
|
||||
|
||||
protected set_attr<T>(attr: InputAttrsOfType<T>, value?: T | Property<T>): void;
|
||||
protected set_attr<T, U>(
|
||||
attr: InputAttrsOfType<U>,
|
||||
|
@ -42,9 +42,7 @@ export class NumberInput extends Input<number> {
|
||||
}
|
||||
|
||||
protected set_value(value: number): void {
|
||||
this.ignore_change(() => {
|
||||
this.input_element.valueAsNumber =
|
||||
Math.round(this.rounding_factor * value) / this.rounding_factor;
|
||||
});
|
||||
this.input_element.valueAsNumber =
|
||||
Math.round(this.rounding_factor * value) / this.rounding_factor;
|
||||
}
|
||||
}
|
||||
|
@ -16,11 +16,11 @@
|
||||
}
|
||||
|
||||
.core_TextArea:hover {
|
||||
border-color: var(--input-border-hover);
|
||||
border: var(--input-border-hover);
|
||||
}
|
||||
|
||||
.core_TextArea:focus-within {
|
||||
border-color: var(--input-border-focus);
|
||||
border: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.core_TextArea.disabled {
|
||||
|
@ -23,9 +23,25 @@ export const el = {
|
||||
...children: HTMLElement[]
|
||||
): HTMLSpanElement => create_element("span", attributes, ...children),
|
||||
|
||||
h2: (
|
||||
attributes?: {
|
||||
class?: string;
|
||||
tab_index?: number;
|
||||
text?: string;
|
||||
data?: { [key: string]: string };
|
||||
},
|
||||
...children: HTMLElement[]
|
||||
): HTMLHeadingElement => create_element("h2", attributes, ...children),
|
||||
|
||||
table: (attributes?: {}, ...children: HTMLElement[]): HTMLTableElement =>
|
||||
create_element("table", attributes, ...children),
|
||||
|
||||
thead: (attributes?: {}, ...children: HTMLElement[]): HTMLTableSectionElement =>
|
||||
create_element("thead", attributes, ...children),
|
||||
|
||||
tbody: (attributes?: {}, ...children: HTMLElement[]): HTMLTableSectionElement =>
|
||||
create_element("tbody", attributes, ...children),
|
||||
|
||||
tr: (attributes?: {}, ...children: HTMLElement[]): HTMLTableRowElement =>
|
||||
create_element("tr", attributes, ...children),
|
||||
|
||||
|
@ -29,8 +29,8 @@
|
||||
--input-text-color: hsl(0, 0%, 75%);
|
||||
--input-text-color-disabled: var(--text-color-disabled);
|
||||
--input-border: solid 1px hsl(0, 0%, 25%);
|
||||
--input-border-hover: hsl(0, 0%, 30%);
|
||||
--input-border-focus: hsl(0, 0%, 40%);
|
||||
--input-border-hover: solid 1px hsl(0, 0%, 30%);
|
||||
--input-border-focus: solid 1px hsl(0, 0%, 40%);
|
||||
--input-border-disabled: solid 1px hsl(0, 0%, 20%);
|
||||
|
||||
--input-inner-border: solid 1px hsl(0, 0%, 5%);
|
||||
@ -67,6 +67,11 @@ body {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1em;
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
|
||||
#root *[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
@ -3,13 +3,13 @@ import { enum_values } from "../enums";
|
||||
export const RARE_ENEMY_PROB = 1 / 512;
|
||||
export const KONDRIEU_PROB = 1 / 10;
|
||||
|
||||
export enum Server {
|
||||
export enum ServerModel {
|
||||
Ephinea = "Ephinea",
|
||||
}
|
||||
|
||||
export const Servers: Server[] = enum_values(Server);
|
||||
export const ServerModels: ServerModel[] = enum_values(ServerModel);
|
||||
|
||||
export enum SectionId {
|
||||
export enum SectionIdModel {
|
||||
Viridia,
|
||||
Greenill,
|
||||
Skyly,
|
||||
@ -22,13 +22,13 @@ export enum SectionId {
|
||||
Whitill,
|
||||
}
|
||||
|
||||
export const SectionIds: SectionId[] = enum_values(SectionId);
|
||||
export const SectionIdModels: SectionIdModel[] = enum_values(SectionIdModel);
|
||||
|
||||
export enum Difficulty {
|
||||
export enum DifficultyModel {
|
||||
Normal,
|
||||
Hard,
|
||||
VHard,
|
||||
Ultimate,
|
||||
}
|
||||
|
||||
export const Difficulties: Difficulty[] = enum_values(Difficulty);
|
||||
export const DifficultyModels: DifficultyModel[] = enum_values(DifficultyModel);
|
@ -1,4 +1,6 @@
|
||||
import { observable, computed } from "mobx";
|
||||
import { Property } from "../observable/property/Property";
|
||||
import { WritableProperty } from "../observable/property/WritableProperty";
|
||||
import { property } from "../observable";
|
||||
|
||||
//
|
||||
// Item types.
|
||||
@ -74,21 +76,40 @@ export interface Item {
|
||||
}
|
||||
|
||||
export class WeaponItem implements Item {
|
||||
/**
|
||||
* Integer from 0 to 100.
|
||||
*/
|
||||
@observable attribute: number = 0;
|
||||
/**
|
||||
* Integer from 0 to 100.
|
||||
*/
|
||||
@observable hit: number = 0;
|
||||
@observable grind: number = 0;
|
||||
readonly type: WeaponItemType;
|
||||
|
||||
@computed get grind_atp(): number {
|
||||
return 2 * this.grind;
|
||||
/**
|
||||
* Integer from 0 to 100.
|
||||
*/
|
||||
readonly attribute: Property<number>;
|
||||
|
||||
/**
|
||||
* Integer from 0 to 100.
|
||||
*/
|
||||
readonly hit: Property<number>;
|
||||
|
||||
readonly grind: Property<number>;
|
||||
|
||||
readonly grind_atp: Property<number>;
|
||||
|
||||
private readonly _attribute: WritableProperty<number>;
|
||||
private readonly _hit: WritableProperty<number>;
|
||||
private readonly _grind: WritableProperty<number>;
|
||||
|
||||
constructor(type: WeaponItemType) {
|
||||
this.type = type;
|
||||
|
||||
this._attribute = property(0);
|
||||
this.attribute = this._attribute;
|
||||
|
||||
this._hit = property(0);
|
||||
this.hit = this._hit;
|
||||
|
||||
this._grind = property(0);
|
||||
this.grind = this._grind;
|
||||
|
||||
this.grind_atp = this.grind.map(grind => 2 * grind);
|
||||
}
|
||||
|
||||
constructor(readonly type: WeaponItemType) {}
|
||||
}
|
||||
|
||||
export class ArmorItem implements Item {
|
@ -15,7 +15,7 @@ export function property<T>(value: T): WritableProperty<T> {
|
||||
return new SimpleProperty(value);
|
||||
}
|
||||
|
||||
export function array_property<T>(...values: T[]): WritableListProperty<T> {
|
||||
export function list_property<T>(...values: T[]): WritableListProperty<T> {
|
||||
return new SimpleWritableListProperty(...values);
|
||||
}
|
||||
|
||||
|
141
src/core/observable/property/LoadableProperty.ts
Normal file
141
src/core/observable/property/LoadableProperty.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { Property } from "./Property";
|
||||
import { WritableProperty } from "./WritableProperty";
|
||||
import { property } from "../index";
|
||||
import { AbstractProperty } from "./AbstractProperty";
|
||||
|
||||
export enum LoadableState {
|
||||
/**
|
||||
* No attempt has been made to load data.
|
||||
*/
|
||||
Uninitialized,
|
||||
|
||||
/**
|
||||
* The first data load is underway.
|
||||
*/
|
||||
Initializing,
|
||||
|
||||
/**
|
||||
* Data was loaded at least once. The most recent load was successful.
|
||||
*/
|
||||
Nominal,
|
||||
|
||||
/**
|
||||
* Data was loaded at least once. The most recent load failed.
|
||||
*/
|
||||
Error,
|
||||
|
||||
/**
|
||||
* Data was loaded at least once. Another data load is underway.
|
||||
*/
|
||||
Reloading,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a value that can be loaded asynchronously.
|
||||
* [state]{@link Loadable#state} represents the current state of this Loadable's value.
|
||||
*/
|
||||
export class LoadableProperty<T> extends AbstractProperty<T> implements Property<T> {
|
||||
/**
|
||||
* When value is accessed and this Loadable is uninitialized, a load will be triggered.
|
||||
* Will return the initial value until a load has succeeded.
|
||||
*/
|
||||
get val(): T {
|
||||
return this.get_val();
|
||||
}
|
||||
|
||||
readonly state: Property<LoadableState>;
|
||||
|
||||
/**
|
||||
* True if the initial data load has happened. It may or may not have succeeded.
|
||||
* Check [error]{@link Loadable#error} to know whether an error occurred.
|
||||
*/
|
||||
readonly is_initialized: Property<boolean>;
|
||||
|
||||
/**
|
||||
* True if a data load is underway. This may be the initializing load or a later reload.
|
||||
*/
|
||||
readonly is_loading: Property<boolean>;
|
||||
|
||||
/**
|
||||
* This property returns valid data as soon as possible.
|
||||
* If the Loadable is uninitialized a data load will be triggered, otherwise the current value will be returned.
|
||||
*/
|
||||
get promise(): Promise<T> {
|
||||
// Load value on first use.
|
||||
if (this._state.val === LoadableState.Uninitialized) {
|
||||
return this.load_value();
|
||||
} else {
|
||||
return this._promise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the {@link Error} object if an error occurred during the most recent data load.
|
||||
*/
|
||||
readonly error: Property<Error | undefined>;
|
||||
|
||||
private _val: T;
|
||||
private _promise: Promise<T>;
|
||||
private readonly _state: WritableProperty<LoadableState> = property(
|
||||
LoadableState.Uninitialized,
|
||||
);
|
||||
private readonly _load?: () => Promise<T>;
|
||||
private readonly _error: WritableProperty<Error | undefined> = property(undefined);
|
||||
|
||||
constructor(initial_value: T, load?: () => Promise<T>) {
|
||||
super();
|
||||
|
||||
this._val = initial_value;
|
||||
this._promise = new Promise(resolve => resolve(this._val));
|
||||
this.state = this._state;
|
||||
|
||||
this.is_initialized = this.state.map(state => state !== LoadableState.Uninitialized);
|
||||
|
||||
this.is_loading = this.state.map(
|
||||
state => state === LoadableState.Initializing || state === LoadableState.Reloading,
|
||||
);
|
||||
|
||||
this._load = load;
|
||||
this.error = this._error;
|
||||
}
|
||||
|
||||
get_val(): T {
|
||||
// Load value on first use.
|
||||
if (this._state.val === LoadableState.Uninitialized) {
|
||||
this.load_value();
|
||||
}
|
||||
|
||||
return this._val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the data. Initializes the Loadable if it is uninitialized.
|
||||
*/
|
||||
load(): Promise<T> {
|
||||
return this.load_value();
|
||||
}
|
||||
|
||||
private async load_value(): Promise<T> {
|
||||
if (this.is_loading.val) return this._promise;
|
||||
|
||||
this._state.val = LoadableState.Initializing;
|
||||
const old_val = this._val;
|
||||
|
||||
try {
|
||||
if (this._load) {
|
||||
this._promise = this._load();
|
||||
this._val = await this._promise;
|
||||
}
|
||||
|
||||
this._state.val = LoadableState.Nominal;
|
||||
this._error.val = undefined;
|
||||
return this._val;
|
||||
} catch (e) {
|
||||
this._state.val = LoadableState.Error;
|
||||
this._error.val = e;
|
||||
throw e;
|
||||
} finally {
|
||||
this.emit(old_val);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
import Logger from "js-logger";
|
||||
import { Server } from "./domain";
|
||||
import { ServerModel } from "./model";
|
||||
|
||||
const logger = Logger.get("core/persistence/Persister");
|
||||
|
||||
export abstract class Persister {
|
||||
protected persist_for_server(server: Server, key: string, data: any): void {
|
||||
this.persist(key + "." + Server[server], data);
|
||||
protected persist_for_server(server: ServerModel, key: string, data: any): void {
|
||||
this.persist(this.server_key(server, key), data);
|
||||
}
|
||||
|
||||
protected persist(key: string, data: any): void {
|
||||
@ -16,8 +16,8 @@ export abstract class Persister {
|
||||
}
|
||||
}
|
||||
|
||||
protected async load_for_server<T>(server: Server, key: string): Promise<T | undefined> {
|
||||
return this.load(key + "." + Server[server]);
|
||||
protected async load_for_server<T>(server: ServerModel, key: string): Promise<T | undefined> {
|
||||
return this.load(this.server_key(server, key));
|
||||
}
|
||||
|
||||
protected async load<T>(key: string): Promise<T | undefined> {
|
||||
@ -29,4 +29,18 @@ export abstract class Persister {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private server_key(server: ServerModel, key: string): string {
|
||||
let k = key + ".";
|
||||
|
||||
switch (server) {
|
||||
case ServerModel.Ephinea:
|
||||
k += "Ephinea";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Server ${ServerModel[server]} not supported.`);
|
||||
}
|
||||
|
||||
return k;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { WritableProperty } from "../observable/property/WritableProperty";
|
||||
import { Disposable } from "../observable/Disposable";
|
||||
import { property } from "../observable";
|
||||
import { Property } from "../observable/property/Property";
|
||||
import { ServerModel } from "../model";
|
||||
|
||||
export enum GuiTool {
|
||||
Viewer,
|
||||
@ -17,7 +19,9 @@ const STRING_TO_GUI_TOOL = new Map([...GUI_TOOL_TO_STRING.entries()].map(([k, v]
|
||||
|
||||
class GuiStore implements Disposable {
|
||||
readonly tool: WritableProperty<GuiTool> = property(GuiTool.Viewer);
|
||||
readonly server: Property<ServerModel>;
|
||||
|
||||
private readonly _server: WritableProperty<ServerModel> = property(ServerModel.Ephinea);
|
||||
private readonly hash_disposer = this.tool.observe(({ value: tool }) => {
|
||||
window.location.hash = `#/${gui_tool_to_string(tool)}`;
|
||||
});
|
||||
@ -27,6 +31,8 @@ class GuiStore implements Disposable {
|
||||
const tool = window.location.hash.slice(2);
|
||||
this.tool.val = string_to_gui_tool(tool) || GuiTool.Viewer;
|
||||
|
||||
this.server = this._server;
|
||||
|
||||
window.addEventListener("keydown", this.dispatch_global_keydown);
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { observable } from "mobx";
|
||||
import {
|
||||
ArmorItemType,
|
||||
ItemType,
|
||||
@ -6,28 +5,33 @@ import {
|
||||
ToolItemType,
|
||||
UnitItemType,
|
||||
WeaponItemType,
|
||||
} from "../domain/items";
|
||||
import { Loadable } from "../Loadable";
|
||||
} from "../model/items";
|
||||
import { ServerMap } from "./ServerMap";
|
||||
import { ItemTypeDto } from "../dto";
|
||||
import { Server } from "../../../core/domain";
|
||||
import { ServerModel } from "../model";
|
||||
import { LoadableProperty } from "../observable/property/LoadableProperty";
|
||||
import { ItemTypeDto } from "../dto/ItemTypeDto";
|
||||
|
||||
export class ItemTypeStore {
|
||||
private id_to_item_type: ItemType[] = [];
|
||||
readonly item_types: ItemType[] = [];
|
||||
|
||||
@observable item_types: ItemType[] = [];
|
||||
private readonly server: ServerModel;
|
||||
private readonly id_to_item_type: ItemType[] = [];
|
||||
|
||||
constructor(server: ServerModel) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
get_by_id(id: number): ItemType | undefined {
|
||||
return this.id_to_item_type[id];
|
||||
}
|
||||
|
||||
load = async (server: Server): Promise<ItemTypeStore> => {
|
||||
load = async (): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`${process.env.PUBLIC_URL}/itemTypes.${Server[server].toLowerCase()}.json`,
|
||||
`${process.env.PUBLIC_URL}/itemTypes.${ServerModel[this.server].toLowerCase()}.json`,
|
||||
);
|
||||
const data: ItemTypeDto[] = await response.json();
|
||||
|
||||
const item_types = new Array<ItemType>();
|
||||
this.item_types.splice(0, Infinity);
|
||||
|
||||
for (const item_type_dto of data) {
|
||||
let item_type: ItemType;
|
||||
@ -85,16 +89,17 @@ export class ItemTypeStore {
|
||||
}
|
||||
|
||||
this.id_to_item_type[item_type.id] = item_type;
|
||||
item_types.push(item_type);
|
||||
this.item_types.push(item_type);
|
||||
}
|
||||
|
||||
this.item_types = item_types;
|
||||
|
||||
return this;
|
||||
};
|
||||
}
|
||||
|
||||
export const item_type_stores: ServerMap<Loadable<ItemTypeStore>> = new ServerMap(server => {
|
||||
const store = new ItemTypeStore();
|
||||
return new Loadable(store, () => store.load(server));
|
||||
});
|
||||
export const item_type_stores: ServerMap<LoadableProperty<ItemTypeStore>> = new ServerMap(
|
||||
server => {
|
||||
const store = new ItemTypeStore(server);
|
||||
return new LoadableProperty(store, async () => {
|
||||
await store.load();
|
||||
return store;
|
||||
});
|
||||
},
|
||||
);
|
20
src/core/stores/ServerMap.ts
Normal file
20
src/core/stores/ServerMap.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { ServerModel } from "../model";
|
||||
import { EnumMap } from "../enums";
|
||||
import { Property } from "../observable/property/Property";
|
||||
import { gui_store } from "./GuiStore";
|
||||
|
||||
/**
|
||||
* Map with a guaranteed value per server.
|
||||
*/
|
||||
export class ServerMap<V> extends EnumMap<ServerModel, V> {
|
||||
/**
|
||||
* @returns the value for the current server as set in {@link gui_store}.
|
||||
*/
|
||||
readonly current: Property<V>;
|
||||
|
||||
constructor(initial_value: (server: ServerModel) => V) {
|
||||
super(ServerModel, initial_value);
|
||||
|
||||
this.current = gui_store.server.map(server => this.get(server));
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { Undo } from "./Undo";
|
||||
import { WritableListProperty } from "../observable/property/list/WritableListProperty";
|
||||
import { Action } from "./Action";
|
||||
import { array_property, map, property } from "../observable";
|
||||
import { list_property, map, property } from "../observable";
|
||||
import { NOOP_UNDO } from "./noop_undo";
|
||||
import { undo_manager } from "./UndoManager";
|
||||
import Logger = require("js-logger");
|
||||
@ -12,7 +12,7 @@ const logger = Logger.get("core/undo/UndoStack");
|
||||
* Full-fledged linear undo/redo implementation.
|
||||
*/
|
||||
export class UndoStack implements Undo {
|
||||
private readonly stack: WritableListProperty<Action> = array_property();
|
||||
private readonly stack: WritableListProperty<Action> = list_property();
|
||||
|
||||
/**
|
||||
* The index where new actions are inserted.
|
||||
|
6
src/hunt_optimizer/dto/QuestDto.ts
Normal file
6
src/hunt_optimizer/dto/QuestDto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type QuestDto = {
|
||||
id: number;
|
||||
name: string;
|
||||
episode: 1 | 2 | 4;
|
||||
enemyCounts: { [npcTypeCode: string]: number };
|
||||
};
|
18
src/hunt_optimizer/dto/drops.ts
Normal file
18
src/hunt_optimizer/dto/drops.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export type EnemyDropDto = {
|
||||
difficulty: string;
|
||||
episode: number;
|
||||
sectionId: string;
|
||||
enemy: string;
|
||||
itemTypeId: number;
|
||||
dropRate: number;
|
||||
rareRate: number;
|
||||
};
|
||||
|
||||
export type BoxDropDto = {
|
||||
difficulty: string;
|
||||
episode: number;
|
||||
sectionId: string;
|
||||
areaId: number;
|
||||
itemTypeId: number;
|
||||
dropRate: number;
|
||||
};
|
22
src/hunt_optimizer/gui/HuntOptimizerView.ts
Normal file
22
src/hunt_optimizer/gui/HuntOptimizerView.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { TabContainer } from "../../core/gui/TabContainer";
|
||||
|
||||
export class HuntOptimizerView extends TabContainer {
|
||||
constructor() {
|
||||
super(
|
||||
{
|
||||
title: "Methods",
|
||||
key: "methods",
|
||||
create_view: async function() {
|
||||
return new (await import("./MethodsView")).MethodsView();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Optimize",
|
||||
key: "optimize",
|
||||
create_view: async function() {
|
||||
return new (await import("./OptimizerView")).OptimizerView();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
91
src/hunt_optimizer/gui/MethodsForEpisodeView.css
Normal file
91
src/hunt_optimizer/gui/MethodsForEpisodeView.css
Normal file
@ -0,0 +1,91 @@
|
||||
.hunt_optimizer_MethodsForEpisodeView table {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-color);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView tr {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView thead tr {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th,
|
||||
.hunt_optimizer_MethodsForEpisodeView td {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 80px;
|
||||
padding: 3px 6px;
|
||||
border-right: solid 1px var(--border-color);
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th:nth-child(2) {
|
||||
position: sticky;
|
||||
left: 250px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView tbody {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView tbody th,
|
||||
.hunt_optimizer_MethodsForEpisodeView tbody td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView tbody th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView tbody td {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th.input {
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput {
|
||||
z-index: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:hover,
|
||||
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:focus-within {
|
||||
margin: -1px;
|
||||
height: calc(100% + 2px);
|
||||
width: calc(100% + 2px);
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:hover {
|
||||
z-index: 4;
|
||||
border: var(--input-border-hover);
|
||||
}
|
||||
|
||||
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:focus-within {
|
||||
z-index: 6;
|
||||
border: var(--input-border-focus);
|
||||
}
|
87
src/hunt_optimizer/gui/MethodsForEpisodeView.ts
Normal file
87
src/hunt_optimizer/gui/MethodsForEpisodeView.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { ResizableWidget } from "../../core/gui/ResizableWidget";
|
||||
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
|
||||
import { el } from "../../core/gui/dom";
|
||||
import { hunt_method_stores } from "../stores/HuntMethodStore";
|
||||
import { HuntMethodModel } from "../model/HuntMethodModel";
|
||||
import {
|
||||
ENEMY_NPC_TYPES,
|
||||
npc_data,
|
||||
NpcType,
|
||||
} from "../../core/data_formats/parsing/quest/npc_types";
|
||||
import "./MethodsForEpisodeView.css";
|
||||
import { Disposer } from "../../core/observable/Disposer";
|
||||
import { DurationInput } from "../../core/gui/DurationInput";
|
||||
|
||||
export class MethodsForEpisodeView extends ResizableWidget {
|
||||
private readonly episode: Episode;
|
||||
private readonly enemy_types: NpcType[];
|
||||
private readonly tbody_element: HTMLTableSectionElement;
|
||||
private readonly time_disposer = this.disposable(new Disposer());
|
||||
|
||||
constructor(episode: Episode) {
|
||||
super(el.div({ class: "hunt_optimizer_MethodsForEpisodeView" }));
|
||||
|
||||
this.episode = episode;
|
||||
|
||||
this.enemy_types = ENEMY_NPC_TYPES.filter(type => npc_data(type).episode === this.episode);
|
||||
|
||||
const table_element = el.table();
|
||||
const thead_element = el.thead();
|
||||
const header_tr_element = el.tr();
|
||||
|
||||
header_tr_element.append(el.th({ text: "Method" }), el.th({ text: "Time" }));
|
||||
|
||||
for (const enemy_type of this.enemy_types) {
|
||||
header_tr_element.append(
|
||||
el.th({
|
||||
text: npc_data(enemy_type).simple_name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.tbody_element = el.tbody();
|
||||
|
||||
thead_element.append(header_tr_element);
|
||||
table_element.append(thead_element, this.tbody_element);
|
||||
this.element.append(table_element);
|
||||
|
||||
hunt_method_stores.current.val.methods.load();
|
||||
|
||||
this.disposables(
|
||||
hunt_method_stores.current
|
||||
.flat_map(current => current.methods)
|
||||
.observe(({ value }) => this.update_table(value)),
|
||||
);
|
||||
}
|
||||
|
||||
private update_table(methods: HuntMethodModel[]): void {
|
||||
this.time_disposer.dispose_all();
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
for (const method of methods) {
|
||||
if (method.episode === this.episode) {
|
||||
const time_input = this.time_disposer.add(new DurationInput(method.time.val));
|
||||
|
||||
this.time_disposer.add(
|
||||
time_input.value.observe(({ value }) => method.set_user_time(value)),
|
||||
);
|
||||
|
||||
const cells: HTMLTableCellElement[] = [
|
||||
el.th({ text: method.name }),
|
||||
el.th({ class: "input" }, time_input.element),
|
||||
];
|
||||
|
||||
// One cell per enemy type.
|
||||
for (const enemy_type of this.enemy_types) {
|
||||
const count = method.enemy_counts.get(enemy_type);
|
||||
cells.push(el.td({ text: count == undefined ? "" : count.toString() }));
|
||||
}
|
||||
|
||||
frag.append(el.tr({}, ...cells));
|
||||
}
|
||||
}
|
||||
|
||||
this.tbody_element.innerHTML = "";
|
||||
this.tbody_element.append(frag);
|
||||
}
|
||||
}
|
31
src/hunt_optimizer/gui/MethodsView.ts
Normal file
31
src/hunt_optimizer/gui/MethodsView.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { TabContainer } from "../../core/gui/TabContainer";
|
||||
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
|
||||
import { MethodsForEpisodeView } from "./MethodsForEpisodeView";
|
||||
|
||||
export class MethodsView extends TabContainer {
|
||||
constructor() {
|
||||
super(
|
||||
{
|
||||
title: "Episode I",
|
||||
key: "episode_1",
|
||||
create_view: async function() {
|
||||
return new MethodsForEpisodeView(Episode.I);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Episode II",
|
||||
key: "episode_2",
|
||||
create_view: async function() {
|
||||
return new MethodsForEpisodeView(Episode.II);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Episode IV",
|
||||
key: "episode_4",
|
||||
create_view: async function() {
|
||||
return new MethodsForEpisodeView(Episode.IV);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
22
src/hunt_optimizer/gui/OptimizerView.ts
Normal file
22
src/hunt_optimizer/gui/OptimizerView.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ResizableWidget } from "../../core/gui/ResizableWidget";
|
||||
import { el } from "../../core/gui/dom";
|
||||
import { WantedItemsView } from "./WantedItemsView";
|
||||
|
||||
export class OptimizerView extends ResizableWidget {
|
||||
private readonly wanted_items_view: WantedItemsView;
|
||||
|
||||
constructor() {
|
||||
super(el.div({ class: "hunt_optimizer_OptimizerView" }));
|
||||
|
||||
this.wanted_items_view = this.disposable(new WantedItemsView());
|
||||
this.element.append(this.wanted_items_view.element);
|
||||
}
|
||||
|
||||
resize(width: number, height: number): this {
|
||||
super.resize(width, height);
|
||||
|
||||
this.wanted_items_view.resize(Math.min(200, width), height);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
5
src/hunt_optimizer/gui/WantedItemsView.css
Normal file
5
src/hunt_optimizer/gui/WantedItemsView.css
Normal file
@ -0,0 +1,5 @@
|
||||
.hunt_optimizer_WantedItemsView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
11
src/hunt_optimizer/gui/WantedItemsView.ts
Normal file
11
src/hunt_optimizer/gui/WantedItemsView.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ResizableWidget } from "../../core/gui/ResizableWidget";
|
||||
import { el } from "../../core/gui/dom";
|
||||
import "./WantedItemsView.css";
|
||||
|
||||
export class WantedItemsView extends ResizableWidget {
|
||||
constructor() {
|
||||
super(el.div({ class: "hunt_optimizer_WantedItemsView" }));
|
||||
|
||||
this.element.append(el.h2({ text: "Wanted Items" }));
|
||||
}
|
||||
}
|
53
src/hunt_optimizer/model/HuntMethodModel.ts
Normal file
53
src/hunt_optimizer/model/HuntMethodModel.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
|
||||
import { NpcType } from "../../core/data_formats/parsing/quest/npc_types";
|
||||
import { SimpleQuestModel } from "./SimpleQuestModel";
|
||||
import { Property } from "../../core/observable/property/Property";
|
||||
import { WritableProperty } from "../../core/observable/property/WritableProperty";
|
||||
import { property } from "../../core/observable";
|
||||
import { Duration } from "luxon";
|
||||
|
||||
export class HuntMethodModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly episode: Episode;
|
||||
readonly quest: SimpleQuestModel;
|
||||
readonly enemy_counts: Map<NpcType, number>;
|
||||
/**
|
||||
* The time it takes to complete the quest in hours.
|
||||
*/
|
||||
readonly default_time: Duration;
|
||||
/**
|
||||
* The time it takes to complete the quest in hours as specified by the user.
|
||||
*/
|
||||
readonly user_time: Property<Duration | undefined>;
|
||||
readonly time: Property<Duration>;
|
||||
|
||||
private readonly _user_time: WritableProperty<Duration | undefined>;
|
||||
|
||||
constructor(id: string, name: string, quest: SimpleQuestModel, default_time: Duration) {
|
||||
if (!id) throw new Error("id is required.");
|
||||
if (!Duration.isDuration(default_time))
|
||||
throw new Error("default_time must a valid duration.");
|
||||
if (!name) throw new Error("name is required.");
|
||||
if (!quest) throw new Error("quest is required.");
|
||||
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.episode = quest.episode;
|
||||
this.quest = quest;
|
||||
this.enemy_counts = quest.enemy_counts;
|
||||
this.default_time = default_time;
|
||||
|
||||
this._user_time = property(undefined);
|
||||
this.user_time = this._user_time;
|
||||
|
||||
this.time = this.user_time.map(user_time =>
|
||||
user_time != undefined ? user_time : this.default_time,
|
||||
);
|
||||
}
|
||||
|
||||
set_user_time(user_time?: Duration): this {
|
||||
this._user_time.val = user_time;
|
||||
return this;
|
||||
}
|
||||
}
|
24
src/hunt_optimizer/model/ItemDrop.ts
Normal file
24
src/hunt_optimizer/model/ItemDrop.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ItemType } from "../../core/model/items";
|
||||
import { DifficultyModel, SectionIdModel } from "../../core/model";
|
||||
import { NpcType } from "../../core/data_formats/parsing/quest/npc_types";
|
||||
|
||||
interface ItemDrop {
|
||||
readonly item_type: ItemType;
|
||||
readonly anything_rate: number;
|
||||
readonly rare_rate: number;
|
||||
}
|
||||
|
||||
export class EnemyDrop implements ItemDrop {
|
||||
readonly rate: number;
|
||||
|
||||
constructor(
|
||||
readonly difficulty: DifficultyModel,
|
||||
readonly section_id: SectionIdModel,
|
||||
readonly npc_type: NpcType,
|
||||
readonly item_type: ItemType,
|
||||
readonly anything_rate: number,
|
||||
readonly rare_rate: number,
|
||||
) {
|
||||
this.rate = anything_rate * rare_rate;
|
||||
}
|
||||
}
|
15
src/hunt_optimizer/model/SimpleQuestModel.ts
Normal file
15
src/hunt_optimizer/model/SimpleQuestModel.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
|
||||
import { NpcType } from "../../core/data_formats/parsing/quest/npc_types";
|
||||
|
||||
export class SimpleQuestModel {
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly name: string,
|
||||
readonly episode: Episode,
|
||||
readonly enemy_counts: Map<NpcType, number>,
|
||||
) {
|
||||
if (!id) throw new Error("id is required.");
|
||||
if (!name) throw new Error("name is required.");
|
||||
if (!enemy_counts) throw new Error("enemyCounts is required.");
|
||||
}
|
||||
}
|
26
src/hunt_optimizer/model/index.ts
Normal file
26
src/hunt_optimizer/model/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { ItemType } from "../../core/model/items";
|
||||
import { DifficultyModel, SectionIdModel } from "../../core/model";
|
||||
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
|
||||
|
||||
export class WantedItemModel {
|
||||
constructor(readonly item_type: ItemType, readonly amount: number) {}
|
||||
}
|
||||
|
||||
export class OptimalResultModel {
|
||||
constructor(
|
||||
readonly wanted_items: ItemType[],
|
||||
readonly optimal_methods: OptimalMethodModel[],
|
||||
) {}
|
||||
}
|
||||
|
||||
export class OptimalMethodModel {
|
||||
constructor(
|
||||
readonly difficulty: DifficultyModel,
|
||||
readonly section_ids: SectionIdModel[],
|
||||
readonly method_name: string,
|
||||
readonly method_episode: Episode,
|
||||
readonly method_time: number,
|
||||
readonly runs: number,
|
||||
readonly item_counts: Map<ItemType, number>,
|
||||
) {}
|
||||
}
|
@ -1,16 +1,17 @@
|
||||
import { Persister } from "../../../core/persistence";
|
||||
import { Server } from "../../../core/domain";
|
||||
import { HuntMethod } from "../domain";
|
||||
import { Persister } from "../../core/persistence";
|
||||
import { ServerModel } from "../../core/model";
|
||||
import { HuntMethodModel } from "../model/HuntMethodModel";
|
||||
import { Duration } from "luxon";
|
||||
|
||||
const METHOD_USER_TIMES_KEY = "HuntMethodStore.methodUserTimes";
|
||||
|
||||
class HuntMethodPersister extends Persister {
|
||||
persist_method_user_times(hunt_methods: HuntMethod[], server: Server): void {
|
||||
persist_method_user_times(hunt_methods: HuntMethodModel[], server: ServerModel): void {
|
||||
const user_times: PersistedUserTimes = {};
|
||||
|
||||
for (const method of hunt_methods) {
|
||||
if (method.user_time != undefined) {
|
||||
user_times[method.id] = method.user_time;
|
||||
if (method.user_time.val != undefined) {
|
||||
user_times[method.id] = method.user_time.val.as("hours");
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,9 +19,9 @@ class HuntMethodPersister extends Persister {
|
||||
}
|
||||
|
||||
async load_method_user_times(
|
||||
hunt_methods: HuntMethod[],
|
||||
server: Server,
|
||||
): Promise<HuntMethod[]> {
|
||||
hunt_methods: HuntMethodModel[],
|
||||
server: ServerModel,
|
||||
): Promise<void> {
|
||||
const user_times = await this.load_for_server<PersistedUserTimes>(
|
||||
server,
|
||||
METHOD_USER_TIMES_KEY,
|
||||
@ -28,11 +29,12 @@ class HuntMethodPersister extends Persister {
|
||||
|
||||
if (user_times) {
|
||||
for (const method of hunt_methods) {
|
||||
method.user_time = user_times[method.id];
|
||||
const hours = user_times[method.id];
|
||||
method.set_user_time(
|
||||
hours == undefined ? undefined : Duration.fromObject({ hours }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return hunt_methods;
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Server } from "../../../core/domain";
|
||||
import { WantedItem } from "../stores/HuntOptimizerStore";
|
||||
import { ServerModel } from "../../core/model";
|
||||
import { item_type_stores } from "../../core/stores/ItemTypeStore";
|
||||
import { Persister } from "../../../core/persistence";
|
||||
import { Persister } from "../../core/persistence";
|
||||
import { WantedItemModel } from "../model";
|
||||
|
||||
const WANTED_ITEMS_KEY = "HuntOptimizerStore.wantedItems";
|
||||
|
||||
class HuntOptimizerPersister extends Persister {
|
||||
persist_wanted_items(server: Server, wanted_items: WantedItem[]): void {
|
||||
persist_wanted_items(server: ServerModel, wanted_items: WantedItemModel[]): void {
|
||||
this.persist_for_server(
|
||||
server,
|
||||
WANTED_ITEMS_KEY,
|
||||
@ -19,14 +19,14 @@ class HuntOptimizerPersister extends Persister {
|
||||
);
|
||||
}
|
||||
|
||||
async load_wanted_items(server: Server): Promise<WantedItem[]> {
|
||||
async load_wanted_items(server: ServerModel): Promise<WantedItemModel[]> {
|
||||
const item_store = await item_type_stores.get(server).promise;
|
||||
|
||||
const persisted_wanted_items = await this.load_for_server<PersistedWantedItem[]>(
|
||||
server,
|
||||
WANTED_ITEMS_KEY,
|
||||
);
|
||||
const wanted_items: WantedItem[] = [];
|
||||
const wanted_items: WantedItemModel[] = [];
|
||||
|
||||
if (persisted_wanted_items) {
|
||||
for (const { itemTypeId, itemKindId, amount } of persisted_wanted_items) {
|
||||
@ -36,7 +36,7 @@ class HuntOptimizerPersister extends Persister {
|
||||
: item_store.get_by_id(itemKindId!);
|
||||
|
||||
if (item) {
|
||||
wanted_items.push(new WantedItem(item, amount));
|
||||
wanted_items.push(new WantedItemModel(item, amount));
|
||||
}
|
||||
}
|
||||
}
|
107
src/hunt_optimizer/stores/HuntMethodStore.ts
Normal file
107
src/hunt_optimizer/stores/HuntMethodStore.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import Logger from "js-logger";
|
||||
import { ServerMap } from "../../core/stores/ServerMap";
|
||||
import { LoadableProperty } from "../../core/observable/property/LoadableProperty";
|
||||
import { Disposable } from "../../core/observable/Disposable";
|
||||
import { Disposer } from "../../core/observable/Disposer";
|
||||
import { ServerModel } from "../../core/model";
|
||||
import { QuestDto } from "../dto/QuestDto";
|
||||
import { NpcType } from "../../core/data_formats/parsing/quest/npc_types";
|
||||
import { SimpleQuestModel } from "../model/SimpleQuestModel";
|
||||
import { HuntMethodModel } from "../model/HuntMethodModel";
|
||||
import { hunt_method_persister } from "../persistence/HuntMethodPersister";
|
||||
import { Duration } from "luxon";
|
||||
|
||||
const logger = Logger.get("hunt_optimizer/stores/HuntMethodStore");
|
||||
|
||||
const DEFAULT_DURATION = Duration.fromObject({ minutes: 30 });
|
||||
const DEFAULT_GOVERNMENT_TEST_DURATION = Duration.fromObject({ minutes: 45 });
|
||||
const DEFAULT_LARGE_ENEMY_COUNT_DURATION = Duration.fromObject({ minutes: 45 });
|
||||
|
||||
class HuntMethodStore implements Disposable {
|
||||
readonly methods: LoadableProperty<HuntMethodModel[]>;
|
||||
|
||||
private readonly disposer = new Disposer();
|
||||
private readonly server: ServerModel;
|
||||
|
||||
constructor(server: ServerModel) {
|
||||
this.methods = new LoadableProperty([], () => this.load_hunt_methods(server));
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposer.dispose();
|
||||
}
|
||||
|
||||
private async load_hunt_methods(server: ServerModel): Promise<HuntMethodModel[]> {
|
||||
const response = await fetch(
|
||||
`${process.env.PUBLIC_URL}/quests.${ServerModel[server].toLowerCase()}.json`,
|
||||
);
|
||||
const quests = (await response.json()) as QuestDto[];
|
||||
const methods = new Array<HuntMethodModel>();
|
||||
|
||||
for (const quest of quests) {
|
||||
let total_enemy_count = 0;
|
||||
const enemy_counts = new Map<NpcType, number>();
|
||||
|
||||
for (const [code, count] of Object.entries(quest.enemyCounts)) {
|
||||
const npc_type = (NpcType as any)[code];
|
||||
|
||||
if (!npc_type) {
|
||||
logger.error(`No NpcType found for code ${code}.`);
|
||||
} else {
|
||||
enemy_counts.set(npc_type, count);
|
||||
total_enemy_count += count;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out some quests.
|
||||
/* eslint-disable no-fallthrough */
|
||||
switch (quest.id) {
|
||||
// The following quests are left out because their enemies don't drop anything.
|
||||
case 31: // Black Paper's Dangerous Deal
|
||||
case 34: // Black Paper's Dangerous Deal 2
|
||||
case 1305: // Maximum Attack S (Ep. 1)
|
||||
case 1306: // Maximum Attack S (Ep. 2)
|
||||
case 1307: // Maximum Attack S (Ep. 4)
|
||||
case 313: // Beyond the Horizon
|
||||
|
||||
// MAXIMUM ATTACK 3 Ver2 is filtered out because its actual enemy count depends on the path taken.
|
||||
// TODO: generate a method per path.
|
||||
case 314:
|
||||
continue;
|
||||
}
|
||||
|
||||
methods.push(
|
||||
new HuntMethodModel(
|
||||
`q${quest.id}`,
|
||||
quest.name,
|
||||
new SimpleQuestModel(quest.id, quest.name, quest.episode, enemy_counts),
|
||||
/^\d-\d.*/.test(quest.name)
|
||||
? DEFAULT_GOVERNMENT_TEST_DURATION
|
||||
: total_enemy_count > 400
|
||||
? DEFAULT_LARGE_ENEMY_COUNT_DURATION
|
||||
: DEFAULT_DURATION,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await this.load_user_times(methods, server);
|
||||
return methods;
|
||||
}
|
||||
|
||||
private load_user_times = async (methods: HuntMethodModel[], server: ServerModel) => {
|
||||
await hunt_method_persister.load_method_user_times(methods, server);
|
||||
|
||||
this.disposer.dispose_all();
|
||||
|
||||
this.disposer.add_all(
|
||||
...methods.map(method => method.user_time.observe(this.persist_user_times)),
|
||||
);
|
||||
};
|
||||
|
||||
private persist_user_times = () => {
|
||||
hunt_method_persister.persist_method_user_times(this.methods.val, this.server);
|
||||
};
|
||||
}
|
||||
|
||||
export const hunt_method_stores = new ServerMap(server => new HuntMethodStore(server));
|
@ -1,72 +1,26 @@
|
||||
import solver from "javascript-lp-solver";
|
||||
import { autorun, computed, IObservableArray, observable } from "mobx";
|
||||
import { ItemType } from "../../core/model/items";
|
||||
import {
|
||||
Difficulties,
|
||||
Difficulty,
|
||||
DifficultyModel,
|
||||
DifficultyModels,
|
||||
KONDRIEU_PROB,
|
||||
RARE_ENEMY_PROB,
|
||||
SectionId,
|
||||
SectionIds,
|
||||
Server,
|
||||
} from "../../../core/domain";
|
||||
SectionIdModel,
|
||||
SectionIdModels,
|
||||
ServerModel,
|
||||
} from "../../core/model";
|
||||
import { npc_data, NpcType } from "../../core/data_formats/parsing/quest/npc_types";
|
||||
import { HuntMethodModel } from "../model/HuntMethodModel";
|
||||
import { Property } from "../../core/observable/property/Property";
|
||||
import { WritableProperty } from "../../core/observable/property/WritableProperty";
|
||||
import { OptimalMethodModel, OptimalResultModel, WantedItemModel } from "../model";
|
||||
import { ListProperty } from "../../core/observable/property/list/ListProperty";
|
||||
import { list_property, map, property } from "../../core/observable";
|
||||
import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty";
|
||||
import { hunt_method_stores } from "./HuntMethodStore";
|
||||
import { hunt_optimizer_persister } from "../persistence/HuntOptimizerPersister";
|
||||
import { hunt_method_store } from "./HuntMethodStore";
|
||||
import { item_drop_stores } from "./ItemDropStore";
|
||||
import { item_type_stores } from "../../core/stores/ItemTypeStore";
|
||||
import { Episode } from "../../../core/data_formats/parsing/quest/Episode";
|
||||
import { npc_data, NpcType } from "../../../core/data_formats/parsing/quest/npc_types";
|
||||
import { HuntMethod } from "../domain";
|
||||
import { ItemType } from "../../core/domain/items";
|
||||
|
||||
export class WantedItem {
|
||||
@observable readonly item_type: ItemType;
|
||||
@observable amount: number;
|
||||
|
||||
constructor(item_type: ItemType, amount: number) {
|
||||
this.item_type = item_type;
|
||||
this.amount = amount;
|
||||
}
|
||||
}
|
||||
|
||||
export class OptimalResult {
|
||||
readonly wanted_items: ItemType[];
|
||||
readonly optimal_methods: OptimalMethod[];
|
||||
|
||||
constructor(wanted_items: ItemType[], optimal_methods: OptimalMethod[]) {
|
||||
this.wanted_items = wanted_items;
|
||||
this.optimal_methods = optimal_methods;
|
||||
}
|
||||
}
|
||||
|
||||
export class OptimalMethod {
|
||||
readonly difficulty: Difficulty;
|
||||
readonly section_ids: SectionId[];
|
||||
readonly method_name: string;
|
||||
readonly method_episode: Episode;
|
||||
readonly method_time: number;
|
||||
readonly runs: number;
|
||||
readonly total_time: number;
|
||||
readonly item_counts: Map<ItemType, number>;
|
||||
|
||||
constructor(
|
||||
difficulty: Difficulty,
|
||||
section_ids: SectionId[],
|
||||
method_name: string,
|
||||
method_episode: Episode,
|
||||
method_time: number,
|
||||
runs: number,
|
||||
item_counts: Map<ItemType, number>,
|
||||
) {
|
||||
this.difficulty = difficulty;
|
||||
this.section_ids = section_ids;
|
||||
this.method_name = method_name;
|
||||
this.method_episode = method_episode;
|
||||
this.method_time = method_time;
|
||||
this.runs = runs;
|
||||
this.total_time = runs * method_time;
|
||||
this.item_counts = item_counts;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: take into account mothmants spawned from mothverts.
|
||||
// TODO: take into account split slimes.
|
||||
@ -76,40 +30,53 @@ export class OptimalMethod {
|
||||
// Can be useful when deciding which item to hunt first.
|
||||
// TODO: boxes.
|
||||
class HuntOptimizerStore {
|
||||
@computed get huntable_item_types(): ItemType[] {
|
||||
const item_drop_store = item_drop_stores.current.value;
|
||||
return item_type_stores.current.value.item_types.filter(
|
||||
i => item_drop_store.enemy_drops.get_drops_for_item_type(i.id).length,
|
||||
);
|
||||
}
|
||||
|
||||
readonly huntable_item_types: Property<ItemType[]>;
|
||||
// TODO: wanted items per server.
|
||||
@observable readonly wanted_items: IObservableArray<WantedItem> = observable.array();
|
||||
@observable result?: OptimalResult;
|
||||
readonly wanted_items: ListProperty<WantedItemModel>;
|
||||
readonly result: Property<OptimalResultModel | undefined>;
|
||||
|
||||
private readonly _wanted_items: WritableListProperty<WantedItemModel> = list_property();
|
||||
private readonly _result: WritableProperty<OptimalResultModel | undefined> = property(
|
||||
undefined,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.huntable_item_types = map(
|
||||
(item_type_store, item_drop_store) => {
|
||||
return item_type_store.item_types.filter(
|
||||
item_type =>
|
||||
item_drop_store.enemy_drops.get_drops_for_item_type(item_type.id).length,
|
||||
);
|
||||
},
|
||||
item_type_stores.current.flat_map(current => current),
|
||||
item_drop_stores.current.flat_map(current => current),
|
||||
);
|
||||
|
||||
this.wanted_items = this._wanted_items;
|
||||
this.result = this._result;
|
||||
|
||||
this.initialize_persistence();
|
||||
}
|
||||
|
||||
optimize = async () => {
|
||||
if (!this.wanted_items.length) {
|
||||
this.result = undefined;
|
||||
this._result.val = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize this set before awaiting data, so user changes don't affect this optimization
|
||||
// run from this point on.
|
||||
const wanted_items = new Set(
|
||||
this.wanted_items.filter(w => w.amount > 0).map(w => w.item_type),
|
||||
this.wanted_items.val.filter(w => w.amount > 0).map(w => w.item_type),
|
||||
);
|
||||
|
||||
const methods = await hunt_method_store.methods.current.promise;
|
||||
const drop_table = (await item_drop_stores.current.promise).enemy_drops;
|
||||
const methods = await hunt_method_stores.current.val.methods.promise;
|
||||
const drop_table = (await item_drop_stores.current.val.promise).enemy_drops;
|
||||
|
||||
// Add a constraint per wanted item.
|
||||
const constraints: { [item_name: string]: { min: number } } = {};
|
||||
|
||||
for (const wanted of this.wanted_items) {
|
||||
for (const wanted of this.wanted_items.val) {
|
||||
constraints[wanted.item_type.name] = { min: wanted.amount };
|
||||
}
|
||||
|
||||
@ -125,9 +92,9 @@ class HuntOptimizerStore {
|
||||
const variables: { [method_name: string]: Variable } = {};
|
||||
|
||||
type VariableDetails = {
|
||||
method: HuntMethod;
|
||||
difficulty: Difficulty;
|
||||
section_id: SectionId;
|
||||
method: HuntMethodModel;
|
||||
difficulty: DifficultyModel;
|
||||
section_id: SectionIdModel;
|
||||
split_pan_arms: boolean;
|
||||
};
|
||||
const variable_details: Map<string, VariableDetails> = new Map();
|
||||
@ -192,12 +159,12 @@ class HuntOptimizerStore {
|
||||
const counts = counts_list[i];
|
||||
const split_pan_arms = i === 1;
|
||||
|
||||
for (const difficulty of Difficulties) {
|
||||
for (const section_id of SectionIds) {
|
||||
for (const difficulty of DifficultyModels) {
|
||||
for (const section_id of SectionIdModels) {
|
||||
// Will contain an entry per wanted item dropped by enemies in this method/
|
||||
// difficulty/section ID combo.
|
||||
const variable: Variable = {
|
||||
time: method.time,
|
||||
time: method.time.val,
|
||||
};
|
||||
// Only add the variable if the method provides at least 1 item we want.
|
||||
let add_variable = false;
|
||||
@ -248,11 +215,11 @@ class HuntOptimizerStore {
|
||||
});
|
||||
|
||||
if (!result.feasible) {
|
||||
this.result = undefined;
|
||||
this._result.val = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const optimal_methods: OptimalMethod[] = [];
|
||||
const optimal_methods: OptimalMethodModel[] = [];
|
||||
|
||||
// Loop over the entries in result, ignore standard properties that aren't variables.
|
||||
for (const [variable_name, runs_or_other] of Object.entries(result)) {
|
||||
@ -277,9 +244,9 @@ class HuntOptimizerStore {
|
||||
// Find all section IDs that provide the same items with the same expected amount.
|
||||
// E.g. if you need a spread needle and a bringer's right arm, using either
|
||||
// purplenum or yellowboze will give you the exact same probabilities.
|
||||
const section_ids: SectionId[] = [];
|
||||
const section_ids: SectionIdModel[] = [];
|
||||
|
||||
for (const sid of SectionIds) {
|
||||
for (const sid of SectionIdModels) {
|
||||
let match_found = true;
|
||||
|
||||
if (sid !== section_id) {
|
||||
@ -306,12 +273,12 @@ class HuntOptimizerStore {
|
||||
}
|
||||
|
||||
optimal_methods.push(
|
||||
new OptimalMethod(
|
||||
new OptimalMethodModel(
|
||||
difficulty,
|
||||
section_ids,
|
||||
method.name + (split_pan_arms ? " (Split Pan Arms)" : ""),
|
||||
method.episode,
|
||||
method.time,
|
||||
method.time.val,
|
||||
runs,
|
||||
items,
|
||||
),
|
||||
@ -319,13 +286,13 @@ class HuntOptimizerStore {
|
||||
}
|
||||
}
|
||||
|
||||
this.result = new OptimalResult([...wanted_items], optimal_methods);
|
||||
this._result.val = new OptimalResultModel([...wanted_items], optimal_methods);
|
||||
};
|
||||
|
||||
private full_method_name(
|
||||
difficulty: Difficulty,
|
||||
section_id: SectionId,
|
||||
method: HuntMethod,
|
||||
difficulty: DifficultyModel,
|
||||
section_id: SectionIdModel,
|
||||
method: HuntMethodModel,
|
||||
split_pan_arms: boolean,
|
||||
): string {
|
||||
let name = `${difficulty}\t${section_id}\t${method.id}`;
|
||||
@ -334,11 +301,14 @@ class HuntOptimizerStore {
|
||||
}
|
||||
|
||||
private initialize_persistence = async () => {
|
||||
this.wanted_items.replace(await hunt_optimizer_persister.load_wanted_items(Server.Ephinea));
|
||||
|
||||
autorun(() => {
|
||||
hunt_optimizer_persister.persist_wanted_items(Server.Ephinea, this.wanted_items);
|
||||
});
|
||||
// TODO:
|
||||
// this.wanted_items.replace(
|
||||
// await hunt_optimizer_persister.load_wanted_items(ServerModel.Ephinea),
|
||||
// );
|
||||
//
|
||||
// autorun(() => {
|
||||
// hunt_optimizer_persister.persist_wanted_items(ServerModel.Ephinea, this.wanted_items.val);
|
||||
// });
|
||||
};
|
||||
}
|
||||
|
133
src/hunt_optimizer/stores/ItemDropStore.ts
Normal file
133
src/hunt_optimizer/stores/ItemDropStore.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import {
|
||||
DifficultyModel,
|
||||
DifficultyModels,
|
||||
SectionIdModel,
|
||||
SectionIdModels,
|
||||
ServerModel,
|
||||
} from "../../core/model";
|
||||
import { item_type_stores } from "../../core/stores/ItemTypeStore";
|
||||
import { ServerMap } from "../../core/stores/ServerMap";
|
||||
import Logger from "js-logger";
|
||||
import { NpcType } from "../../core/data_formats/parsing/quest/npc_types";
|
||||
import { EnemyDrop } from "../model/ItemDrop";
|
||||
import { LoadableProperty } from "../../core/observable/property/LoadableProperty";
|
||||
import { EnemyDropDto } from "../dto/drops";
|
||||
|
||||
const logger = Logger.get("stores/ItemDropStore");
|
||||
|
||||
export class EnemyDropTable {
|
||||
// Mapping of difficulties to section IDs to NpcTypes to EnemyDrops.
|
||||
private table: EnemyDrop[][][] = [];
|
||||
|
||||
// Mapping of ItemType ids to EnemyDrops.
|
||||
private item_type_to_drops: EnemyDrop[][] = [];
|
||||
|
||||
constructor() {
|
||||
for (let i = 0; i < DifficultyModels.length; i++) {
|
||||
const diff_array: EnemyDrop[][] = [];
|
||||
this.table.push(diff_array);
|
||||
|
||||
for (let j = 0; j < SectionIdModels.length; j++) {
|
||||
diff_array.push([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get_drop(
|
||||
difficulty: DifficultyModel,
|
||||
section_id: SectionIdModel,
|
||||
npc_type: NpcType,
|
||||
): EnemyDrop | undefined {
|
||||
return this.table[difficulty][section_id][npc_type];
|
||||
}
|
||||
|
||||
set_drop(
|
||||
difficulty: DifficultyModel,
|
||||
section_id: SectionIdModel,
|
||||
npc_type: NpcType,
|
||||
drop: EnemyDrop,
|
||||
): void {
|
||||
this.table[difficulty][section_id][npc_type] = drop;
|
||||
|
||||
let drops = this.item_type_to_drops[drop.item_type.id];
|
||||
|
||||
if (!drops) {
|
||||
drops = [];
|
||||
this.item_type_to_drops[drop.item_type.id] = drops;
|
||||
}
|
||||
|
||||
drops.push(drop);
|
||||
}
|
||||
|
||||
get_drops_for_item_type(item_type_id: number): EnemyDrop[] {
|
||||
return this.item_type_to_drops[item_type_id] || [];
|
||||
}
|
||||
}
|
||||
|
||||
export class ItemDropStore {
|
||||
readonly enemy_drops: EnemyDropTable = new EnemyDropTable();
|
||||
|
||||
private readonly server: ServerModel;
|
||||
|
||||
constructor(server: ServerModel) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
load = async (): Promise<void> => {
|
||||
const item_type_store = await item_type_stores.get(this.server).promise;
|
||||
const response = await fetch(
|
||||
`${process.env.PUBLIC_URL}/enemyDrops.${ServerModel[this.server].toLowerCase()}.json`,
|
||||
);
|
||||
const data: EnemyDropDto[] = await response.json();
|
||||
|
||||
for (const drop_dto of data) {
|
||||
const npc_type = (NpcType as any)[drop_dto.enemy];
|
||||
|
||||
if (!npc_type) {
|
||||
logger.warn(
|
||||
`Couldn't determine NpcType of episode ${drop_dto.episode} ${drop_dto.enemy}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const difficulty = (DifficultyModel as any)[drop_dto.difficulty];
|
||||
const item_type = item_type_store.get_by_id(drop_dto.itemTypeId);
|
||||
|
||||
if (!item_type) {
|
||||
logger.warn(`Couldn't find item kind ${drop_dto.itemTypeId}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const section_id = (SectionIdModel as any)[drop_dto.sectionId];
|
||||
|
||||
if (section_id == null) {
|
||||
logger.warn(`Couldn't find section ID ${drop_dto.sectionId}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.enemy_drops.set_drop(
|
||||
difficulty,
|
||||
section_id,
|
||||
npc_type,
|
||||
new EnemyDrop(
|
||||
difficulty,
|
||||
section_id,
|
||||
npc_type,
|
||||
item_type,
|
||||
drop_dto.dropRate,
|
||||
drop_dto.rareRate,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const item_drop_stores: ServerMap<LoadableProperty<ItemDropStore>> = new ServerMap(
|
||||
server => {
|
||||
const store = new ItemDropStore(server);
|
||||
return new LoadableProperty(store, async () => {
|
||||
await store.load();
|
||||
return store;
|
||||
});
|
||||
},
|
||||
);
|
@ -1,137 +0,0 @@
|
||||
import { observable, computed } from "mobx";
|
||||
import { defer } from "lodash";
|
||||
|
||||
export enum LoadableState {
|
||||
/**
|
||||
* No attempt has been made to load data.
|
||||
*/
|
||||
Uninitialized,
|
||||
|
||||
/**
|
||||
* The first data load is underway.
|
||||
*/
|
||||
Initializing,
|
||||
|
||||
/**
|
||||
* Data was loaded at least once. The most recent load was successful.
|
||||
*/
|
||||
Nominal,
|
||||
|
||||
/**
|
||||
* Data was loaded at least once. The most recent load failed.
|
||||
*/
|
||||
Error,
|
||||
|
||||
/**
|
||||
* Data was loaded at least once. Another data load is underway.
|
||||
*/
|
||||
Reloading,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a value that can be loaded asynchronously.
|
||||
* [state]{@link Loadable#state} represents the current state of this Loadable's value.
|
||||
*/
|
||||
export class Loadable<T> {
|
||||
@observable private _value: T;
|
||||
@observable private _promise: Promise<T> = new Promise(resolve => resolve(this._value));
|
||||
@observable private _state = LoadableState.Uninitialized;
|
||||
private _load?: () => Promise<T>;
|
||||
@observable private _error?: Error;
|
||||
|
||||
constructor(initial_value: T, load?: () => Promise<T>) {
|
||||
this._value = initial_value;
|
||||
this._load = load;
|
||||
}
|
||||
|
||||
/**
|
||||
* When this Loadable is uninitialized, a load will be triggered.
|
||||
* Will return the initial value until a load has succeeded.
|
||||
*/
|
||||
@computed get value(): T {
|
||||
// Load value on first use and return initial placeholder value.
|
||||
if (this._state === LoadableState.Uninitialized) {
|
||||
// Defer loading value to avoid side effects in computed value.
|
||||
defer(() => this.load_value());
|
||||
}
|
||||
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(value: T) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* This property returns valid data as soon as possible.
|
||||
* If the Loadable is uninitialized a data load will be triggered, otherwise the current value will be returned.
|
||||
*/
|
||||
get promise(): Promise<T> {
|
||||
// Load value on first use.
|
||||
if (this._state === LoadableState.Uninitialized) {
|
||||
return this.load_value();
|
||||
} else {
|
||||
return this._promise;
|
||||
}
|
||||
}
|
||||
|
||||
@computed get state(): LoadableState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the initial data load has happened. It may or may not have succeeded.
|
||||
* Check [error]{@link Loadable#error} to know whether an error occurred.
|
||||
*/
|
||||
@computed get is_initialized(): boolean {
|
||||
return this._state !== LoadableState.Uninitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if a data load is underway. This may be the initializing load or a later load.
|
||||
*/
|
||||
@computed get is_loading(): boolean {
|
||||
switch (this._state) {
|
||||
case LoadableState.Initializing:
|
||||
case LoadableState.Reloading:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns an {@link Error} if an error occurred during the most recent data load.
|
||||
*/
|
||||
@computed get error(): Error | undefined {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the data. Initializes the Loadable if it is uninitialized.
|
||||
*/
|
||||
load(): Promise<T> {
|
||||
return this.load_value();
|
||||
}
|
||||
|
||||
private async load_value(): Promise<T> {
|
||||
if (this.is_loading) return this._promise;
|
||||
|
||||
this._state = LoadableState.Initializing;
|
||||
|
||||
try {
|
||||
if (this._load) {
|
||||
this._promise = this._load();
|
||||
this._value = await this._promise;
|
||||
}
|
||||
|
||||
this._state = LoadableState.Nominal;
|
||||
this._error = undefined;
|
||||
return this._value;
|
||||
} catch (e) {
|
||||
this._state = LoadableState.Error;
|
||||
this._error = e;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import { computed } from "mobx";
|
||||
import { Server } from "../../../core/domain";
|
||||
import { EnumMap } from "../../../core/enums";
|
||||
|
||||
/**
|
||||
* Map with a guaranteed value per server.
|
||||
*/
|
||||
export class ServerMap<V> extends EnumMap<Server, V> {
|
||||
constructor(initial_value: (server: Server) => V) {
|
||||
super(Server, initial_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the value for the current server as set in {@link application_store}.
|
||||
*/
|
||||
@computed get current(): V {
|
||||
return this.get(Server.Ephinea);
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
.main {
|
||||
color: var(--text-color-disabled);
|
||||
padding: 5px 0;
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import styles from "./DisabledTextComponent.css";
|
||||
|
||||
export class DisabledTextComponent extends Component<{ children: string }> {
|
||||
render(): ReactNode {
|
||||
return <div className={styles.main}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main > * {
|
||||
margin-top: 10%;
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import { Alert } from "antd";
|
||||
import React, { ReactNode, Component, ComponentType } from "react";
|
||||
import styles from "./ErrorBoundary.css";
|
||||
|
||||
type State = { has_error: boolean };
|
||||
|
||||
export class ErrorBoundary extends Component<{}, State> {
|
||||
state = {
|
||||
has_error: false,
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.has_error) {
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<div>
|
||||
<Alert type="error" message="Something went wrong." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { has_error: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function with_error_boundary<P>(Component: ComponentType<P>): ComponentType<P> {
|
||||
const ComponentErrorBoundary = (props: P): JSX.Element => (
|
||||
<ErrorBoundary>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
ComponentErrorBoundary.displayName = `${Component.displayName}ErrorBoundary`;
|
||||
return ComponentErrorBoundary;
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Component, ReactNode, FocusEvent } from "react";
|
||||
import { InputNumber } from "antd";
|
||||
|
||||
export class NumberInput extends Component<{
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
on_change?: (new_value?: number) => void;
|
||||
on_blur?: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
}> {
|
||||
render(): ReactNode {
|
||||
return (
|
||||
<InputNumber
|
||||
value={this.props.value}
|
||||
min={this.props.min}
|
||||
max={this.props.max}
|
||||
onChange={this.props.on_change}
|
||||
onBlur={this.props.on_blur}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { Renderer } from "../../../core/rendering/Renderer";
|
||||
|
||||
type Props = {
|
||||
renderer: Renderer;
|
||||
width: number;
|
||||
height: number;
|
||||
debug?: boolean;
|
||||
on_will_unmount?: () => void;
|
||||
};
|
||||
|
||||
export class RendererComponent extends Component<Props> {
|
||||
render(): ReactNode {
|
||||
return <div ref={this.modify_dom} />;
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(props: Props): void {
|
||||
if (this.props.debug !== props.debug) {
|
||||
this.props.renderer.debug = !!props.debug;
|
||||
}
|
||||
|
||||
if (this.props.width !== props.width || this.props.height !== props.height) {
|
||||
this.props.renderer.set_size(props.width, props.height);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.props.renderer.start_rendering();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.props.renderer.stop_rendering();
|
||||
this.props.on_will_unmount && this.props.on_will_unmount();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private modify_dom = (div: HTMLDivElement | null) => {
|
||||
if (div) {
|
||||
this.props.renderer.set_size(this.props.width, this.props.height);
|
||||
div.appendChild(this.props.renderer.dom_element);
|
||||
}
|
||||
};
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import { SectionId } from "../../../core/domain";
|
||||
import { SectionIdModel } from "../../../core/model";
|
||||
|
||||
export function SectionIdIcon({
|
||||
section_id,
|
||||
size = 28,
|
||||
title,
|
||||
}: {
|
||||
section_id: SectionId;
|
||||
section_id: SectionIdModel;
|
||||
size?: number;
|
||||
title?: string;
|
||||
}): JSX.Element {
|
||||
@ -17,7 +17,7 @@ export function SectionIdIcon({
|
||||
display: "inline-block",
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundImage: `url(${process.env.PUBLIC_URL}/images/sectionids/${SectionId[section_id]}.png)`,
|
||||
backgroundImage: `url(${process.env.PUBLIC_URL}/images/sectionids/${SectionIdModel[section_id]}.png)`,
|
||||
backgroundSize: size,
|
||||
}}
|
||||
/>
|
||||
|
@ -1,23 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { ChangeEvent, Component, FocusEvent, ReactNode } from "react";
|
||||
import { Input } from "antd";
|
||||
|
||||
export class TextArea extends Component<{
|
||||
value: string;
|
||||
max_length: number;
|
||||
rows: number;
|
||||
on_change?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
on_blur?: (e: FocusEvent<HTMLTextAreaElement>) => void;
|
||||
}> {
|
||||
render(): ReactNode {
|
||||
return (
|
||||
<Input.TextArea
|
||||
value={this.props.value}
|
||||
maxLength={this.props.max_length}
|
||||
rows={this.props.rows}
|
||||
onChange={this.props.on_change}
|
||||
onBlur={this.props.on_blur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { ChangeEvent, Component, FocusEvent, ReactNode } from "react";
|
||||
import { Input } from "antd";
|
||||
|
||||
export class TextInput extends Component<{
|
||||
value: string;
|
||||
max_length: number;
|
||||
on_change: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
on_blur?: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
}> {
|
||||
render(): ReactNode {
|
||||
return (
|
||||
<Input
|
||||
value={this.props.value}
|
||||
maxLength={this.props.max_length}
|
||||
onChange={this.props.on_change}
|
||||
onBlur={this.props.on_blur}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
:root {
|
||||
--background-color: hsl(0, 0%, 20%);
|
||||
--foreground-color: hsl(0, 0%, 23%);
|
||||
|
||||
--hover-color: hsl(198, 61%, 87%);
|
||||
|
||||
--item-hover-bg: hsl(200, 30%, 30%);
|
||||
|
||||
--border-color: hsl(0, 0%, 25%);
|
||||
--border-color-split: hsl(0, 0%, 30%);
|
||||
--input-border-color: hsl(0, 0%, 40%);
|
||||
|
||||
--text-color: hsl(0, 0%, 90%);
|
||||
--text-color-disabled: hsl(0, 0%, 50%);
|
||||
|
||||
--scrollbar-color: hsl(0, 0%, 17%);
|
||||
--scrollbar-thumb-color: hsl(0, 0%, 23%);
|
||||
|
||||
--table-scrollbar-color: hsl(0, 0%, 18%);
|
||||
--table-scrollbar-thumb-color: hsl(0, 0%, 22%);
|
||||
--table-border-color: var(--input-border-color);
|
||||
|
||||
--dock-border-color: hsl(0, 0%, 17%);
|
||||
--dock-tab-color: hsl(0, 0%, 14%);
|
||||
--dock-tab-active-color: var(--background-color);
|
||||
}
|
||||
|
||||
/* React Root Element */
|
||||
|
||||
#phantasmal_world_root {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-color);
|
||||
|
||||
/* Turn off antd animations by turning all animations off. */
|
||||
animation-duration: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
background-color: var(--scrollbar-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--scrollbar-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: var(--scrollbar-color);
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden; /* Necessary for golden layout. */
|
||||
}
|
||||
|
||||
/* react-virtualized */
|
||||
|
||||
#phantasmal_world_root :global(.ReactVirtualized__Grid) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#phantasmal_world_root :global(.ReactVirtualized__Table__headerRow) {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
#phantasmal_world_root :global(.ant-tabs-bar) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* golden-layout */
|
||||
|
||||
#phantasmal_world_root :global(.lm_header) {
|
||||
background: var(--dock-border-color);
|
||||
}
|
||||
|
||||
#phantasmal_world_root :global(.lm_goldenlayout) {
|
||||
background: var(--dock-border-color);
|
||||
}
|
||||
|
||||
#phantasmal_world_root :global(.lm_content) {
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
#phantasmal_world_root :global(.lm_tab) {
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
font-size: 12px;
|
||||
padding: 0 16px;
|
||||
margin: 2px 0 0 0;
|
||||
background: var(--dock-tab-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#phantasmal_world_root :global(.lm_tab.lm_active) {
|
||||
background: var(--dock-tab-active-color);
|
||||
}
|
||||
|
||||
#phantasmal_world_root :global(.lm_controls) {
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { observable, IObservableArray, computed } from "mobx";
|
||||
import { WeaponItem, WeaponItemType, ArmorItemType, ShieldItemType } from "../../core/domain/items";
|
||||
import { item_type_stores } from "../../core/stores/ItemTypeStore";
|
||||
import { WeaponItem, WeaponItemType, ArmorItemType, ShieldItemType } from "../../../core/model/items";
|
||||
import { item_type_stores } from "../../../core/stores/ItemTypeStore";
|
||||
|
||||
const NORMAL_DAMAGE_FACTOR = 0.2 * 0.9;
|
||||
const HEAVY_DAMAGE_FACTOR = NORMAL_DAMAGE_FACTOR * 1.89;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { InputNumber } from "antd";
|
||||
import { observer } from "mobx-react";
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { ArmorItemType, ShieldItemType, WeaponItemType } from "../../core/domain/items";
|
||||
import { ArmorItemType, ShieldItemType, WeaponItemType } from "../../../core/model/items";
|
||||
import { dps_calc_store } from "../stores/DpsCalcStore";
|
||||
import { item_type_stores } from "../../core/stores/ItemTypeStore";
|
||||
import { item_type_stores } from "../../../core/stores/ItemTypeStore";
|
||||
import { BigSelect } from "../../core/ui/BigSelect";
|
||||
|
||||
@observer
|
||||
|
@ -1,73 +0,0 @@
|
||||
import { Episode } from "../../../core/data_formats/parsing/quest/Episode";
|
||||
import { NpcType } from "../../../core/data_formats/parsing/quest/npc_types";
|
||||
import { computed, observable } from "mobx";
|
||||
import { ItemType } from "../../core/domain/items";
|
||||
import { Difficulty, SectionId } from "../../../core/domain";
|
||||
|
||||
export class HuntMethod {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly episode: Episode;
|
||||
readonly quest: SimpleQuest;
|
||||
readonly enemy_counts: Map<NpcType, number>;
|
||||
/**
|
||||
* The time it takes to complete the quest in hours.
|
||||
*/
|
||||
readonly default_time: number;
|
||||
/**
|
||||
* The time it takes to complete the quest in hours as specified by the user.
|
||||
*/
|
||||
@observable user_time?: number;
|
||||
|
||||
@computed get time(): number {
|
||||
return this.user_time != null ? this.user_time : this.default_time;
|
||||
}
|
||||
|
||||
constructor(id: string, name: string, quest: SimpleQuest, default_time: number) {
|
||||
if (!id) throw new Error("id is required.");
|
||||
if (default_time <= 0) throw new Error("default_time must be greater than zero.");
|
||||
if (!name) throw new Error("name is required.");
|
||||
if (!quest) throw new Error("quest is required.");
|
||||
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.episode = quest.episode;
|
||||
this.quest = quest;
|
||||
this.enemy_counts = quest.enemy_counts;
|
||||
this.default_time = default_time;
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleQuest {
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly name: string,
|
||||
readonly episode: Episode,
|
||||
readonly enemy_counts: Map<NpcType, number>,
|
||||
) {
|
||||
if (!id) throw new Error("id is required.");
|
||||
if (!name) throw new Error("name is required.");
|
||||
if (!enemy_counts) throw new Error("enemyCounts is required.");
|
||||
}
|
||||
}
|
||||
|
||||
type ItemDrop = {
|
||||
item_type: ItemType;
|
||||
anything_rate: number;
|
||||
rare_rate: number;
|
||||
};
|
||||
|
||||
export class EnemyDrop implements ItemDrop {
|
||||
readonly rate: number;
|
||||
|
||||
constructor(
|
||||
readonly difficulty: Difficulty,
|
||||
readonly section_id: SectionId,
|
||||
readonly npc_type: NpcType,
|
||||
readonly item_type: ItemType,
|
||||
readonly anything_rate: number,
|
||||
readonly rare_rate: number,
|
||||
) {
|
||||
this.rate = anything_rate * rare_rate;
|
||||
}
|
||||
}
|
@ -1,88 +0,0 @@
|
||||
import Logger from "js-logger";
|
||||
import { autorun, IReactionDisposer, observable } from "mobx";
|
||||
import { Server } from "../../../core/domain";
|
||||
import { QuestDto } from "../../core/dto";
|
||||
import { Loadable } from "../../core/Loadable";
|
||||
import { hunt_method_persister } from "../persistence/HuntMethodPersister";
|
||||
import { ServerMap } from "../../core/stores/ServerMap";
|
||||
import { NpcType } from "../../../core/data_formats/parsing/quest/npc_types";
|
||||
import { HuntMethod, SimpleQuest } from "../domain";
|
||||
|
||||
const logger = Logger.get("stores/HuntMethodStore");
|
||||
|
||||
class HuntMethodStore {
|
||||
@observable methods: ServerMap<Loadable<HuntMethod[]>> = new ServerMap(
|
||||
server => new Loadable([], () => this.load_hunt_methods(server)),
|
||||
);
|
||||
|
||||
private storage_disposer?: IReactionDisposer;
|
||||
|
||||
private async load_hunt_methods(server: Server): Promise<HuntMethod[]> {
|
||||
const response = await fetch(
|
||||
`${process.env.PUBLIC_URL}/quests.${Server[server].toLowerCase()}.json`,
|
||||
);
|
||||
const quests = (await response.json()) as QuestDto[];
|
||||
const methods = new Array<HuntMethod>();
|
||||
|
||||
for (const quest of quests) {
|
||||
let total_count = 0;
|
||||
const enemy_counts = new Map<NpcType, number>();
|
||||
|
||||
for (const [code, count] of Object.entries(quest.enemyCounts)) {
|
||||
const npc_type = (NpcType as any)[code];
|
||||
|
||||
if (!npc_type) {
|
||||
logger.error(`No NpcType found for code ${code}.`);
|
||||
} else {
|
||||
enemy_counts.set(npc_type, count);
|
||||
total_count += count;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out some quests.
|
||||
/* eslint-disable no-fallthrough */
|
||||
switch (quest.id) {
|
||||
// The following quests are left out because their enemies don't drop anything.
|
||||
case 31: // Black Paper's Dangerous Deal
|
||||
case 34: // Black Paper's Dangerous Deal 2
|
||||
case 1305: // Maximum Attack S (Ep. 1)
|
||||
case 1306: // Maximum Attack S (Ep. 2)
|
||||
case 1307: // Maximum Attack S (Ep. 4)
|
||||
case 313: // Beyond the Horizon
|
||||
|
||||
// MAXIMUM ATTACK 3 Ver2 is filtered out because its actual enemy count depends on the path taken.
|
||||
// TODO: generate a method per path.
|
||||
case 314:
|
||||
continue;
|
||||
}
|
||||
|
||||
methods.push(
|
||||
new HuntMethod(
|
||||
`q${quest.id}`,
|
||||
quest.name,
|
||||
new SimpleQuest(quest.id, quest.name, quest.episode, enemy_counts),
|
||||
/^\d-\d.*/.test(quest.name) ? 0.75 : total_count > 400 ? 0.75 : 0.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await this.load_user_times(methods, server);
|
||||
return methods;
|
||||
}
|
||||
|
||||
private load_user_times = async (methods: HuntMethod[], server: Server) => {
|
||||
await hunt_method_persister.load_method_user_times(methods, server);
|
||||
|
||||
if (this.storage_disposer) {
|
||||
this.storage_disposer();
|
||||
}
|
||||
|
||||
this.storage_disposer = autorun(() => this.persist_user_times(methods, server));
|
||||
};
|
||||
|
||||
private persist_user_times = (methods: HuntMethod[], server: Server) => {
|
||||
hunt_method_persister.persist_method_user_times(methods, server);
|
||||
};
|
||||
}
|
||||
|
||||
export const hunt_method_store = new HuntMethodStore();
|
@ -1,122 +0,0 @@
|
||||
import { observable } from "mobx";
|
||||
import { Difficulties, Difficulty, SectionId, SectionIds, Server } from "../../../core/domain";
|
||||
import { EnemyDropDto } from "../../core/dto";
|
||||
import { Loadable } from "../../core/Loadable";
|
||||
import { item_type_stores } from "../../core/stores/ItemTypeStore";
|
||||
import { ServerMap } from "../../core/stores/ServerMap";
|
||||
import Logger from "js-logger";
|
||||
import { NpcType } from "../../../core/data_formats/parsing/quest/npc_types";
|
||||
import { EnemyDrop } from "../domain";
|
||||
|
||||
const logger = Logger.get("stores/ItemDropStore");
|
||||
|
||||
export class EnemyDropTable {
|
||||
// Mapping of difficulties to section IDs to NpcTypes to EnemyDrops.
|
||||
private table: EnemyDrop[][][] = [];
|
||||
|
||||
// Mapping of ItemType ids to EnemyDrops.
|
||||
private item_type_to_drops: EnemyDrop[][] = [];
|
||||
|
||||
constructor() {
|
||||
for (let i = 0; i < Difficulties.length; i++) {
|
||||
const diff_array: EnemyDrop[][] = [];
|
||||
this.table.push(diff_array);
|
||||
|
||||
for (let j = 0; j < SectionIds.length; j++) {
|
||||
diff_array.push([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get_drop(
|
||||
difficulty: Difficulty,
|
||||
section_id: SectionId,
|
||||
npc_type: NpcType,
|
||||
): EnemyDrop | undefined {
|
||||
return this.table[difficulty][section_id][npc_type];
|
||||
}
|
||||
|
||||
set_drop(
|
||||
difficulty: Difficulty,
|
||||
section_id: SectionId,
|
||||
npc_type: NpcType,
|
||||
drop: EnemyDrop,
|
||||
): void {
|
||||
this.table[difficulty][section_id][npc_type] = drop;
|
||||
|
||||
let drops = this.item_type_to_drops[drop.item_type.id];
|
||||
|
||||
if (!drops) {
|
||||
drops = [];
|
||||
this.item_type_to_drops[drop.item_type.id] = drops;
|
||||
}
|
||||
|
||||
drops.push(drop);
|
||||
}
|
||||
|
||||
get_drops_for_item_type(item_type_id: number): EnemyDrop[] {
|
||||
return this.item_type_to_drops[item_type_id] || [];
|
||||
}
|
||||
}
|
||||
|
||||
export class ItemDropStore {
|
||||
@observable.ref enemy_drops: EnemyDropTable = new EnemyDropTable();
|
||||
}
|
||||
|
||||
export const item_drop_stores: ServerMap<Loadable<ItemDropStore>> = new ServerMap(server => {
|
||||
const store = new ItemDropStore();
|
||||
return new Loadable(store, () => load(store, server));
|
||||
});
|
||||
|
||||
async function load(store: ItemDropStore, server: Server): Promise<ItemDropStore> {
|
||||
const item_type_store = await item_type_stores.current.promise;
|
||||
const response = await fetch(
|
||||
`${process.env.PUBLIC_URL}/enemyDrops.${Server[server].toLowerCase()}.json`,
|
||||
);
|
||||
const data: EnemyDropDto[] = await response.json();
|
||||
|
||||
const drops = new EnemyDropTable();
|
||||
|
||||
for (const drop_dto of data) {
|
||||
const npc_type = (NpcType as any)[drop_dto.enemy];
|
||||
|
||||
if (!npc_type) {
|
||||
logger.warn(
|
||||
`Couldn't determine NpcType of episode ${drop_dto.episode} ${drop_dto.enemy}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const difficulty = (Difficulty as any)[drop_dto.difficulty];
|
||||
const item_type = item_type_store.get_by_id(drop_dto.itemTypeId);
|
||||
|
||||
if (!item_type) {
|
||||
logger.warn(`Couldn't find item kind ${drop_dto.itemTypeId}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const section_id = (SectionId as any)[drop_dto.sectionId];
|
||||
|
||||
if (section_id == null) {
|
||||
logger.warn(`Couldn't find section ID ${drop_dto.sectionId}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
drops.set_drop(
|
||||
difficulty,
|
||||
section_id,
|
||||
npc_type,
|
||||
new EnemyDrop(
|
||||
difficulty,
|
||||
section_id,
|
||||
npc_type,
|
||||
item_type,
|
||||
drop_dto.dropRate,
|
||||
drop_dto.rareRate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
store.enemy_drops = drops;
|
||||
return store;
|
||||
}
|
@ -2,7 +2,7 @@ import { computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { AutoSizer, Index } from "react-virtualized";
|
||||
import { Difficulty, SectionId } from "../../../core/domain";
|
||||
import { DifficultyModel, SectionIdModel } from "../../../core/model";
|
||||
import { hunt_optimizer_store, OptimalMethod } from "../stores/HuntOptimizerStore";
|
||||
import { BigTable, Column } from "../../core/ui/BigTable";
|
||||
import { SectionIdIcon } from "../../core/ui/SectionIdIcon";
|
||||
@ -28,7 +28,7 @@ export class OptimizationResultComponent extends Component {
|
||||
{
|
||||
name: "Difficulty",
|
||||
width: 75,
|
||||
cell_renderer: result => Difficulty[result.difficulty],
|
||||
cell_renderer: result => DifficultyModel[result.difficulty],
|
||||
footer_value: "Totals:",
|
||||
},
|
||||
{
|
||||
@ -52,7 +52,7 @@ export class OptimizationResultComponent extends Component {
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
tooltip: result => result.section_ids.map(sid => SectionId[sid]).join(", "),
|
||||
tooltip: result => result.section_ids.map(sid => SectionIdModel[sid]).join(", "),
|
||||
},
|
||||
{
|
||||
name: "Time/Run",
|
||||
|
@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { AutoSizer, Column, Table, TableCellRenderer } from "react-virtualized";
|
||||
import { hunt_optimizer_store, WantedItem } from "../stores/HuntOptimizerStore";
|
||||
import { item_type_stores } from "../../core/stores/ItemTypeStore";
|
||||
import { item_type_stores } from "../../../core/stores/ItemTypeStore";
|
||||
import { BigSelect } from "../../core/ui/BigSelect";
|
||||
import styles from "./WantedItemsComponent.css";
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { Button } from "../../core/gui/Button";
|
||||
import { quest_editor_store } from "../stores/QuestEditorStore";
|
||||
import { undo_manager } from "../../core/undo/UndoManager";
|
||||
import { Select } from "../../core/gui/Select";
|
||||
import { array_property } from "../../core/observable";
|
||||
import { list_property } from "../../core/observable";
|
||||
import { AreaModel } from "../model/AreaModel";
|
||||
import { Icon } from "../../core/gui/dom";
|
||||
import { DropDownButton } from "../../core/gui/DropDownButton";
|
||||
@ -50,9 +50,9 @@ export class QuestEditorToolBar extends ToolBar {
|
||||
const area_select = new Select<AreaModel>(
|
||||
quest_editor_store.current_quest.flat_map(quest => {
|
||||
if (quest) {
|
||||
return array_property(...area_store.get_areas_for_episode(quest.episode));
|
||||
return list_property(...area_store.get_areas_for_episode(quest.episode));
|
||||
} else {
|
||||
return array_property<AreaModel>();
|
||||
return list_property<AreaModel>();
|
||||
}
|
||||
}),
|
||||
area => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ListProperty } from "../../core/observable/property/list/ListProperty";
|
||||
import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty";
|
||||
import { array_property } from "../../core/observable";
|
||||
import { list_property } from "../../core/observable";
|
||||
import { AreaModel } from "./AreaModel";
|
||||
import { SectionModel } from "./SectionModel";
|
||||
|
||||
@ -9,7 +9,7 @@ export class AreaVariantModel {
|
||||
|
||||
readonly area: AreaModel;
|
||||
|
||||
private readonly _sections: WritableListProperty<SectionModel> = array_property();
|
||||
private readonly _sections: WritableListProperty<SectionModel> = list_property();
|
||||
readonly sections: ListProperty<SectionModel> = this._sections;
|
||||
|
||||
constructor(id: number, area: AreaModel) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { array_property, map, property } from "../../core/observable";
|
||||
import { list_property, map, property } from "../../core/observable";
|
||||
import { WritableProperty } from "../../core/observable/property/WritableProperty";
|
||||
import { check_episode, Episode } from "../../core/data_formats/parsing/quest/Episode";
|
||||
import { QuestObjectModel } from "./QuestObjectModel";
|
||||
@ -109,7 +109,7 @@ export class QuestModel {
|
||||
private readonly _short_description: WritableProperty<string> = property("");
|
||||
private readonly _long_description: WritableProperty<string> = property("");
|
||||
private readonly _map_designations: WritableProperty<Map<number, number>>;
|
||||
private readonly _area_variants: WritableListProperty<AreaVariantModel> = array_property();
|
||||
private readonly _area_variants: WritableListProperty<AreaVariantModel> = list_property();
|
||||
|
||||
constructor(
|
||||
id: number,
|
||||
@ -148,8 +148,8 @@ export class QuestModel {
|
||||
this.episode = episode;
|
||||
this._map_designations = property(map_designations);
|
||||
this.map_designations = this._map_designations;
|
||||
this.objects = array_property(...objects);
|
||||
this.npcs = array_property(...npcs);
|
||||
this.objects = list_property(...objects);
|
||||
this.npcs = list_property(...npcs);
|
||||
this.dat_unknowns = dat_unknowns;
|
||||
this.object_code = object_code;
|
||||
this.shop_items = shop_items;
|
||||
|
10
yarn.lock
10
yarn.lock
@ -445,6 +445,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.136.tgz#413e85089046b865d960c9ff1d400e04c31ab60f"
|
||||
integrity sha512-0GJhzBdvsW2RUccNHOBkabI8HZVdOXmXbXhuKlDEd5Vv12P7oAVGfomGp3Ne21o5D/qu1WmthlNKFaoZJJeErA==
|
||||
|
||||
"@types/luxon@^1.15.2":
|
||||
version "1.15.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-1.15.2.tgz#528f11f7d6dc08cec0445d4bea8065a5bb6989b2"
|
||||
integrity sha512-zHPoyVrLvNaiMRYdhmh88Rn489ZgAgbc6iLxR5Yi0VCNfeNYHcszbhJV2vDHLNrVGy35BPtWBRn4OP2F9BBvFw==
|
||||
|
||||
"@types/minimatch@*":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
|
||||
@ -5007,6 +5012,11 @@ lru-cache@^5.1.1:
|
||||
dependencies:
|
||||
yallist "^3.0.2"
|
||||
|
||||
luxon@^1.17.2:
|
||||
version "1.17.2"
|
||||
resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.17.2.tgz#95189c450341cfddf5f826ef8c32b5b022943fd5"
|
||||
integrity sha512-qELKtIj3HD41N+MvgoxArk8DZGUb4Gpiijs91oi+ZmKJzRlxY6CoyTwNoUwnogCVs4p8HuxVJDik9JbnYgrCng==
|
||||
|
||||
make-dir@^2.0.0, make-dir@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
|
||||
|
Loading…
Reference in New Issue
Block a user