Skip to content

Commit 5249f7c

Browse files
peans99claude
andcommitted
0.8.35 - say what looted gear is typically worth
The Loot page listed what was picked up and where, and said nothing about value. The join needed already existed - the stash prices items by taking the item class to a community entry and its uuid to UEX - so this is that same hop at the loot endpoint. The number is the median across every terminal stocking the item, not UexData.ItemPrice, which is documented as the cheapest. The two answer different questions and on this install they differ tenfold: the MaxLift Tractor Beam is stocked near 19,175 by about a hundred terminals and at 1,975 by one, so the cheapest reads as a tenth of what the thing is worth. A mean would still be dragged by that row, which is why this is a median. UEX stocks 64 of this install's 109 looted item classes, so 41% of the table has no price. Those show a dash and a reason rather than a blank that reads as worthless or a zero that reads as free, and a summary tile says how many rows carry a price so a sparse table is explained rather than looking broken. Nothing stored changes shape, so PayloadVersion stays where it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C5xdyEeEFuvh5nPTGFSjnF
1 parent 12b04df commit 5249f7c

8 files changed

Lines changed: 239 additions & 7 deletions

File tree

Directory.Build.props

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
<Copyright>Copyright © nekron</Copyright>
1111
<Description>A pilot's logbook for Star Citizen: second-screen dashboard, in-game overlay, and flight-log analysis for Star Citizen, driven by Game.log.</Description>
1212

13-
<Version>0.8.34</Version>
14-
<AssemblyVersion>0.8.34.0</AssemblyVersion>
15-
<FileVersion>0.8.34.0</FileVersion>
13+
<Version>0.8.35</Version>
14+
<AssemblyVersion>0.8.35.0</AssemblyVersion>
15+
<FileVersion>0.8.35.0</FileVersion>
1616

1717
<RepositoryUrl>https://github.com/peans99/QuantumWake</RepositoryUrl>
1818
<PackageProjectUrl>https://github.com/peans99/QuantumWake</PackageProjectUrl>

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ affiliated with or endorsed by Cloud Imperium Games.
384384
Newest first. Each version's section is what the GitHub release says too — the
385385
release workflow lifts it from here, so it is written once.
386386

387-
### 0.8.34
387+
### 0.8.35
388388

389389
- Ship retrievals are recorded again. Game build 12519617 stopped writing the
390390
log line that confirmed a retrieved ship had reached the pad, so retrievals
@@ -401,6 +401,14 @@ release workflow lifts it from here, so it is written once.
401401
than showing a zero it cannot stand behind. It can be switched on in the
402402
overlay widget too.
403403

404+
- Loot says what things are worth. Each item carries the median price across
405+
every terminal UEX reports stocking it — what it usually goes for, rather
406+
than the cheapest run to make. The two are not the same: one item here is
407+
stocked near 19,175 by a hundred terminals and at 1,975 by one, so the
408+
cheapest understates it tenfold. Items UEX does not stock show a dash and
409+
the reason instead of a zero, and the summary says how many of the rows
410+
carry a price at all.
411+
404412
### 0.8.32
405413

406414
- **Settings can save a report to send with a bug.** If a page is empty for

src/Quantumwake.Data/UexData.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,37 @@ public UexData(string? directory = null)
266266
public decimal? ItemPrice(string? uuid) =>
267267
uuid is not null && _itemPrices.TryGetValue(uuid, out var price) ? price : null;
268268

