Skip to content

feat: vending machine search, undercut tracking, and sell-out alerts - #79

Merged
HandyS11 merged 30 commits into
developfrom
feat/vending-tracking
Aug 18, 2026
Merged

feat: vending machine search, undercut tracking, and sell-out alerts#79
HandyS11 merged 30 commits into
developfrom
feat/vending-tracking

Conversation

@HandyS11

Copy link
Copy Markdown
Owner

Lets players search every player vending machine on a server, register their own shop, and get told in Discord when a rival undercuts them or when their own listings run dry.

Commands

In-gameSlashDoes
!vending <item>/vending <item>Who sells it — in stock first, then cheapest per item
!vtrack <grid>Register a grid cell; every machine in it counts as yours
!vuntrack <grid>/vending-untrackStop tracking
!vtracked/vending-trackedWhat you have registered
/vending-track <item> <price> [currency] [quantity] [blueprint]Register a listing by hand

Notifications land in a new read-only per-server #vending channel.

How it works

Vending data rides along on the existing 5-second GetMapMarkers poll, so there is no new Rust+ request — the bot was already receiving every machine's full offer list and discarding it. The supervisor publishes a VendingMachinesObservedEvent; Features.Vending keeps a wholesale-replaced in-memory index per server, and two pure evaluators turn (index + tracks) into a desired set of notifications. A relay does nothing but reconcile that set against persisted Discord message ids.

Only track registrations and posted message ids touch SQLite (migration VendingTracking, four additive tables, cascade-deleted with their server).

Putting all the logic that must be correct into pure functions means it is tested with no Discord, no database and no socket.

Decisions worth knowing

  • Same-currency only, compared per item. Rust shops price in arbitrary items, and cross-currency rates drift every wipe. "2 for 10 scrap" (5 each) undercuts "1 for 6 scrap" — compared by exact integer cross-multiplication, never division, so rounding can't decide whether you've been undercut.
  • Your reference price is your cheapest listing. Sell at 5 and 6, and a rival at 6 hasn't beaten you.
  • Matching your price counts as undercutting; a sold-out rival does not (but still shows in search).
  • !vtrack binds a grid cell, not machine ids, so a machine you deploy later is picked up automatically. The trade-off: a neighbour building in that cell counts as yours.
  • Your action clears a message; the world's changes only edit it. A rival appearing edits the alert; you repricing deletes it and reposts fresh, so reacting produces a new unread instead of a silent edit nobody notices. Same rule for sell-outs: another item running dry edits, your restock deletes.
  • A disconnect freezes notifications rather than deleting them — a dropped socket must never read as "every rival vanished".
  • On wipe, grid registrations are purged and messages deleted, but manual listings are kept — an item and a price stay valid next wipe.

Testing

1315 → 1388 tests, all passing. Coverage concentrates on the evaluators, where the rules live: currency isolation, blueprint-vs-item distinctness, exact rational comparison, cheapest-reference selection, sold-out exclusion, and every delete-vs-edit transition.

Incidental fixes

  • HelpEmbedRenderer split its slash-command section by group. The single flat field was unbounded and the French rendering hit 1097 characters against Discord's 1024 limit once these commands were added.
  • ServerResolver/ServerResolution became public so a module outside Features.Connections can resolve a server; the autocomplete handler feeding that parameter was already public.

🤖 Generated with Claude Code

