Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 0 additions & 68 deletions EveProfitManager/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"""
import csv
import hashlib
import json
import math
import os
import re
Expand Down Expand Up @@ -46,14 +45,6 @@
CREATE TABLE IF NOT EXISTS payouts (
journal_id INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS loot (
session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
character_id INTEGER NOT NULL,
baseline TEXT NOT NULL,
millions REAL,
unpriced INTEGER,
as_of TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
Expand Down Expand Up @@ -121,21 +112,6 @@ class Run:
count: int


@dataclass
class Baseline:
"""What a character owned when a session started."""
character_id: int
quantities: dict # type_id -> quantity


@dataclass
class Loot:
"""A session's unsold loot: the market value of how its items changed since it started."""
millions: float
unpriced: int # kinds of item that changed but have no market price
as_of: datetime # when the assets it was valued from were read


@dataclass
class Session:
id: int
Expand Down Expand Up @@ -449,50 +425,6 @@ def current_isk(self, session_id):
session = self.session(session_id)
return session.starting_isk if session else None

# --- loot -----------------------------------------------------------

def set_loot_baseline(self, session_id, character_id, quantities):
"""
Remember what the character owned when the session started, by type_id.

Only the first baseline counts, so reading the assets again after a
restart doesn't wipe out loot already brought back.
"""
with self.db:
self.db.execute(
"INSERT OR IGNORE INTO loot (session_id, character_id, baseline) VALUES (?, ?, ?)",
(session_id, character_id, json.dumps(quantities)),
)

def loot_baseline(self, session_id):
"""The session's Baseline, or None if none was taken."""
row = self.db.execute("SELECT character_id, baseline FROM loot WHERE session_id = ?", (session_id,)).fetchone()
if row is None:
return None
return Baseline(row["character_id"], {int(t): q for t, q in json.loads(row["baseline"]).items()})

def set_loot(self, session_id, value, as_of):
"""
Save the session's latest loot valuation.

Args:
session_id (int): Session with a baseline.
value (loot.LootValue): Worth of the change since the baseline.
as_of (datetime): When the assets it was valued from were read.
"""
with self.db:
self.db.execute(
"UPDATE loot SET millions = ?, unpriced = ?, as_of = ? WHERE session_id = ?",
(value.millions, value.unpriced, as_of.isoformat(timespec="seconds"), session_id),
)

def loot(self, session_id):
"""The session's latest Loot, or None if it hasn't been valued."""
row = self.db.execute(
"SELECT millions, unpriced, as_of FROM loot WHERE session_id = ? AND as_of IS NOT NULL", (session_id,)
).fetchone()
return Loot(row["millions"], row["unpriced"], datetime.fromisoformat(row["as_of"])) if row else None

# --- stats ----------------------------------------------------------

def totals(self, kind=None):
Expand Down
103 changes: 33 additions & 70 deletions EveProfitManager/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
from clock import read_clock
from journal import get_incursion_payouts
from location import AbyssWatcher, get_location
from loot import get_assets, get_prices, value_change
from wallet import get_wallet
from account import SIGN_IN_ERRORS, Account
from theme import NEUTRAL_THEME, asset_path, load_fonts, theme_for, ui_family
Expand Down Expand Up @@ -853,7 +852,6 @@ class AbyssPage(SessionPage):
pocket_label = "Wallet after run (M)"
LOCATION_POLL_MS = 5000 # ESI caches the location for 5 s; the client never asks sooner
LOCATION_RETRY_MS = 30000 # after a failed read
LOOT_POLL_MS = 5 * 60 * 1000 # ESI caches assets for an hour; the client never asks sooner

def __init__(self, master, app):
super().__init__(master, app)
Expand All @@ -864,76 +862,47 @@ def __init__(self, master, app):
lambda location: self.see_abyss(location.in_abyss), "Location",
self.LOCATION_POLL_MS, self.LOCATION_RETRY_MS, on_start=self.reset_watcher,
).switch
self._valuing = False
self._loot_problem = None # an asset error or mismatch, shown instead of the loot's time
self._loot_waiting = "reading assets..." # shown before the session's first reading
self._loot_poll()

