chore: init

This commit is contained in:
Hikyu
2026-09-18 17:18:57 +02:00
commit 15463c2e3c
200 changed files with 51032 additions and 0 deletions
@@ -0,0 +1,462 @@
package com.example.essentialsplus.data;
import com.example.essentialsplus.WealthCategory;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Persisted state of one character. Collections are replaced wholesale (never mutated in place,
* except history which is copy-on-write) so the UI and the save thread can read them safely.
*/
public class AccountData
{
public int schema = 3;
public String key;
public long accountHash;
public String profile;
public String name;
public long lastUpdated;
public boolean bankKnown;
public long[] current;
/** running sum of value changes caused only by GE price movements (held quantity x price change) */
public long market;
/** Last known item stacks per source ("bank", "inventory", ...): item id -> quantity. */
public Map<String, Map<Integer, Long>> containers = new ConcurrentHashMap<>();
public List<SourceValue> sources = new ArrayList<>();
public List<TopItem> topItems = new ArrayList<>();
public List<Snapshot> history = new CopyOnWriteArrayList<>();
/** Grand Exchange slot state (see GrandExchangeTracker) */
public GeSlot[] geSlots;
/** Items known to be collected already when GE tracking starts for this character (item id -> qty). */
public Map<Integer, Long> geSeedCollected;
/** Items that went to the bank while it was not visible; dropped when the bank is seen again. */
public Map<Integer, Long> bankPending = new java.util.HashMap<>();
/** 4-hour Grand Exchange buy limit tracker: canonical item id -> GeBuyLimit */
public Map<Integer, GeBuyLimit> geBuyLimits = new ConcurrentHashMap<>();
/** Snapshot of Grand Exchange slots for 4h buy limit tracking, modeled after LimitKeeper */
public GeBuyLimitSlot[] geBuyLimitSlots = new GeBuyLimitSlot[8];
/** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Now' */
public long resetHourlyNowAt;
/** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Session' */
public long resetHourlySessionAt;
public static class GeBuyLimitSlot
{
public int itemId;
public int price;
public int total;
public int quantity;
public int spent;
public GeBuyLimitSlot()
{
}
public GeBuyLimitSlot(int itemId, int price, int total, int quantity, int spent)
{
this.itemId = itemId;
this.price = price;
this.total = total;
this.quantity = quantity;
this.spent = spent;
}
public boolean isSameOffer(GeBuyLimitSlot other)
{
return other != null && other.itemId == itemId && other.price == price && other.total == total;
}
}
public transient long fileModified;
public long currentTotal()
{
long sum = 0;
if (current != null)
{
for (long x : current)
{
sum += x;
}
}
return sum;
}
public Snapshot lastSnapshot()
{
List<Snapshot> h = history;
return h.isEmpty() ? null : h.get(h.size() - 1);
}
public void normalize()
{
containers = containers == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(containers);
sources = sources == null ? new ArrayList<>() : sources;
topItems = topItems == null ? new ArrayList<>() : topItems;
bankPending = bankPending == null ? new java.util.HashMap<>() : bankPending;
geBuyLimits = geBuyLimits == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(geBuyLimits);
if (geBuyLimitSlots == null || geBuyLimitSlots.length != 8)
{
geBuyLimitSlots = geBuyLimitSlots == null ? new GeBuyLimitSlot[8] : java.util.Arrays.copyOf(geBuyLimitSlots, 8);
}
if (schema < 3)
{
clearBuyLimits();
schema = 3;
}
List<Snapshot> h = history == null ? new ArrayList<>() : history;
h.removeIf(s -> s == null);
h.forEach(Snapshot::normalize);
h.sort((a, b) -> Long.compare(a.t, b.t));
h = sanitizeHistory(h);
history = new CopyOnWriteArrayList<>(h);
if (current != null && current.length < WealthCategory.COUNT)
{
current = java.util.Arrays.copyOf(current, WealthCategory.COUNT);
}
}
public static final long MAX_SPIKE_WINDOW_MS = 5 * 60 * 1000L;
public static final long MIN_SPIKE_ABS_GP = 1_000_000L;
public static final double MIN_SPIKE_REL_RATIO = 0.08;
/**
* Checks if a single snapshot at (tSpike, vSpike) represents a transient outlier spike between
* (tBefore, vBefore) and (tAfter, vAfter) that jumped away and quickly returned to baseline.
*/
public static boolean isTransientSpike(long tBefore, long vBefore,
long tSpike, long vSpike,
long tAfter, long vAfter)
{
long legBefore = tSpike - tBefore;
long legAfter = tAfter - tSpike;
if (legBefore <= 0 || legAfter <= 0)
{
return false;
}
// The spike itself must be transient:
// Either the entire span is within MAX_SPIKE_WINDOW_MS (continuous session),
// OR across a session/logout gap (where one leg is long), the spike only persisted for
// a brief moment (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS).
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|| (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS);
if (!transientDuration)
{
return false;
}
long d1 = vSpike - vBefore;
long d2 = vAfter - vSpike;
// Must be opposite directions (a jump away and a return)
if ((d1 > 0 && d2 >= 0) || (d1 < 0 && d2 <= 0) || d1 == 0 || d2 == 0)
{
return false;
}
long absD1 = Math.abs(d1);
long absD2 = Math.abs(d2);
long spikeMag = Math.min(absD1, absD2);
long baseDelta = Math.abs(vAfter - vBefore);
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
// Magnitude check: must be at least 1M GP or at least 8% of baseline wealth
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
if (!significantSpike)
{
return false;
}
// Baseline consistency check: after the spike, the value returned close to the starting baseline.
return baseDelta <= 0.35 * spikeMag
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
}
/**
* Checks if two consecutive snapshots represent a 2-point transient outlier spike between
* (tBefore, vBefore) and (tAfter, vAfter).
*/
public static boolean isDoubleTransientSpike(long tBefore, long vBefore,
long tSpike1, long vSpike1,
long tSpike2, long vSpike2,
long tAfter, long vAfter)
{
long legBefore = tSpike1 - tBefore;
long legGlitch = tSpike2 - tSpike1;
long legAfter = tAfter - tSpike2;
if (legBefore <= 0 || legGlitch <= 0 || legAfter <= 0)
{
return false;
}
if (legGlitch > MAX_SPIKE_WINDOW_MS)
{
return false;
}
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|| (Math.min(legBefore, legAfter) + legGlitch <= MAX_SPIKE_WINDOW_MS);
if (!transientDuration)
{
return false;
}
long d1_1 = vSpike1 - vBefore;
long d1_2 = vSpike2 - vBefore;
long d2_1 = vAfter - vSpike1;
long d2_2 = vAfter - vSpike2;
// Both points must deviate in the same direction from baseline and return
boolean bothPositive = d1_1 > 0 && d1_2 > 0 && d2_1 < 0 && d2_2 < 0;
boolean bothNegative = d1_1 < 0 && d1_2 < 0 && d2_1 > 0 && d2_2 > 0;
if (!bothPositive && !bothNegative)
{
return false;
}
long spikeMag = Math.min(
Math.min(Math.abs(d1_1), Math.abs(d1_2)),
Math.min(Math.abs(d2_1), Math.abs(d2_2))
);
long baseDelta = Math.abs(vAfter - vBefore);
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
if (!significantSpike)
{
return false;
}
return baseDelta <= 0.35 * spikeMag
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
}
/**
* Checks if three consecutive snapshots represent a 3-point transient outlier spike between
* (tBefore, vBefore) and (tAfter, vAfter).
*/
public static boolean isTripleTransientSpike(long tBefore, long vBefore,
long tSpike1, long vSpike1,
long tSpike2, long vSpike2,
long tSpike3, long vSpike3,
long tAfter, long vAfter)
{
long legBefore = tSpike1 - tBefore;
long legGlitch1 = tSpike2 - tSpike1;
long legGlitch2 = tSpike3 - tSpike2;
long totalGlitch = tSpike3 - tSpike1;
long legAfter = tAfter - tSpike3;
if (legBefore <= 0 || legGlitch1 <= 0 || legGlitch2 <= 0 || legAfter <= 0)
{
return false;
}
if (totalGlitch > MAX_SPIKE_WINDOW_MS)
{
return false;
}
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|| (Math.min(legBefore, legAfter) + totalGlitch <= MAX_SPIKE_WINDOW_MS);
if (!transientDuration)
{
return false;
}
long d1_1 = vSpike1 - vBefore;
long d1_2 = vSpike2 - vBefore;
long d1_3 = vSpike3 - vBefore;
long d2_1 = vAfter - vSpike1;
long d2_2 = vAfter - vSpike2;
long d2_3 = vAfter - vSpike3;
boolean allPositive = d1_1 > 0 && d1_2 > 0 && d1_3 > 0 && d2_1 < 0 && d2_2 < 0 && d2_3 < 0;
boolean allNegative = d1_1 < 0 && d1_2 < 0 && d1_3 < 0 && d2_1 > 0 && d2_2 > 0 && d2_3 > 0;
if (!allPositive && !allNegative)
{
return false;
}
long spikeMag = Math.min(
Math.min(Math.abs(d1_1), Math.min(Math.abs(d1_2), Math.abs(d1_3))),
Math.min(Math.abs(d2_1), Math.min(Math.abs(d2_2), Math.abs(d2_3)))
);
long baseDelta = Math.abs(vAfter - vBefore);
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
if (!significantSpike)
{
return false;
}
return baseDelta <= 0.35 * spikeMag
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
}
/**
* Filters out momentary outlier spikes from the timeline (e.g. 1-minute spikes caused by
* transient container reloading, temporary GE duplication, or pricing glitches).
*/
public static List<Snapshot> sanitizeHistory(List<Snapshot> snapshots)
{
if (snapshots == null || snapshots.size() < 3)
{
return snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots);
}
List<Snapshot> list = new ArrayList<>(snapshots);
boolean changed = true;
int passes = 0;
while (changed && passes < 5)
{
changed = false;
passes++;
// Pass 1: check single-point spikes
for (int i = 1; i < list.size() - 1; i++)
{
Snapshot before = list.get(i - 1);
Snapshot cur = list.get(i);
Snapshot after = list.get(i + 1);
if (isTransientSpike(before.t, before.total(), cur.t, cur.total(), after.t, after.total()))
{
list.remove(i);
changed = true;
i--;
}
}
// Pass 2: check two-point spikes
for (int i = 1; i < list.size() - 2; i++)
{
Snapshot before = list.get(i - 1);
Snapshot s1 = list.get(i);
Snapshot s2 = list.get(i + 1);
Snapshot after = list.get(i + 2);
if (isDoubleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), after.t, after.total()))
{
list.remove(i + 1);
list.remove(i);
changed = true;
i--;
}
}
// Pass 3: check three-point spikes
for (int i = 1; i < list.size() - 3; i++)
{
Snapshot before = list.get(i - 1);
Snapshot s1 = list.get(i);
Snapshot s2 = list.get(i + 1);
Snapshot s3 = list.get(i + 2);
Snapshot after = list.get(i + 3);
if (isTripleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), s3.t, s3.total(), after.t, after.total()))
{
list.remove(i + 2);
list.remove(i + 1);
list.remove(i);
changed = true;
i--;
}
}
}
return list;
}
public static final long FOUR_HOURS_MS = 4 * 3600 * 1000L;
/**
* Records a purchase of an item, managing the 4-hour window rollover.
*/
public void recordBuy(int itemId, int quantity, long now)
{
if (quantity <= 0 || itemId <= 0)
{
return;
}
if (geBuyLimits == null)
{
geBuyLimits = new ConcurrentHashMap<>();
}
GeBuyLimit limit = geBuyLimits.computeIfAbsent(itemId, id -> new GeBuyLimit(id, 0, 0, 0));
synchronized (limit)
{
if (limit.windowStart == 0 || now - limit.windowStart >= FOUR_HOURS_MS)
{
limit.windowStart = now;
limit.bought = quantity;
}
else
{
limit.bought += quantity;
}
limit.lastTradeTime = now;
}
}
/**
* Computes the current 4-hour buy limit status for an item.
*/
public GeLimitInfo getBuyLimitInfo(int itemId, int itemGeLimit, long now)
{
GeBuyLimit limit = geBuyLimits != null ? geBuyLimits.get(itemId) : null;
if (limit == null || limit.windowStart == 0)
{
return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false);
}
synchronized (limit)
{
long elapsed = now - limit.windowStart;
if (elapsed >= FOUR_HOURS_MS)
{
// Expired: full limit restored
return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false);
}
else
{
int bought = limit.bought;
int remaining = Math.max(0, itemGeLimit > 0 ? itemGeLimit - bought : 0);
long resetInMs = FOUR_HOURS_MS - elapsed;
return new GeLimitInfo(itemId, itemGeLimit, bought, remaining, resetInMs, true);
}
}
}
/**
* Resets all tracked 4-hour buy limits and slot tracking for this character.
*/
public void clearBuyLimits()
{
if (geBuyLimits != null)
{
geBuyLimits.clear();
}
geBuyLimitSlots = new GeBuyLimitSlot[8];
}
public void resetHourly(boolean isSession)
{
long now = System.currentTimeMillis();
if (isSession)
{
resetHourlySessionAt = now;
}
else
{
resetHourlyNowAt = now;
}
}
}
@@ -0,0 +1,24 @@
package com.example.essentialsplus.data;
/**
* Tracks purchases of a specific item within the rolling 4-hour Grand Exchange buy limit window.
*/
public class GeBuyLimit
{
public int itemId;
public int bought;
public long windowStart;
public long lastTradeTime;
public GeBuyLimit()
{
}
public GeBuyLimit(int itemId, int bought, long windowStart, long lastTradeTime)
{
this.itemId = itemId;
this.bought = bought;
this.windowStart = windowStart;
this.lastTradeTime = lastTradeTime;
}
}
@@ -0,0 +1,24 @@
package com.example.essentialsplus.data;
/**
* Calculated status of an item's 4-hour Grand Exchange buy limit.
*/
public class GeLimitInfo
{
public final int itemId;
public final int limit;
public final int bought;
public final int remaining;
public final long resetInMs;
public final boolean windowActive;
public GeLimitInfo(int itemId, int limit, int bought, int remaining, long resetInMs, boolean windowActive)
{
this.itemId = itemId;
this.limit = limit;
this.bought = bought;
this.remaining = remaining;
this.resetInMs = resetInMs;
this.windowActive = windowActive;
}
}
@@ -0,0 +1,28 @@
package com.example.essentialsplus.data;
/** One Grand Exchange slot prepared for display. */
public class GeRow
{
public final String character;
public final int slot;
/** null = empty slot */
public final GeSlot offer;
public final String name;
public final int marketPrice;
public final GeLimitInfo limitInfo;
public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice)
{
this(character, slot, offer, name, marketPrice, null);
}
public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice, GeLimitInfo limitInfo)
{
this.character = character;
this.slot = slot;
this.offer = offer;
this.name = name;
this.marketPrice = marketPrice;
this.limitInfo = limitInfo;
}
}
@@ -0,0 +1,30 @@
package com.example.essentialsplus.data;
/** Last known state of one Grand Exchange slot, persisted so collections can be told apart from gains. */
public class GeSlot
{
public int item;
public boolean buy;
public long total;
public long price;
/** items / coins already taken out of the collection box (fallback tracking) */
public long collectedItems;
public long collectedCoins;
/** value held by the slot at the last update */
public long escrowItems;
public long escrowCoins;
public long collectItems;
public long collectCoins;
/** live offer details */
public long done;
public long spent;
public String state;
/** when the offer was placed (0 = before tracking started, see trackedSince) */
public long placedAt;
public long trackedSince;
public long trackedDone;
public long lastTradeAt;
}
@@ -0,0 +1,48 @@
package com.example.essentialsplus.data;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/** Item holdings over time, stored separately from the wealth timeline (&lt;key&gt;.items.json). */
public class ItemLedger
{
public List<ItemSnapshot> snapshots = new CopyOnWriteArrayList<>();
public transient long fileModified;
public transient boolean dirty;
public ItemSnapshot last()
{
List<ItemSnapshot> s = snapshots;
return s.isEmpty() ? null : s.get(s.size() - 1);
}
/** Latest snapshot taken at or before {@code t}, or the first one after it if none exists. */
public ItemSnapshot at(long t)
{
List<ItemSnapshot> s = snapshots;
ItemSnapshot best = null;
for (ItemSnapshot x : s)
{
if (x.t <= t)
{
best = x;
}
else
{
return best != null ? best : x;
}
}
return best;
}
public void normalize()
{
List<ItemSnapshot> s = snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots);
s.removeIf(x -> x == null || x.ids == null || x.q == null || x.p == null
|| x.ids.length != x.q.length || x.ids.length != x.p.length);
s.sort((a, b) -> Long.compare(a.t, b.t));
snapshots = new CopyOnWriteArrayList<>(s);
}
}
@@ -0,0 +1,20 @@
package com.example.essentialsplus.data;
/** One priced item stack for the item viewer. */
public class ItemRow
{
public final int id;
public final String name;
public final long qty;
public final long price;
public final long value;
public ItemRow(int id, String name, long qty, long price)
{
this.id = id;
this.name = name;
this.qty = qty;
this.price = price;
this.value = qty * price;
}
}
@@ -0,0 +1,44 @@
package com.example.essentialsplus.data;
import java.util.Arrays;
/**
* Holdings of a character at one moment: canonical item id, quantity and the GE price at that time.
* Allows splitting wealth changes into "market" (price) and "activity" (quantity) effects.
*/
public class ItemSnapshot
{
public long t;
public int[] ids;
public long[] q;
public int[] p;
/** pinned (session start) - kept by compaction */
public Boolean k;
public ItemSnapshot()
{
}
public ItemSnapshot(long t, int[] ids, long[] q, int[] p)
{
this.t = t;
this.ids = ids;
this.q = q;
this.p = p;
}
public boolean sameAs(ItemSnapshot other)
{
return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q) && Arrays.equals(p, other.p);
}
public boolean sameQuantities(ItemSnapshot other)
{
return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q);
}
public int indexOf(int id)
{
return ids == null ? -1 : Arrays.binarySearch(ids, id);
}
}
@@ -0,0 +1,148 @@
package com.example.essentialsplus.data;
import com.example.essentialsplus.WealthCategory;
import java.util.Arrays;
/**
* One point on the wealth timeline.
* <p>
* Raw points only have {@code t} and {@code v}. Aggregated points (hourly / daily buckets of older data)
* additionally hold open, close, low and high of the total and store the time-weighted average
* breakdown in {@code v}.
*/
public class Snapshot
{
public static final String HOUR = "h";
public static final String DAY = "d";
public long t;
public long[] v;
/** open / close / low / high total - only set on aggregates */
public Long o;
public Long c;
public Long lo;
public Long hi;
/** granularity of an aggregate: "h" or "d" */
public String g;
/** cumulative value change from GE price movements up to this point ({@link AccountData#market}); null on old data */
public Long m;
public Snapshot()
{
}
public Snapshot(long t, long[] v)
{
this.t = t;
this.v = v;
}
public long total()
{
long sum = 0;
if (v != null)
{
for (long x : v)
{
sum += x;
}
}
return sum;
}
public long get(WealthCategory category)
{
int i = category.ordinal();
return v != null && i < v.length ? v[i] : 0;
}
public long low()
{
return lo != null ? lo : total();
}
public long high()
{
return hi != null ? hi : total();
}
public long open()
{
return o != null ? o : total();
}
public long close()
{
return c != null ? c : total();
}
public boolean isAggregate()
{
return g != null || lo != null;
}
/** Pads arrays loaded from older files to the current category count. */
public void normalize()
{
if (v == null)
{
v = new long[WealthCategory.COUNT];
}
else if (v.length < WealthCategory.COUNT)
{
v = Arrays.copyOf(v, WealthCategory.COUNT);
}
if (lo != null && g == null)
{
g = DAY;
}
}
public long market()
{
return m != null ? m : 0;
}
/** Copy with {@code delta} removed from the total, spread over the categories by their share. */
public Snapshot shifted(long delta)
{
long[] nv = v == null ? new long[0] : v.clone();
if (delta != 0 && nv.length > 0)
{
long base = 0;
for (long x : nv)
{
base += Math.abs(x);
}
long left = delta;
int largest = 0;
for (int i = 0; i < nv.length; i++)
{
if (Math.abs(nv[i]) > Math.abs(nv[largest]))
{
largest = i;
}
if (base > 0)
{
long part = Math.round((double) delta * Math.abs(nv[i]) / base);
nv[i] -= part;
left -= part;
}
}
nv[largest] -= left;
}
Snapshot s = new Snapshot(t, nv);
s.g = g;
s.m = m;
s.o = o == null ? null : o - delta;
s.c = c == null ? null : c - delta;
s.lo = lo == null ? null : lo - delta;
s.hi = hi == null ? null : hi - delta;
return s;
}
public boolean sameValues(long[] other)
{
return Arrays.equals(v, other);
}
}
@@ -0,0 +1,20 @@
package com.example.essentialsplus.data;
/** Value of a single wealth source (e.g. "Looting bag") inside a category. */
public class SourceValue
{
public String name;
public int category;
public long value;
public SourceValue()
{
}
public SourceValue(String name, int category, long value)
{
this.name = name;
this.category = category;
this.value = value;
}
}
@@ -0,0 +1,21 @@
package com.example.essentialsplus.data;
public class TopItem
{
public int id;
public String name;
public long qty;
public long value;
public TopItem()
{
}
public TopItem(int id, String name, long qty, long value)
{
this.id = id;
this.name = name;
this.qty = qty;
this.value = value;
}
}