269+
/// <summary>
270+
/// What an item usually costs: the median buy price across every terminal
271+
/// stocking it. Null when nothing stocks it.
272+
/// </summary>
273+
/// <remarks>
274+
/// Median rather than mean, and deliberately not <see cref="ItemPrice"/>.
275+
/// The cheapest is what you would pay having flown to the right terminal,
276+
/// which is a different question from what a thing is worth, and one odd
277+
/// row makes it wildly unrepresentative: this install's MaxLift Tractor
278+
/// Beam is stocked by 103 terminals at about 19,175 and by one at 1,975,
279+
/// so the cheapest understates it tenfold. A mean would still be dragged
280+
/// by that row; a median ignores it.
281+
///
282+
/// Falls back to the cheapest when the per-terminal rows are missing but a
283+
/// price is known, so an item is never left unpriced over a gap in the
284+
/// market table alone.
285+
/// </remarks>
286+
public decimal? TypicalItemPrice(string? uuid)
287+
{
288+
var rows = ItemMarket(uuid);
289+
if (rows.Count == 0)
290+
return ItemPrice(uuid);
291+
292+
var sorted = rows.Select(row => row.Buy).Order().ToArray();
293+
var middle = sorted.Length / 2;
294+
295+
return sorted.Length % 2 == 1
296+
? sorted[middle]
297+
: (sorted[middle - 1] + sorted[middle]) / 2;
298+
}
299+
269300
/// <summary>Every terminal stocking an item, by uuid. Empty when unknown.</summary>
270301
public IReadOnlyList<UexItemRow> ItemMarket(string? uuid) =>
271302
uuid is not null && _itemMarket.TryGetValue(uuid, out var rows) ? rows : [];

src/Quantumwake.Server/ServerHost.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,20 @@ static void MustRevalidate(StaticFileResponseContext context) =>
838838
});
839839

840840
// Items observed entering the player's inventories - the Loot page.
841-
app.MapGet("/api/loot", (LogLibrary lib, int? days) => lib.Pickups(days ?? 0));
841+
// Priced at the endpoint rather than in the library, the same join the
842+
// stash uses: the item class names a community entry, which carries the
843+
// uuid UEX prices against. Null price is normal and the page says so -
844+
// UEX stocks 64 of this install's 109 looted classes.
845+
app.MapGet("/api/loot", (LogLibrary lib, UexData uex, int? days) =>
846+
lib.Pickups(days ?? 0).Select(p => new
847+
{
848+
p.At,
849+
p.Item,
850+
p.ItemClass,
851+
p.Place,
852+
p.Category,
853+
price = uex.TypicalItemPrice(lib.Community.Item(p.ItemClass)?.Uuid)
854+
}));
842855
app.MapGet("/api/contracts", (LogLibrary lib, int? days) => lib.Contracts(days ?? 0));
843856

