Skip to content

Commit 8e535c5

Browse files
authored
Merge pull request #39 from peans99/release/0.8.35
0.8.35 - retrievals recorded again, who is with you, and what loot is worth
2 parents 034a790 + 5249f7c commit 8e535c5

17 files changed

Lines changed: 583 additions & 14 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.32</Version>
14-
<AssemblyVersion>0.8.32.0</AssemblyVersion>
15-
<FileVersion>0.8.32.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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,31 @@ 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.35
388+
389+
- Ship retrievals are recorded again. Game build 12519617 stopped writing the
390+
log line that confirmed a retrieved ship had reached the pad, so retrievals
391+
on the current build went uncounted — the ship shown for a session stayed
392+
empty and sortie totals stopped rising. Quantum Wake now reads the request
393+
line the game still writes. Existing sessions are re-read on upgrade, so the
394+
history fills itself back in.
395+
396+
- The Now page says who is with you. A Party card lists everyone the party
397+
channel has named this session and the last thing it said about each of
398+
them, so a member who dropped is not left looking like one still aboard. It
399+
is a floor rather than a roster, and says so: somebody already online when
400+
you grouped up is never announced, and the card stays away entirely rather
401+
than showing a zero it cannot stand behind. It can be switched on in the
402+
overlay widget too.
403+
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+
387412
### 0.8.32
388413

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

src/Quantumwake.Core/Parsing/LogEventParser.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,9 @@ private static string Truncate(string value) =>
248248
m.Groups["flags"].Success
249249
&& m.Groups["flags"].Value.Contains("ShowInLog", StringComparison.Ordinal))),
250250

251+
"CEntityComponentShipListProvider::SetVehicleSpawningInformations" or
251252
"CEntityComponentShipListProvider::SetVehicleSpawnedInformations" =>
252-
Match(VehicleSpawnRegex, line, m =>
253-
new VehicleSpawnEvent(
254-
line.Timestamp,
255-
m.Groups["entity"].Value,
256-
m.Groups["area"].Success ? m.Groups["area"].Value.Trim() : null)),
253+
ParseVehicleSpawn(line),
257254

