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
61 changes: 61 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# AbyssTracker

Tracks ISK made running abyssal deadspace and incursions in EVE Online. All amounts are in millions of ISK, all times in minutes.

## Language

**Run**:
One trip into abyssal deadspace on one filament, logged with its profit and the minutes it took (20 minus the time left on the timer). An incursion site is logged the same way but is called a site.
_Avoid_: trip, pocket (a pocket is one room inside a run)

**Session**:
A stretch of play holding the runs of one kind (abyss or incursion), started with a filament (tier and weather) for abyss. Its total is the sum of its runs' profit.
_Avoid_: batch, sitting

**Active ship**:
The ship EVE says the character is flying at the moment of the read, by its item id.
_Avoid_: current ship, hull

**Cargo**:
The items directly in the active ship's cargo hold (ESI location flag `Cargo`), ammo included. Not the drone bay, fitted modules, loaded ammo, items inside containers, or other ships and hangars.
_Avoid_: inventory, loot, hold contents

**Cargo value**:
What the cargo is worth, priced with CCP's average market price (or the adjusted price when there is no average). Typed by hand from EVE's inventory estimated value, or fetched from ESI.
_Avoid_: loot value, net worth

**Cargo before / Cargo after**:
The cargo value going into a run and coming out of it. Cargo before is prefilled from the previous run's cargo after in the same session, and can be changed when the hold was restocked or unloaded in station. The first run of a session starts with cargo before empty.

**Run profit**:
Cargo after minus cargo before. Filaments and ammo used up come out of it on their own, because they leave the hold. It can be negative, for example after losing cargo. Old runs logged from the wallet keep the profit they were logged with.
_Avoid_: loot, gain, wallet change

**Unpriced item**:
A cargo item type with no market price (for example an abyssal module). It is left out of a fetched cargo value and counted ("· 2 unpriced"), so the user knows the figure is incomplete.

**Wallet**:
The character's ISK balance. Shown on the Abyss page read-only, for reference. It takes no part in run profit any more; the Incursion page still reads payouts from the wallet journal, since those arrive as ISK.
_Avoid_: balance (in UI copy), ISK (as a noun for the wallet)

## Relationships

- A **Session** holds many **Runs**; a **Run** belongs to exactly one **Session**.
- A **Run** is either a cargo run (cargo before and cargo after set, no wallet after) or an old wallet run (wallet after set, no cargo values).
- **Run profit** = **Cargo after** − **Cargo before** for a cargo run.
- A run's **Cargo before** defaults to the previous cargo run's **Cargo after** in the same **Session**.
- **Cargo value** counts only the **Active ship**'s **Cargo**; **Unpriced items** are counted, not valued.

## Example dialogue

> **Dev:** "The user sold nothing this session, so the **Wallet** barely moved. What did run 3 make?"
> **Domain expert:** "Its **Run profit**: the **Cargo after** they typed when they left, minus the **Cargo before** going in. The **Wallet** doesn't come into it."
> **Dev:** "And if they dropped loot in station before run 4?"
> **Domain expert:** "They change run 4's prefilled **Cargo before** to what's left in the hold. Run 3 keeps its profit."

## Flagged ambiguities

- "Loot" was used for both the items picked up in a run and the all-assets "Loot (unsold)" stat from #41. Resolved: the stat is removed, and what a run brings back is measured as **Cargo value**.
- "Wallet" was used for both the ISK balance and a run's starting figure. Resolved: **Wallet** means only the ISK balance; a run starts from **Cargo before**.