844857
// Work done per faction, and the little reputation anyone has written
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using Quantumwake.Data;
2+
3+
namespace Quantumwake.Tests;
4+
5+
/// <summary>
6+
/// What an item is worth, as opposed to what it costs at the one terminal
7+
/// selling it cheapest. The two answer different questions and this install
8+
/// has an item where they differ tenfold.
9+
/// </summary>
10+
public class UexTypicalPriceTests : IDisposable
11+
{
12+
private readonly string _directory =
13+
Path.Combine(Path.GetTempPath(), $"qw-typical-{Guid.NewGuid():N}");
14+
15+
public UexTypicalPriceTests() => Directory.CreateDirectory(_directory);
16+
17+
public void Dispose()
18+
{
19+
if (Directory.Exists(_directory))
20+
Directory.Delete(_directory, recursive: true);
21+
22+
GC.SuppressFinalize(this);
23+
}
24+
25+
/// <summary>
26+
/// The cache only loads as a set: prices.json is the gate, and the ids and
27+
/// terminals beside it are read unguarded, so a directory holding item
28+
/// prices alone loads nothing at all.
29+
/// </summary>
30+
private void SeedTheCacheItLoadsAsASet()
31+
{
32+
File.WriteAllText(Path.Combine(_directory, "prices.json"), "{}");
33+
File.WriteAllText(Path.Combine(_directory, "commodity-ids.json"), "{}");
34+
File.WriteAllText(Path.Combine(_directory, "terminals.json"), "[]");
35+
}
36+
37+
private UexData Seeded(string uuid, decimal cheapest, params decimal[] terminals)
38+
{
39+
SeedTheCacheItLoadsAsASet();
40+
41+
File.WriteAllText(
42+
Path.Combine(_directory, "item-prices.json"),
43+
$$"""{"{{uuid}}":{{cheapest}}}""");
44+
45+
var rows = string.Join(",", terminals.Select((b, i) =>
46+
$$"""{"Terminal":"T{{i}}","Buy":{{b}}}"""));
47+
48+
File.WriteAllText(
49+
Path.Combine(_directory, "item-market.json"),
50+
$$"""{"{{uuid}}":[{{rows}}]}""");
51+
52+
return new UexData(_directory);
53+
}
54+
55+
/// <summary>
56+
/// The MaxLift Tractor Beam as this install sees it: stocked near 19,175
57+
/// almost everywhere and at 1,975 in one place. The cheapest understates it
58+
/// tenfold, and a mean would still be pulled down by the odd row.
59+
/// </summary>
60+
[Fact]
61+
public void One_odd_terminal_does_not_move_the_typical_price()
62+
{
63+
var uex = Seeded("beam", 1975, 1975, 19175, 19175, 19175, 19175);
64+
65+
Assert.Equal(1975, uex.ItemPrice("beam"));
66+
Assert.Equal(19175, uex.TypicalItemPrice("beam"));
67+
}
68+
69+
[Fact]
70+
public void An_even_number_of_terminals_takes_the_middle_pair()
71+
{
72+
var uex = Seeded("part", 100, 100, 200, 300, 400);
73+
74+
Assert.Equal(250, uex.TypicalItemPrice("part"));
75+
}
76+
77+
/// <summary>
78+
/// A price with no per-terminal rows behind it is still a price. Falling
79+
/// through to null would drop items over a gap in the market table alone.
80+
/// </summary>
81+
[Fact]
82+
public void A_price_with_no_terminal_rows_falls_back_to_the_cheapest()
83+
{
84+
SeedTheCacheItLoadsAsASet();
85+
File.WriteAllText(Path.Combine(_directory, "item-prices.json"), """{"lonely":4200}""");
86+
87+
Assert.Equal(4200, new UexData(_directory).TypicalItemPrice("lonely"));
88+
}
89+
90+
[Fact]
91+
public void An_item_nothing_stocks_has_no_typical_price()
92+
{
93+
Assert.Null(Seeded("beam", 1975, 1975).TypicalItemPrice("unstocked"));
94+
Assert.Null(Seeded("beam", 1975, 1975).TypicalItemPrice(null));
95+
}
96+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
namespace Quantumwake.WebTests;
2+
3+
/// <summary>
4+
/// What looted gear is worth. The number shown is the median across every
5+
/// terminal stocking it, not the cheapest, because the two differ tenfold on
6+
/// at least one item this install has picked up.
7+
/// </summary>
8+
public class LootPriceTests
9+
{
10+
private const string Pickups = """
11+
[{"at":"2026-08-20T09:00:00+00:00","item":"MaxLift Tractor Beam","itemClass":"maxlift_01",
12+
"place":"Orison","category":"Attachments","price":19175},
13+
{"at":"2026-08-19T09:00:00+00:00","item":"Bantam Hat Orange","itemClass":"987_hat_01",
14+
"place":"Orison","category":"Clothing","price":null}]
15+
""";
16+
17+
private static Page Loaded()
18+
{
19+
var page = new Page();
20+
page.Serve("/api/loot?days=0", Pickups);
21+
page.Do("__dom.node('#loot-period').value = '0'; await loadLoot();");
22+
return page;
23+
}
24+
25+
[Fact]
26+
public void It_shows_what_an_item_typically_costs()
27+
{
28+
Assert.Contains("19,175 aUEC", Loaded().NodeText("#loot-table tbody"));
29+
}
30+
31+
/// <summary>
32+
/// An unstocked item is not a worthless one. A blank cell reads as nothing
33+
/// and a zero as free, so the gap gets a dash and a reason instead.
34+
/// </summary>
35+
[Fact]
36+
public void An_item_nothing_stocks_shows_a_dash_rather_than_a_zero()
37+
{
38+
var page = Loaded();
39+
40+
Assert.Contains("—", page.NodeText("#loot-table tbody"));
41+
Assert.DoesNotContain("0 aUEC", page.NodeText("#loot-table tbody"));
42+
}
43+
44+
/// <summary>
45+
/// The summary says how much of the table carries a price, so a page that
46+
/// is mostly dashes is explained rather than looking broken.
47+
/// </summary>
48+
[Fact]
49+
public void The_summary_says_how_many_carry_a_price()
50+
{
51+
Assert.Contains("1 of 2", Loaded().NodeText("#loot-summary"));
52+
}
53+
}

web/app.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2405,6 +2405,24 @@ function fillLootFilter(select, all, label) {
24052405
return select.value;
24062406
}
24072407

2408+
/**
2409+
* What an item usually costs, or an explanation of why it does not say.
2410+
*
2411+
* A blank cell would read as "worthless" and a zero as "free", and neither is
2412+
* what an unpriced row means: UEX reports 64 of this install's 109 looted item
2413+
* classes, and the rest are simply not sold anywhere it can see. So the gap
2414+
* gets a dash and a reason rather than a number nobody should act on.
2415+
*/
2416+
function lootPriceCell(pickup) {
2417+
if (!(pickup.price > 0)) {
2418+
const td = el('td', 'num muted', '—');
2419+
td.title = 'No terminal UEX knows about stocks this, so it has no price to report.';
2420+
return td;
2421+
}
2422+
2423+
return el('td', 'num', money(pickup.price));
2424+
}
2425+
24082426
function renderLoot(pickups) {
24092427
const term = ($('#loot-search').value || '').trim().toLowerCase();
24102428

@@ -2422,6 +2440,10 @@ function renderLoot(pickups) {
24222440
['New items', rows.length],
24232441
['Last 7 days', rows.filter((p) => Date.now() - new Date(p.at).getTime() < 7 * 86400000).length],
24242442
['Places', new Set(rows.map((p) => p.place)).size],
2443+
2444+
// What share carries a price, rather than a total: these are first
2445+
// sightings, so summing them would value a wardrobe nobody owns twice over.
2446+
['Priced', `${rows.filter((p) => p.price > 0).length} of ${rows.length}`],
24252447
]);
24262448

24272449
const body = $('#loot-table tbody');
@@ -2436,7 +2458,7 @@ function renderLoot(pickups) {
24362458
? `Nothing matching ${[kind, place].filter(Boolean).join(' at ')} in that range.`
24372459
: 'Nothing in that range.');
24382460

2439-
td.colSpan = 4;
2461+
td.colSpan = 5;
24402462
tr.append(td);
24412463
body.append(tr);
24422464
lastLootRows = pickups;
@@ -2448,6 +2470,7 @@ function renderLoot(pickups) {
24482470
tr.append(el('td', null, dateOf(pickup.at)));
24492471
tr.append(el('td', null, prettyItem(pickup.item)));
24502472
tr.append(el('td', 'muted', pickup.category));
2473+
tr.append(lootPriceCell(pickup));
24512474
tr.append(tdPlace(pickup.place, 'muted'));
24522475
body.append(tr);
24532476
}

web/index.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1321,13 +1321,21 @@ <h2>New items</h2>
13211321
than pickups. An item counts once, at its first ever appearance.
13221322
</p>
13231323

1324+
<p class="muted caption">
1325+
Typical price is the median across every terminal UEX reports stocking the
1326+
item &mdash; what it usually goes for, not the cheapest run to make. Items
1327+
UEX does not stock show no price rather than a zero.
1328+
</p>
1329+
13241330
<div class="summary-strip" id="loot-summary"></div>
13251331

13261332
<div class="table-wrap">
13271333
<table id="loot-table">
13281334
<thead>
13291335
<tr>
1330-
<th>First seen</th><th>Item</th><th>Kind</th><th>Where you were</th>
1336+
<th>First seen</th><th>Item</th><th>Kind</th>
1337+
<th title="Median buy price across every terminal stocking it">Typical price</th>
1338+
<th>Where you were</th>
13311339
</tr>
13321340
</thead>
13331341
<tbody></tbody>

0 commit comments

Comments
 (0)