def _loot_poll(self):
self.value_loot()
self.after(self.LOOT_POLL_MS, self._loot_poll)

def value_loot(self):
"""
Read the assets off the UI thread: the first reading of a session is its
baseline, and later ones value what changed since. Quiet when signed out.
"""
if self.session_id is None or self._valuing:
self._wallet = None # the latest Wallet read from ESI
self._wallet_note = "reading wallet..." # the stat's sub-line when there is no reading to date
self._wallet_problem = None # a read error, shown instead of the reading's time
self._reading_wallet = False

def read_wallet(self):
"""Read the wallet off the UI thread for the read-only Wallet stat. Quiet when signed out."""
if self.session_id is None or self._reading_wallet:
return
client, _ = self.signed_in_client()
client, why = self.signed_in_client()
self._wallet_problem = None
if client is None:
if self._loot_waiting != "sign in to value loot":
self._loot_waiting = "sign in to value loot"
self.refresh()
self._wallet = None
self._wallet_note = "sign in under Settings to see it"
self.refresh()
return
self._loot_waiting = "reading assets..."
self._reading_wallet = True
session_id = self.session_id
has_baseline = self.tracker.loot_baseline(session_id) is not None
self._valuing = True

def read():
return get_assets(client, client.character.id), get_prices(client) if has_baseline else None

def done(result, error):
self._valuing = False
def done(wallet, error):
self._reading_wallet = False
if session_id != self.session_id: # the session changed while the read was out
self.value_loot()
elif error is not None:
self.loot_failed(error)
self.read_wallet()
return
if error is not None:
self._wallet_problem = f"Wallet: {error}"
else:
self.take_assets(*result)

self.app.run_async(read, done)

def take_assets(self, assets, prices):
"""Use an asset reading: the session's baseline if it has none, else value the change since."""
baseline = self.tracker.loot_baseline(self.session_id)
self._loot_problem = None
if baseline is None:
self.tracker.set_loot_baseline(self.session_id, assets.character_id, assets.quantities)
elif baseline.character_id != assets.character_id:
self._loot_problem = "baseline is for another character"
elif prices is not None:
self.tracker.set_loot(self.session_id, value_change(baseline.quantities, assets.quantities, prices),
datetime.fromtimestamp(assets.fetched_at))
self.refresh()
self._wallet = wallet
self.refresh()

def loot_failed(self, error):
self._loot_problem = f"Assets: {error}"
self.refresh()
self.app.run_async(lambda: get_wallet(client, client.character.id), done)

def start_session(self):
self._loot_problem = None
self._wallet = None
self._wallet_note = "reading wallet..."
super().start_session()
self.value_loot()

def end_note(self):
loot = self.tracker.loot(self.session_id)
return f"\n\nUnsold loot: {signed_isk(loot.millions)} ISK at market prices." if loot else ""
self.read_wallet()

def resume(self):
super().resume()
self.value_loot()
self.read_wallet()

def build_start_form(self, frame):
self.start_isk = self.field(frame, 0, "Wallet now (million ISK)", 220, "e.g. 1,250 or 1.25b")
Expand Down Expand Up @@ -1028,7 +997,7 @@ def pocket_fields(self):
return self.after_isk, self.time_left

