Skip to main content

steamlogger::enrich

src/enrich.rs· 190 lines

Turns raw Steam summaries and A2S replies into the optional fields of a log entry. The matching functions are pure and unit-tested; only analyze performs I/O, and it is infallible by design — failures become empty data, never errors.

use steamlogger::enrich::{self, Enrichment};

Narrative version: Enrichment pipeline.

Types​

Enrichment​

#[derive(Debug, Clone, Default, PartialEq)]
pub struct Enrichment {
pub map: Option<String>,
pub server: Option<String>,
pub friends_playing: Vec<String>,
pub friends_in_lobby: Vec<String>,
}

Everything one tick could discover about an in-progress session.

FieldEmpty valueSource
mapNoneA2S_INFO reply
serverNoneA2S_INFO reply
friends_playing[]same_game_friends
friends_in_lobby[]lobby_friends

Note the asymmetry: the server fields are Option, the friend lists are plain Vec. Tracker::apply_meta is what converts an empty Vec into None for serialisation. Enrichment::default() — all empty — is the value returned whenever a Steam call fails.

Functions​

analyze​

pub fn analyze(
client: &SteamClient,
a2s: &mut A2sClient,
me: u64,
game: &CurrentGame,
) -> Enrichment

Collect all enrichment for a running game. Never returns an error.

let Ok(friends) = client.friends(me) else { return Enrichment::default(); };
let ids: Vec<u64> = friends.iter().map(|f| f.steam_id).collect();
let Ok(summaries) = client.summaries(&ids) else { return Enrichment::default(); };
let (server, map) = server_and_map(a2s, game);
build(&summaries, game, map, server)
StepCallOn failure
1client.friends(me) — GetFriendListreturn Enrichment::default()
2client.summaries(&ids) — batched GetPlayerSummariesreturn Enrichment::default()
3server_and_map(a2s, game)(None, None), other fields kept
4build(...)—

:::note An early failure costs the server fields too Steps 1 and 2 return before the A2S query runs, so a Steam outage also drops map/server for that tick even though the game server might have answered. This keeps the function to a single linear path; the next tick retries everything. :::

Cost: 1 + ceil(friends / 100) HTTPS requests plus at most one UDP round trip (two datagrams if the server issues a challenge).

same_game_friends​

pub fn same_game_friends(summaries: &[PlayerSummary], game: &CurrentGame) -> Vec<String>

Persona names of everyone in summaries whose game_id equals game.app_id — sorted, then de-duplicated.

  • Matching is on AppID, so titles and localisation do not matter.
  • sort() then dedup() means duplicates are fully removed (not just consecutive ones), so two friends sharing a persona name collapse to one.
  • Summaries with no game_id (idle, or private game details) never match.
  • The tracked account is not in its own friend list, so you never appear here.
// zed and alpha are both in app 440; bravo is in 730; delta is idle
assert_eq!(same_game_friends(&summaries, &game), vec!["alpha", "zed"]);

lobby_friends​

pub fn lobby_friends(summaries: &[PlayerSummary], game: &CurrentGame) -> Vec<String>

Persona names of everyone whose lobby_steam_id equals the tracked player's — sorted and de-duplicated.

let Some(lobby) = game.lobby_steam_id.as_deref() else { return Vec::new(); };
  • Returns an empty vector immediately when you are not in a Steam lobby.
  • Exact string comparison on the opaque lobbysteamid value.
  • Not filtered by game: a shared lobby ID already implies the same game.
  • Games that do not use Steam matchmaking never report a lobby, so this stays empty for them — see the limits.

server_and_map​

pub fn server_and_map(a2s: &mut A2sClient, game: &CurrentGame) -> (Option<String>, Option<String>)

Resolve the game server Steam reported into (server_name, map).

ConditionResult
game.gameserver_ip is None(None, None) — no query is sent
Query succeeds(Some(info.name), Some(info.map))
Query fails (timeout, unreachable, malformed, repeated challenge)(None, None) — the error is discarded

:::caution Mind the tuple order The return value is (server, map), while Tracker::apply_meta takes map first. The call site in analyze destructures accordingly:

let (server, map) = server_and_map(a2s, game);
build(&summaries, game, map, server)

:::

build (private)​

fn build(
summaries: &[PlayerSummary],
game: &CurrentGame,
map: Option<String>,
server: Option<String>,
) -> Enrichment

Assembles the struct by calling the two matching functions. Kept separate from analyze so the assembly logic can be unit-tested without any client — see build_assembles_enrichment.

Tests​

6 unit tests in src/enrich.rs, plus enrich_same_lobby_from_fixture in the integration suite (which runs the matchers over the recorded player_summaries.json fixture).

TestAsserts
no_friends_plays_nothingEmpty input → empty output for both matchers
same_game_friends_filters_sorts_dedupesFiltering by AppID, alphabetical order, duplicates removed
lobby_friends_matches_same_lobbyOnly exact lobby matches, sorted and deduped
lobby_friends_ignored_when_no_lobbyNo lobby for the player → empty
no_gameserver_ip_yields_no_server_or_mapNo address → no UDP query
build_assembles_enrichmentFull Enrichment assembled from parts
enrich_same_lobby_from_fixture (integration)Real-shaped payload yields ["medicmain99"] for both lists

See also​