_Rebuilt from the decisions recorded in GitHub issue #38 (grilled 2026-10-25); the original working-tree copy was lost before it was committed._
31 changes: 16 additions & 15 deletions EveProfitManager/abyss.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,11 @@ def abyssMain(tracker=None):
tracker = tracker or Tracker()
print(title)
weather, tier = askFilament(tracker)
startingIsk = startingInven()
session_id = tracker.start_session(ABYSS, startingIsk, weather, tier)
session_id = tracker.start_session(ABYSS, weather=weather, tier=tier)
try:
while True:
after_inv = afterInven()
run = tracker.log_abyss_run(session_id, startingIsk, after_inv, timeLeft())
startingIsk = after_inv
before = cargoBefore(tracker.latest_cargo_after(session_id))
run = tracker.log_cargo_run(session_id, before, cargoAfter(), timeLeft())
printRun(run)

while True:
Expand All @@ -31,7 +29,6 @@ def abyssMain(tracker=None):

if user_input == "1":
print(title)
print(f"\n{Fore.GREEN}Last Total: {Fore.RED}{startingIsk} Million\n")
break # Exit the inner loop to continue tracking another Abyssal run
elif user_input == "0":
tracker.end_session(session_id)
Expand Down Expand Up @@ -61,7 +58,7 @@ def printRun(run):
"""
print(
f"\n{Fore.GREEN}Profit: {Fore.RED}{run.profit} Million"
f"\n{Fore.GREEN}New total: {Fore.RED}{run.isk_after}"
f"\n{Fore.GREEN}Cargo after: {Fore.RED}{run.cargo_after} Million"
f"\n{Fore.GREEN}Time inside Abyss: {Fore.RED}{run.minutes} Mins"
f"\n{Fore.GREEN}ISK per Hour: {Fore.RED}{isk_per_hour(run.profit, run.minutes):.1f} Million\n"
)
Expand All @@ -85,24 +82,28 @@ def askFilament(tracker):
return WEATHERS[weather_index], tier


def startingInven():
def cargoBefore(default):
"""
Get the ISK total before an Abyssal run.
Get the cargo value going into an Abyssal run.

Args:
default (float | None): Last run's cargo after, taken on Enter. None on a session's first run.

Returns:
float: ISK total before Abyssal run.
float: Cargo value before the run, in millions.
"""
return askNumber("\nIsk Total before Abyss")
print()
return askNumber("Cargo before", minimum=0, default=default)


def afterInven():
def cargoAfter():
"""
Get the ISK total after an Abyssal run.
Get the cargo value coming out of an Abyssal run.

Returns:
float: ISK total after Abyssal run.
float: Cargo value after the run, in millions.
"""
return askNumber("Isk Total after Abyss")
return askNumber("Cargo after", minimum=0)


def timeLeft():
Expand Down
41 changes: 41 additions & 0 deletions EveProfitManager/cargo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Read what the active ship's cargo hold is worth from ESI, in the app's millions of ISK."""
from dataclasses import dataclass

from loot import _items, _whole, get_prices


@dataclass(frozen=True)
class Cargo:
millions: float
unpriced: int # item types with no market price, left out of `millions`
fetched_at: float # when ESI last answered for the asset list; it caches it for an hour