def stat_labels(self):
return [("profit", "Session"), ("loot", "Loot (unsold)"), ("rate", "ISK/hr"), ("time", "Time"),
return [("profit", "Session"), ("rate", "ISK/hr"), ("time", "Time"),
("wallet", "Wallet")]

def timer_caption(self, session):
Expand Down Expand Up @@ -1056,7 +1025,7 @@ def log_run(self):
def after_log(self):
self.after_isk.delete(0, "end")
self.time_left.delete(0, "end")
self.value_loot()
self.read_wallet()

def edit_fields(self, run):
return [("Wallet after run (M)", f"{run.isk_after:g}"), ("Time left (min)", f"{MAXTIME - run.minutes:g}")]
Expand All @@ -1073,17 +1042,11 @@ def update_stats(self, session, runs):
self.stats["rate"].set(format_isk(session.isk_per_hour))
self.stats["time"].set(format_minutes(session.minutes),
sub=f"{session.runs} runs" if runs else "")
self.stats["wallet"].set(format_isk(self.tracker.current_isk(session.id) or 0),
sub=f"from {format_isk(session.starting_isk or 0)}")
loot = self.tracker.loot(session.id)
if loot:
unpriced = f" · {loot.unpriced} unpriced" if loot.unpriced else ""
self.stats["loot"].set(signed_isk(loot.millions), profit_color(loot.millions),
self._loot_problem or f"from EVE at {loot.as_of:%H:%M}{unpriced}")
elif self.tracker.loot_baseline(session.id) is not None:
self.stats["loot"].set(signed_isk(0), sub=self._loot_problem or "ESI updates assets hourly")
if self._wallet:
self.stats["wallet"].set(format_isk(self._wallet.millions), sub=self._wallet_problem or
f"from EVE at {datetime.fromtimestamp(self._wallet.fetched_at):%H:%M}")
else:
self.stats["loot"].set("—", sub=self._loot_problem or self._loot_waiting)
self.stats["wallet"].set("—", sub=self._wallet_problem or self._wallet_note)


class IncursionPage(SessionPage):
Expand Down
28 changes: 1 addition & 27 deletions EveProfitManager/loot.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Value the items a session brought back, from the character's assets on ESI and market prices."""
"""Read the character's assets from ESI and the market prices that value them."""
from dataclasses import dataclass


Expand All @@ -9,12 +9,6 @@ class Assets:
fetched_at: float # when ESI last answered; it caches the asset list for an hour


@dataclass(frozen=True)
class LootValue:
millions: float # ISK, at market prices
unpriced: int # kinds of item that changed but have no market price, like abyssal modules


def get_assets(client, character_id):
"""
Everything the character owns, counted by type.
Expand Down Expand Up @@ -55,26 +49,6 @@ def get_prices(client):
return prices


def value_change(before, after, prices):
"""
What the change from the `before` quantities to the `after` ones is worth.

Items gained count for their value and items used up (filaments, ammo, a
lost ship) against it, so buying something only moves ISK out of the wallet
and into the loot.
"""
isk, unpriced = 0.0, 0
for type_id in before.keys() | after.keys():
change = after.get(type_id, 0) - before.get(type_id, 0)
if not change:
continue
if type_id in prices:
isk += change * prices[type_id]
else:
unpriced += 1
return LootValue(millions=isk / 1e6, unpriced=unpriced)


def _items(body):
if not isinstance(body, list) or not all(
isinstance(e, dict) and _whole(e.get("type_id")) and _whole(e.get("quantity")) for e in body
Expand Down
52 changes: 27 additions & 25 deletions EveProfitManager/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
from pathlib import Path
from tempfile import TemporaryDirectory

from core import ABYSS, INCURSION, Baseline, Loot, Totals, Tracker, best_weather, isk_per_hour, parse_sessions_txt
from core import ABYSS, INCURSION, Totals, Tracker, best_weather, isk_per_hour, parse_sessions_txt
from journal import Payout
from loot import LootValue

OLD_FILE = """Abyss Details!

Expand Down Expand Up @@ -259,29 +258,32 @@ def test_a_site_logged_by_hand_starts_the_next_payouts_time(self):
self.assertAlmostEqual(logged[0].minutes, (self.start + timedelta(minutes=30) - manual).total_seconds() / 60)


class LootTest(unittest.TestCase):
def setUp(self):
self.tracker = Tracker(":memory:")
self.sid = self.tracker.start_session(ABYSS, starting_isk=1000)

def test_the_first_baseline_of_a_session_is_the_one_kept(self):
self.assertIsNone(self.tracker.loot_baseline(self.sid))
self.tracker.set_loot_baseline(self.sid, 123, {34: 1_000, 47_762: 2})
self.tracker.set_loot_baseline(self.sid, 456, {34: 5}) # e.g. read again after a restart
self.assertEqual(self.tracker.loot_baseline(self.sid), Baseline(123, {34: 1_000, 47_762: 2}))

def test_the_latest_valuation_is_kept_with_when_the_assets_were_read(self):
self.assertIsNone(self.tracker.loot(self.sid))
self.tracker.set_loot_baseline(self.sid, 123, {})
as_of = datetime(2026, 10, 25, 18, 40)
self.tracker.set_loot(self.sid, LootValue(millions=12.5, unpriced=0), datetime(2026, 10, 25, 18, 0))
self.tracker.set_loot(self.sid, LootValue(millions=48.25, unpriced=2), as_of)
self.assertEqual(self.tracker.loot(self.sid), Loot(millions=48.25, unpriced=2, as_of=as_of))

def test_an_abandoned_session_takes_its_loot_with_it(self):
self.tracker.set_loot_baseline(self.sid, 123, {34: 1_000})
self.tracker.end_session(self.sid) # no runs, so the session is deleted
self.assertIsNone(self.tracker.loot_baseline(self.sid))
class LootTableTest(unittest.TestCase):
def tables(self, db):
return {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}

def test_a_new_database_has_no_loot_table(self):
tracker = Tracker(":memory:")
self.assertNotIn("loot", self.tables(tracker.db))

def test_an_old_database_with_a_loot_table_still_opens_and_keeps_it(self):
with TemporaryDirectory() as folder:
path = str(Path(folder) / "old.db")
tracker = Tracker(path)
sid = tracker.start_session(ABYSS, starting_isk=1000)
tracker.db.execute(
"CREATE TABLE loot (session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, "
"character_id INTEGER NOT NULL, baseline TEXT NOT NULL, millions REAL, unpriced INTEGER, as_of TEXT)"
)
tracker.db.execute("INSERT INTO loot (session_id, character_id, baseline) VALUES (?, 1, '{}')", (sid,))
tracker.db.commit()
tracker.close()
reopened = Tracker(path)
self.assertIn("loot", self.tables(reopened.db))
self.assertEqual(reopened.session(sid).starting_isk, 1000)
reopened.log_abyss_run(sid, 1000, 1050, 10)
self.assertEqual(len(reopened.runs(sid)), 1)
reopened.close()


class ImportTest(unittest.TestCase):
Expand Down
14 changes: 1 addition & 13 deletions EveProfitManager/test_loot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import unittest

from loot import Assets, LootValue, get_assets, get_prices, value_change
from loot import Assets, get_assets, get_prices

ASSETS = "/characters/123/assets"

Expand Down Expand Up @@ -59,17 +59,5 @@ def test_rejects_a_response_that_is_not_a_list_of_prices(self):
get_prices(FakeClient({"/markets/prices": body}))


class ValueChangeTests(unittest.TestCase):
def test_values_what_was_gained_less_what_was_used_up_in_millions(self):
before = {34: 10_000, 47_762: 1, 30_000: 500} # tritanium, a filament, ammo
after = {34: 10_000, 30_000: 100, 49_000: 2} # filament used, ammo shot, two drops
prices = {34: 4.5, 47_762: 1_200_000.0, 30_000: 500.0, 49_000: 2_100_000.0}
self.assertEqual(value_change(before, after, prices), LootValue(millions=2.8, unpriced=0))

def test_counts_the_kinds_of_item_with_no_price(self):
self.assertEqual(value_change({}, {52_227: 1, 52_228: 1, 34: 1_000_000}, {34: 4.5}),
LootValue(millions=4.5, unpriced=2))


if __name__ == "__main__":
unittest.main()
Loading
Loading