HandyS11and others added 29 commits August 17, 2026 21:47
Spec for vending search (/vending, !vending), undercut tracking
(/vending-track, !vtrack by grid cell), and sell-out alerts for
registered machines, all posted to a new per-server #vending channel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 TDD tasks from abstractions through commands, each leaving a green
build: marker-poll data path, pure grid/search/undercut/stock logic,
persistence + migration, #vending channel, notification relay, wipe
purge, and both command surfaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… results
Review found the ! in TryGet suppressed nothing once [MaybeNullWhen(false)]
was added (ConcurrentDictionary's own TryGetValue is already so-annotated),
and that no test proved Search keeps sold-out offers rather than filtering
them.
Bumps the localization catalog's expected key-count literal (395 -> 396)
alongside the new channel.vending.name string; the resx-parity tests
already guard cross-file consistency, this literal just needed its
designed maintenance bump for the deliberate addition.
ConsumeAsync rethrows on real cancellation and nothing caught an exception
from the await-foreach enumeration itself, so either case faulted the
Task.Run task with no log line, leaving the feature dead until the host
restarted. Wrap each of the three loops in its own try/catch, mirroring
AlarmsHostedService, with a distinct LoggerMessage per loop.
Inject IItemNameResolver into VTrackedCommandHandler so manually tracked
listings render resolved item and currency names instead of raw Rust item
ids, matching how VendingLine and VendingEmbedRenderer already format
prices. Adds a pinning test asserting resolved names appear and raw ids
do not.
Manual listing tracking previously hardcoded ItemIsBlueprint=false, so a
player selling a blueprint had no way to register what they actually sell
and would end up watching the wrong ListingKey. CurrencyIsBlueprint stays
hardcoded false — paying in blueprints is rare enough that a second option
would add clutter for a case almost nobody has; the grid-registration path
still handles it correctly since it reads the flag off the real machine.
/vending-tracked and /vending-untrack's autocomplete both rendered a
listing's item name with no blueprint indicator, so a plain item and its
blueprint tracked at the same price/quantity looked like identical rows —
the encoded choice values differed under the hood, but a player could not
tell which was which and could untrack the wrong one. Add
vending.listing.blueprint ("Blueprint: {0}") and apply it to the item side
(never the currency side, which is never a blueprint by prior ruling) in
both display paths.
…arking
Whole-branch review findings, applied as one wave.
Crashes:
- RenderUndercut joined every undercutter into the embed description, which
Discord rejects past 4096 characters. The throw escaped ReconcileUndercutsAsync
and, since that is awaited before ReconcileStockAsync, killed both kinds of
vending notification for the server on every poll. Cap at 10 rows via the
existing VendingSearch.Take and append the localized "+N more". RenderStock is
capped the same way; RenderSearch is left alone because its caller already
truncated and passes the omitted count in.
- /vending-tracked joined every listing into one embed field, which Discord
rejects past 1024 characters — about 27 listings, which is the workflow the
feature exists for. Truncate to fit and say how many were omitted, following
ClanRosterMessageRenderer. Applied to both the Listings and Grids fields.
Blueprint marking reached only two of five surfaces, so a rival selling the
blueprint of a Metal Pipe at 5 scrap rendered identically to one selling the
item at 40. Consolidate the rule into ListingDisplay.MarkBlueprint in
Abstractions.Vending and call it from all five. This adds an Abstractions ->
Localization project reference; Localization is a dependency-free leaf, so no
cycle is possible.
Notification churn: EnsureAsync returns the existing id even when the render
gate suppresses the edit, so the relay re-upserted every live notice every five
seconds and PostedUtc permanently read "just now". The relay now skips the call
when nothing changed, and the store sets PostedUtc only on a genuine (re)post.
Reference comparison moved to UnitPrice.Compare, so an equal-unit-price flip or
a cosmetic repackage no longer reads as an owner reprice.
Also: drop a redundant re-sort of an already-ordered search result, localize
!vtracked's hardcoded " for ", list the Vending section in appsettings.json and
reject a non-positive MaxNotificationsPerServer at startup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ency status
Abstractions is the codebase's lowest layer with 19 dependents and a
deliberate no-project-references rule. The last fix wave broke that to
carry ListingDisplay's localized "Blueprint: {0}" marker, adding
Abstractions -> Localization.
Relocate ListingDisplay to Features.ItemData.Naming instead: it already
references both Abstractions and Localization, and both consumers
(Features.Vending, Features.Commands) already reference it, so every
call site is reached with no new project references at all.
The pre-push hook reformatted these files; committing so the pushed
branch matches what the hook enforces. Whitespace and line-wrapping
only, no behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 18, 2026 15:51

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new “Vending” feature slice that indexes player vending machines from the existing GetMapMarkers poll, adds search + tracking commands, persists tracking state and notification message IDs, and posts undercut / sell-out notifications into a new per-server read-only #vending channel.

Changes:

  • Extend the marker poll pipeline to surface vending machine data and publish VendingMachinesObservedEvent every poll.
  • Add Features.Vending (indexing, evaluators, relay, wipe purger, commands/modules) plus persistence entities/store + EF migration.
  • Add localization/help/README/spec updates and comprehensive unit tests for the pure evaluators and supporting logic.

Reviewed changes

Copilot reviewed 90 out of 92 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/RustPlusBot.Persistence.Tests/VendingStoreTests.csAdds persistence tests for vending grid/listing store behavior.
tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.csUpdates expected resource key count after adding vending strings.
tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerChannelSpecTests.csEnsures the new per-server vending channel key is included in spec order.
tests/RustPlusBot.Features.Vending.Tests/VendingWipePurgerTests.csTests wipe behavior for vending notifications and tracked state.
tests/RustPlusBot.Features.Vending.Tests/VendingTrackedFieldTests.csTests embed-field truncation helper for /vending-tracked.
tests/RustPlusBot.Features.Vending.Tests/VendingSearchTests.csTests ordering and truncation behavior for vending search results.
tests/RustPlusBot.Features.Vending.Tests/VendingRegistrationTests.csVerifies DI registration for the Vending feature slice.
tests/RustPlusBot.Features.Vending.Tests/VendingIndexTests.csTests in-memory vending index replace/clear/search behavior.
tests/RustPlusBot.Features.Vending.Tests/VendingEmbedRendererTests.csTests embed rendering and Discord length caps for vending notices/search.
tests/RustPlusBot.Features.Vending.Tests/UndercutEvaluatorTests.csTests pure undercut evaluation rules (currency isolation, unit-price, etc.).
tests/RustPlusBot.Features.Vending.Tests/StockEvaluatorTests.csTests pure sell-out evaluation rules and signatures.
tests/RustPlusBot.Features.Vending.Tests/RustPlusBot.Features.Vending.Tests.csprojAdds the new test project for the Vending feature.
tests/RustPlusBot.Features.Vending.Tests/GridOwnershipTests.csTests grid ownership and offer projection behavior.
tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.csUpdates test fake to return MapMarkersSnapshot including vending machines.
tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.csAdds coverage ensuring vending machines are published on each poll.
tests/RustPlusBot.Features.Commands.Tests/VendingCommandHandlerTests.csAdds tests for new in-game vending-related command handlers.
tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.csUpdates registered command handler-name expectations.
tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.csUpdates DI tests for new vending handlers/services.
tests/RustPlusBot.Abstractions.Tests/UnitPriceTests.csAdds tests for exact rational unit-price comparisons.
src/RustPlusBot.Persistence/Vending/VendingStore.csImplements EF-backed persistence for vending tracking + live notifications.
src/RustPlusBot.Persistence/Vending/IVendingStore.csDefines persistence contract for vending tracking + notification rows.
src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.csRegisters IVendingStore in persistence DI.
src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.csUpdates EF model snapshot for new vending tables/entities.
src/RustPlusBot.Persistence/Migrations/20260817210009_VendingTracking.csAdds migration creating vending tracking + notification tables.
src/RustPlusBot.Persistence/Configurations/VendingStockNotificationConfiguration.csEF config for sell-out notification rows.
src/RustPlusBot.Persistence/Configurations/VendingNotificationConfiguration.csEF config for undercut notification rows.
src/RustPlusBot.Persistence/Configurations/VendingListingTrackConfiguration.csEF config for manually tracked listing rows.
src/RustPlusBot.Persistence/Configurations/VendingGridTrackConfiguration.csEF config for grid track rows.
src/RustPlusBot.Persistence/BotDbContext.csAdds DbSets + applies vending EF configurations.
src/RustPlusBot.Localization/Strings.resxAdds English resource strings for vending commands/embeds/channel name.
src/RustPlusBot.Localization/Strings.fr.resxAdds French resource strings for vending commands/embeds/channel name.
src/RustPlusBot.Host/RustPlusBot.Host.csprojAdds reference to the new Vending feature project.
src/RustPlusBot.Host/Program.csWires Vending options + registers the Vending feature in the host.
src/RustPlusBot.Host/appsettings.jsonAdds Vending:MaxNotificationsPerServer configuration.
src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.csRegisters IVendingChannelLocator implementation.
src/RustPlusBot.Features.Workspace/WorkspaceKeys.csAdds workspace channel key for per-server #vending.
src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.csProvisions new per-server read-only #vending channel.
src/RustPlusBot.Features.Workspace/Locating/VendingChannelLocator.csAdds cached channel locator for the #vending channel.
src/RustPlusBot.Features.Workspace/Locating/IVendingChannelLocator.csDefines interface for resolving #vending channel id.
src/RustPlusBot.Features.Vending/VendingServiceCollectionExtensions.csRegisters Vending feature services + interaction module assembly.
src/RustPlusBot.Features.Vending/VendingOptions.csAdds options for per-server notification caps.
src/RustPlusBot.Features.Vending/Tracking/VendingTrackService.csImplements grid + manual listing tracking logic over store + index.
src/RustPlusBot.Features.Vending/Searching/VendingSearch.csProvides pure ordering/truncation helpers for offers.
src/RustPlusBot.Features.Vending/RustPlusBot.Features.Vending.csprojIntroduces new Vending feature project and references.
src/RustPlusBot.Features.Vending/Rendering/VendingEmbedRenderer.csRenders vending search + notices into Discord embeds with size limits.
src/RustPlusBot.Features.Vending/Relaying/VendingWipePurger.csPurges vending state/messages on wipe events.
src/RustPlusBot.Features.Vending/Relaying/VendingNotificationRelay.csReplaces index + reconciles desired notices against posted/persisted state.
src/RustPlusBot.Features.Vending/Posting/IVendingChannelPoster.csAbstraction for posting/editing/deleting vending embeds.
src/RustPlusBot.Features.Vending/Posting/DiscordVendingChannelPoster.csDiscord implementation for ensuring/deleting vending messages.
src/RustPlusBot.Features.Vending/Ownership/GridOwnership.csDefines ownership-by-grid and offer projection from snapshots.
src/RustPlusBot.Features.Vending/Modules/VendingUntrackAutocompleteHandler.csAdds autocomplete for /vending-untrack over grids + manual listings.
src/RustPlusBot.Features.Vending/Modules/VendingModule.csAdds slash commands: /vending, /vending-track, /vending-untrack, /vending-tracked.
src/RustPlusBot.Features.Vending/Indexing/VendingIndex.csImplements in-memory per-(guild,server) vending index + read model.
src/RustPlusBot.Features.Vending/Hosting/VendingHostedService.csAdds hosted service to consume vending-related events.
src/RustPlusBot.Features.Vending/Evaluating/UndercutNotice.csDefines undercut notice record type.
src/RustPlusBot.Features.Vending/Evaluating/UndercutEvaluator.csImplements pure undercut evaluation rules.
src/RustPlusBot.Features.Vending/Evaluating/StockNotice.csDefines stock/sell-out notice record type + signature.
src/RustPlusBot.Features.Vending/Evaluating/StockEvaluator.csImplements pure sell-out evaluation rules.
src/RustPlusBot.Features.ItemData/Naming/ListingDisplay.csCentralizes blueprint-listing display marker logic.
src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.csPublishes vending observed events from marker poll results.
src/RustPlusBot.Features.Connections/Servers/ServerResolver.csMakes resolver public for use outside Connections slice.
src/RustPlusBot.Features.Connections/Servers/ServerResolution.csMakes resolution record public for broader reuse.
src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.csMaps vending machine markers into snapshots and returns MapMarkersSnapshot.
src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.csChanges marker poll return type to MapMarkersSnapshot.
src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.csSplits slash-command help field by group to respect Discord limits.
src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.csRegisters vending commands in help catalog (in-game + slash).
src/RustPlusBot.Features.Commands/Help/CommandGroup.csAdds new help command group for Vending.
src/RustPlusBot.Features.Commands/Handlers/VUntrackCommandHandler.csAdds !vuntrack handler for grid untracking.
src/RustPlusBot.Features.Commands/Handlers/VTrackedCommandHandler.csAdds !vtracked handler to list tracked grids/listings.
src/RustPlusBot.Features.Commands/Handlers/VTrackCommandHandler.csAdds !vtrack handler to register owned grid cells.
src/RustPlusBot.Features.Commands/Handlers/VendingCommandHandler.csAdds !vending handler using the live vending read model.
src/RustPlusBot.Features.Commands/Formatting/VendingLine.csAdds formatting helper for in-game vending offer lines.
src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.csRegisters new vending command handlers.
src/RustPlusBot.Domain/Vending/VendingStockNotification.csAdds domain entity for sell-out notifications.
src/RustPlusBot.Domain/Vending/VendingNotification.csAdds domain entity for undercut notifications.
src/RustPlusBot.Domain/Vending/VendingListingTrack.csAdds domain entity for manually tracked listings.
src/RustPlusBot.Domain/Vending/VendingGridTrack.csAdds domain entity for tracked grid cells.
src/RustPlusBot.Abstractions/Vending/VendingOffer.csAdds vending offer model for read/display/compare.
src/RustPlusBot.Abstractions/Vending/UnitPrice.csAdds exact unit-price comparisons via cross-multiplication.
src/RustPlusBot.Abstractions/Vending/ListingKey.csAdds listing identity type (item/currency + blueprint flags).
src/RustPlusBot.Abstractions/Vending/IVendingTrackService.csAdds tracking service abstraction + DTOs for tracked state.
src/RustPlusBot.Abstractions/Vending/IVendingReadModel.csAdds vending read-model abstraction for search + connection status.
src/RustPlusBot.Abstractions/Events/VendingMachinesObservedEvent.csAdds event published on each marker poll with full vending set.
src/RustPlusBot.Abstractions/Connections/VendingOfferSnapshot.csAdds raw vending-offer snapshot from Rust+ marker payload.
src/RustPlusBot.Abstractions/Connections/VendingMachineSnapshot.csAdds raw vending-machine snapshot from Rust+ marker payload.
src/RustPlusBot.Abstractions/Connections/MapMarkersSnapshot.csAdds marker poll result wrapper including vending machines.
RustPlusBot.slnxAdds Vending feature + tests projects to the solution.
README.mdDocuments the new vending capabilities and commands.
docs/superpowers/specs/2026-08-17-vending-machine-tracking-design.mdAdds design/spec document for the vending feature.
Files not reviewed (1)
  • src/RustPlusBot.Persistence/Migrations/20260817210009_VendingTracking.Designer.cs: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +42 to +50
context.VendingGridTracks.Add(new VendingGridTrack
{
GuildId = guildId,
ServerId = serverId,
Grid = normalized,
RegisteredBySteamId = steamId,
CreatedUtc = clock.UtcNow,
});
await context.SaveChangesAsync(ct).ConfigureAwait(false);
Comment on lines +18 to +22
public static string GridOf(VendingMachineSnapshot machine, uint worldSize, MapGridStyle style)
{
ArgumentNullException.ThrowIfNull(machine);
return MapGrid.LabelFor(machine.X, machine.Y, worldSize, style);
}
Comment on lines +39 to +42
public int Compare(VendingOffer? x, VendingOffer? y) =>
x is null || y is null
? 0
: UnitPrice.Compare(x.CostPerOrder, x.Quantity, y.CostPerOrder, y.Quantity);
Comment on lines +83 to +86
private static string DisplayName(TrackedListing listing, IItemDatabase items, ILocalizer loc, string culture) =>
string.Create(CultureInfo.InvariantCulture,
$"{listing.Quantity} x {ItemDisplayName(items, loc, culture, listing.Key.ItemId, listing.Key.ItemIsBlueprint)} for " +
$"{listing.CostPerOrder} {ItemName(items, listing.Key.CurrencyId)}");
- VendingStore.AddGridAsync recovers from a concurrent duplicate insert
(unique index race) by re-querying instead of letting DbUpdateException
escape from an operation documented as idempotent.
- GridOwnership.GridOf returns a new GridOwnership.UnknownGrid ("?")
constant instead of a computed "A0" when world size is unknown, so
search surfaces stop lying about machine locations.
- VendingSearch's UnitPriceComparer is now a total order: null no longer
compares equal to a non-null offer.
- VendingUntrackAutocompleteHandler's listing label now renders through
the shared command.vtracked.listing resource key instead of
interpolating literal " x "/" for ", matching !vtracked's output for
French guilds.
Adds a test for each of the above; the concurrency fix is covered against
the real SQLite fixture by injecting a conflicting insert via
DbContext.SavingChanges, deterministically simulating the race.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HandyS11
HandyS11 merged commit 420c174 into developAug 18, 2026
3 checks passed
@HandyS11
HandyS11 deleted the feat/vending-tracking branch August 18, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@HandyS11