def get_cargo(client, character_id):
"""
The cargo value of the ship EVE says the character is flying now.

Counts only items directly in that ship with flag Cargo (ammo included), not
its drone bay, fitted modules, other ships, hangars or containers. Blueprint
copies are left out, as in the asset reader, because their type's price is
the original's.
"""
ship = client.get(f"/characters/{character_id}/ship")
if not isinstance(ship, dict) or not _whole(ship.get("ship_item_id")):
raise ValueError(f"Unexpected ship response: {ship!r}")
path = f"/characters/{character_id}/assets"
first = f"{path}?page=1"
items = _items(client.get(first))
for page in range(2, client.pages(first) + 1):
items += _items(client.get(f"{path}?page={page}"))
quantities = {}
for entry in items:
if not _whole(entry.get("location_id")) or not isinstance(entry.get("location_flag"), str):
raise ValueError(f"Unexpected asset: {entry!r}")
if entry["location_id"] == ship["ship_item_id"] and entry["location_flag"] == "Cargo" \
and not entry.get("is_blueprint_copy"):
quantities[entry["type_id"]] = quantities.get(entry["type_id"], 0) + entry["quantity"]
prices = get_prices(client)
isk = sum(count * prices[type_id] for type_id, count in quantities.items() if type_id in prices)
unpriced = sum(1 for type_id in quantities if type_id not in prices)
return Cargo(millions=isk / 1e6, unpriced=unpriced, fetched_at=client.fetched_at(first))
89 changes: 84 additions & 5 deletions EveProfitManager/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
profit REAL NOT NULL,
minutes REAL NOT NULL,
isk_after REAL,
count INTEGER NOT NULL DEFAULT 1
count INTEGER NOT NULL DEFAULT 1,
cargo_before REAL,
cargo_after REAL
);
CREATE TABLE IF NOT EXISTS payouts (
journal_id INTEGER PRIMARY KEY
Expand Down Expand Up @@ -108,8 +110,10 @@ class Run:
recorded_at: datetime
profit: float
minutes: float
isk_after: float | None
isk_after: float | None # wallet after an old wallet-based run
count: int
cargo_before: float | None = None # set, with cargo_after, on a cargo run
cargo_after: float | None = None


@dataclass
Expand Down Expand Up @@ -183,6 +187,10 @@ def _migrate(self):
for column, type_ in (("import_key", "TEXT"), ("weather", "TEXT"), ("tier", "INTEGER")):
if column not in columns:
self.db.execute(f"ALTER TABLE sessions ADD COLUMN {column} {type_}")
columns = [r["name"] for r in self.db.execute("PRAGMA table_info(runs)")]
for column in ("cargo_before", "cargo_after"): # added, never back-filled (ADR 0001)
if column not in columns:
self.db.execute(f"ALTER TABLE runs ADD COLUMN {column} REAL")

def close(self):
self.db.close()
Expand All @@ -195,7 +203,8 @@ def start_session(self, kind, starting_isk=None, weather=None, tier=None):

Args:
kind (str): ABYSS or INCURSION.
starting_isk (float, optional): Wallet before the first abyssal run.
starting_isk (float, optional): Wallet before the first run, kept for old
wallet-based sessions. New abyss sessions start without one.
weather (str, optional): Abyssal weather from WEATHERS.
tier (int, optional): Filament tier, 0 (Tranquil) to 6 (Cataclysmic).

Expand Down Expand Up @@ -263,9 +272,49 @@ def delete_session(self, session_id):

# --- runs -----------------------------------------------------------

def log_cargo_run(self, session_id, cargo_before, cargo_after, time_left):
"""
Record an abyssal run from the cargo value before and after it, and the timer.

Profit is cargo after minus cargo before, so it can be negative (lost cargo).

Args:
session_id (int): Session to add the run to.
cargo_before (float): Cargo value going in.
cargo_after (float): Cargo value coming out.
time_left (float): Minutes left on the abyssal timer when leaving.

Returns:
Run: The recorded run.
"""
_require_finite(cargo_before, cargo_after)
_require_time_left(time_left)
with self.db:
cur = self.db.execute(
"INSERT INTO runs (session_id, recorded_at, profit, minutes, cargo_before, cargo_after) "
"VALUES (?, ?, ?, ?, ?, ?)",
(session_id, _now(), cargo_after - cargo_before, MAXTIME - time_left, cargo_before, cargo_after),
)
return self.run(cur.lastrowid)

def latest_cargo_after(self, session_id):
"""
Cargo after of the session's latest cargo run, to prefill the next run's cargo before.

Returns:
float | None: None for a session with no cargo runs (new, or wallet runs only).
"""
row = self.db.execute(
"SELECT cargo_after FROM runs WHERE session_id = ? AND cargo_after IS NOT NULL "
"ORDER BY id DESC LIMIT 1",
(session_id,),
).fetchone()
return row["cargo_after"] if row else None

def log_abyss_run(self, session_id, isk_before, isk_after, time_left):
"""
Record an abyssal run from wallet amounts and the timer.
Record an abyssal run from wallet amounts and the timer, the way runs were logged
before cargo runs. Nothing logs new runs this way now; kept for old wallet-based data.

Args:
session_id (int): Session to add the run to.
Expand Down Expand Up @@ -394,6 +443,32 @@ def edit_abyss_run(self, run_id, isk_after, time_left):
)
return self.run(run_id)

def edit_cargo_run(self, run_id, cargo_before, cargo_after, time_left):
"""
Correct a cargo run's cargo values or timer. Its profit stands on its own
cargo values, so no other run changes.

Args:
run_id (int): Cargo run to change.
cargo_before (float): Corrected cargo value going in.
cargo_after (float): Corrected cargo value coming out.
time_left (float): Corrected minutes left on the abyssal timer.

Returns:
Run: The updated run.
"""
_require_finite(cargo_before, cargo_after)
_require_time_left(time_left)
run = self.run(run_id)
if run is None or run.cargo_after is None:
raise ValueError("Only cargo runs have cargo values to correct")
with self.db:
self.db.execute(
"UPDATE runs SET profit = ?, minutes = ?, cargo_before = ?, cargo_after = ? WHERE id = ?",
(cargo_after - cargo_before, MAXTIME - time_left, cargo_before, cargo_after, run_id),
)
return self.run(run_id)

def edit_incursion_site(self, run_id, profit, minutes):
"""Correct an incursion site's payout or time."""
_require_finite(profit)
Expand Down Expand Up @@ -518,7 +593,8 @@ def export_csv(self, path, kind=None):
rows = self.db.execute(
"SELECT s.id AS session_id, s.kind, s.started_at, s.ended_at, s.weather, s.tier, s.imported, "
"r.id AS run_id, r.recorded_at, r.profit AS profit_millions, r.minutes, "
"r.isk_after AS isk_after_millions, r.count AS runs "
"r.isk_after AS isk_after_millions, r.count AS runs, "
"r.cargo_before AS cargo_before_millions, r.cargo_after AS cargo_after_millions "
f"FROM sessions s LEFT JOIN runs r ON r.session_id = s.id {where} ORDER BY s.started_at, s.id, r.id",
args,
).fetchall()
Expand Down Expand Up @@ -674,6 +750,7 @@ def _now():
_CSV_COLUMNS = (
"session_id", "kind", "started_at", "ended_at", "weather", "tier", "imported",
"run_id", "recorded_at", "profit_millions", "minutes", "isk_after_millions", "runs",
"cargo_before_millions", "cargo_after_millions", # last, so older columns keep their place
)

_SESSION_QUERY = (
Expand Down Expand Up @@ -731,4 +808,6 @@ def _run(row):
minutes=row["minutes"],
isk_after=row["isk_after"],
count=row["count"],
cargo_before=row["cargo_before"],
cargo_after=row["cargo_after"],
)
9 changes: 7 additions & 2 deletions EveProfitManager/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,26 @@ def clear():
os.system("cls" if os.name == "nt" else "clear")


def askNumber(prompt, minimum=None, maximum=None):
def askNumber(prompt, minimum=None, maximum=None, default=None):
"""
Keep asking until the user enters a valid number.

Args:
prompt (str): Text shown before the input.
minimum (float, optional): Lowest accepted value.
maximum (float, optional): Highest accepted value.
default (float, optional): Value returned when the user just presses Enter.

Returns:
float: The number entered.
"""
hint = f" [{default:.15g}]" if default is not None else ""
while True:
answer = input(f"{Fore.GREEN}{prompt}{hint}: {Fore.RED}").strip()
if not answer and default is not None:
return default
try:
value = float(input(f"{Fore.GREEN}{prompt}: {Fore.RED}"))
value = float(answer)
except ValueError:
value = math.nan
if not math.isfinite(value):
Expand Down
Loading
Loading