258255
"AttachmentReceived" => Match(AttachmentRegex, line, m =>
259256
new AttachmentEvent(
@@ -565,6 +562,36 @@ internal static (string? Manufacturer, string Model) SplitVehicleId(string vehic
565562
return m.Success ? project(m) : null;
566563
}
567564

565+
/// <summary>
566+
/// A ship being retrieved, from either spelling of the line.
567+
/// </summary>
568+
/// <remarks>
569+
/// Both spellings mark the same retrieval: "Spawning" when the request goes
570+
/// in, "Spawned" a few seconds later once the ship is on the pad. Build
571+
/// 12519617 stopped emitting "Spawned" at all, so reading only that one lost
572+
/// every retrieval on current builds - and silently, because the tag simply
573+
/// stopped appearing rather than failing to parse. The timestamp is
574+
/// therefore the request, not the arrival, which is all the current build
575+
/// offers. <see cref="SessionBuilder"/> collapses the pair by entity id, so
576+
/// reading both does not double-count a retrieval on older logs.
577+
///
578+
/// The ASOP terminal also emits an [Error] twin of the request line when it
579+
/// cannot resolve the landing area name. It repeats an entity id already
580+
/// being retrieved on the line beside it, so it is not a second retrieval
581+
/// and must not count as a parse failure either.
582+
/// </remarks>
583+
private GameEvent? ParseVehicleSpawn(LogLine line)
584+
{
585+
if (line.Body.Contains("Invalid landingAreaLocStr", StringComparison.Ordinal))
586+
return null;
587+
588+
return Match(VehicleSpawnRegex, line, m =>
589+
new VehicleSpawnEvent(
590+
line.Timestamp,
591+
m.Groups["entity"].Value,
592+
m.Groups["area"].Success ? m.Groups["area"].Value.Trim() : null));
593+
}
594+
568595
/// <summary>
569596
/// Fleet queries log twice: an opening "Fetching vehicle list…" line with no
570597
/// counts, then a completion line carrying them. Only the latter is an event;

src/Quantumwake.Core/State/Party.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,32 @@ public sealed record PartyNote(DateTimeOffset At, string? Handle, PartyMoment Mo
8888
/// </remarks>
8989
public static class Party
9090
{
91+
/// <summary>
92+
/// The latest note about each named player, most recent first.
93+
/// </summary>
94+
/// <remarks>
95+
/// Latest-wins rather than a tally, because the moments are not commutative:
96+
/// somebody who connected, dropped and connected again is present, and
97+
/// counting arrivals against departures would call that a draw. Disbanded
98+
/// names nobody, so it is dropped here and asked about separately.
99+
///
100+
/// The result is a floor and every caller has to word it as one. A member
101+
/// already online when the party formed, who never drops, is never the
102+
/// subject of a toast - so absence from this list says nothing at all.
103+
/// </remarks>
104+
public static IReadOnlyList<PartyNote> Latest(IReadOnlyList<PartyNote> notes)
105+
{
106+
var latest = new Dictionary<string, PartyNote>(StringComparer.Ordinal);
107+
108+
foreach (var note in notes)
109+
{
110+
if (note.Handle is { Length: > 0 } handle)
111+
latest[handle] = note;
112+
}
113+
114+
return [.. latest.Values.OrderByDescending(note => note.At)];
115+
}
116+
91117
/// <summary>True when a notification came from the party channel at all.</summary>
92118
/// <remarks>
93119
/// Both shared titles are asked about their body rather than taken on their

src/Quantumwake.Data/OverlayLayout.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public sealed record OverlayLayout(
2828

2929
/// <summary>The Now page's cards, by their data-card name.</summary>
3030
public static readonly IReadOnlyList<string> SelectableCards =
31-
["location", "briefing", "ship", "session", "handle", "feed", "stats", "respawn", "job", "checklist", "trip", "trade"];
31+
["location", "briefing", "ship", "session", "handle", "feed", "stats", "party", "respawn", "job", "checklist", "trip", "trade"];
3232

3333
public static OverlayLayout Default => new(
3434
["now", "map", "commodities", "market", "loadout", "stash"],

src/Quantumwake.Data/SessionStore.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ payload TEXT NOT NULL
146146
// 7: ship comms channels are kept, so sessions summarised before them know
147147
// nobody was ever aboard anything and the Crew page's ships would be
148148
// empty for every install except a brand new one.
149-
private const int PayloadVersion = 7;
149+
private const int PayloadVersion = 8;
150150

151151

152152
/// <summary>

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/LiveSessionService.cs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,28 @@ public sealed record NowState
3636
public int Kills { get; init; }
3737

3838
public IReadOnlyList<TimelineEntry> RecentEvents { get; init; } = [];
39+
40+
/// <summary>
41+
/// Everyone the party channel has named this session, most recent first.
42+
/// </summary>
43+
/// <remarks>
44+
/// Not a roster, and the view must not present it as one. A party member who
45+
/// was already online when you grouped up and never dropped produces no
46+
/// toast at all, so this is a floor: everyone here was mentioned, and being
47+
/// absent from it means nothing either way.
48+
/// </remarks>
49+
public IReadOnlyList<PartySighting> Party { get; init; } = [];
50+
51+
/// <summary>True once a "Party Disbanded" toast has been seen this session.</summary>
52+
public bool PartyDisbanded { get; init; }
3953
}
4054

55+
/// <summary>The last thing the party channel said about one player.</summary>
56+
/// <param name="Moment">
57+
/// The <see cref="PartyMoment"/> name, lowercased for display.
58+
/// </param>
59+
public sealed record PartySighting(string Handle, string Moment, DateTimeOffset At);
60+
4161
/// <summary>SignalR hub clients subscribe to for live updates.</summary>
4262
public sealed class LiveHub : Hub
4363
{
@@ -208,10 +228,23 @@ private NowState Snapshot()
208228
Incapacitations = summary.Incapacitations,
209229
Deaths = summary.Deaths,
210230
Kills = summary.Kills,
211-
RecentEvents = [.. _recent]
231+
RecentEvents = [.. _recent],
232+
Party = ReadParty(summary.PartyNotes),
233+
PartyDisbanded = summary.PartyNotes.Count > 0
234+
&& summary.PartyNotes[^1].Moment == PartyMoment.Disbanded
212235
};
213236
}
214237

238+
/// <summary>
239+
/// The party channel's latest word on each player, shaped for the client.
240+
/// </summary>
241+
private static IReadOnlyList<PartySighting> ReadParty(IReadOnlyList<PartyNote> notes) =>
242+
[.. Party.Latest(notes)
243+
.Select(note => new PartySighting(
244+
note.Handle!,
245+
note.Moment.ToString().ToLowerInvariant(),
246+
note.At))];
247+
215248
private async Task BroadcastAsync()
216249
{
217250
NowState snapshot;

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

tests/Quantumwake.Tests/LogEventParserTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,61 @@ public void Extracts_client_spawned()
301301
ParseOne<ClientSpawnedEvent>("<2026-08-20T01:28:58.254Z> [CSessionManager::OnClientSpawned] Spawned!");
302302
}
303303

304+
/// <summary>
305+
/// The retrieval line the game writes today. Build 12519617 stopped emitting
306+
/// the "Spawned" confirmation, so this spelling is the only one left - and
307+
/// reading only the other one lost every retrieval on current builds without
308+
/// registering as a parse failure.
309+
/// </summary>
310+
[Fact]
311+
public void Extracts_retrieval_from_the_spawning_line()
312+
{
313+
var ev = ParseOne<VehicleSpawnEvent>(
314+
"<2026-08-27T14:00:41.291Z> [Notice] " +
315+
"<CEntityComponentShipListProvider::SetVehicleSpawningInformations> " +
316+
"SetVehicleSpawningInformations - VehicleEntityId: [787284778374], LandingArea: nekron's");
317+
318+
Assert.Equal("787284778374", ev.EntityId);
319+
Assert.Equal("nekron's", ev.LandingArea);
320+
}
321+
322+
/// <summary>
323+
/// The ASOP terminal emits an [Error] twin beside the real request when it
324+
/// cannot resolve the landing area name. It names an entity already being
325+
/// retrieved, so it is neither a retrieval nor a parse failure.
326+
/// </summary>
327+
[Fact]
328+
public void Ignores_the_invalid_landing_area_twin()
329+
{
330+
Assert.True(LogEnvelope.TryParse(
331+
"<2026-07-26T19:37:55.992Z> [Error] " +
332+
"<CEntityComponentShipListProvider::SetVehicleSpawningInformations> " +
333+
"SetVehicleSpawningInformations - Invalid landingAreaLocStr - " +
334+
"Entity id: 738680164755 [Team_GameServices][ASOP]", out var line));
335+
336+
var parser = new LogEventParser();
337+
338+
Assert.Null(parser.Parse(line));
339+
Assert.Equal(0, parser.UnmatchedKnownTags);
340+
}
341+
342+
/// <summary>
343+
/// The older confirmation line, still present in archived logs. Its extra
344+
/// LandingATCId field sits between the id and the landing area.
345+
/// </summary>
346+
[Fact]
347+
public void Extracts_retrieval_from_the_spawned_line()
348+
{
349+
var ev = ParseOne<VehicleSpawnEvent>(
350+
"<2026-08-24T01:34:39.741Z> [Notice] " +
351+
"<CEntityComponentShipListProvider::SetVehicleSpawnedInformations> " +
352+
"SetVehicleSpawnedInformations - VehicleEntityId: [774736075446], " +
353+
"LandingATCId: [746997539721], LandingArea: nekron's");
354+
355+
Assert.Equal("774736075446", ev.EntityId);
356+
Assert.Equal("nekron's", ev.LandingArea);
357+
}
358+
304359
/// <summary>
305360
/// The session header spans several lines and only completes at FileVersion,
306361
/// so the parser must hold state across them.

0 commit comments

Comments
 (0)