feat: vending machine search, undercut tracking, and sell-out alerts - #79
Merged
Conversation
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>
There was a problem hiding this comment.
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
VendingMachinesObservedEventevery 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
| File | Description |
|---|---|
| tests/RustPlusBot.Persistence.Tests/VendingStoreTests.cs | Adds persistence tests for vending grid/listing store behavior. |
| tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs | Updates expected resource key count after adding vending strings. |
| tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerChannelSpecTests.cs | Ensures the new per-server vending channel key is included in spec order. |
| tests/RustPlusBot.Features.Vending.Tests/VendingWipePurgerTests.cs | Tests wipe behavior for vending notifications and tracked state. |
| tests/RustPlusBot.Features.Vending.Tests/VendingTrackedFieldTests.cs | Tests embed-field truncation helper for /vending-tracked. |
| tests/RustPlusBot.Features.Vending.Tests/VendingSearchTests.cs | Tests ordering and truncation behavior for vending search results. |
| tests/RustPlusBot.Features.Vending.Tests/VendingRegistrationTests.cs | Verifies DI registration for the Vending feature slice. |
| tests/RustPlusBot.Features.Vending.Tests/VendingIndexTests.cs | Tests in-memory vending index replace/clear/search behavior. |
| tests/RustPlusBot.Features.Vending.Tests/VendingEmbedRendererTests.cs | Tests embed rendering and Discord length caps for vending notices/search. |
| tests/RustPlusBot.Features.Vending.Tests/UndercutEvaluatorTests.cs | Tests pure undercut evaluation rules (currency isolation, unit-price, etc.). |
| tests/RustPlusBot.Features.Vending.Tests/StockEvaluatorTests.cs | Tests pure sell-out evaluation rules and signatures. |
| tests/RustPlusBot.Features.Vending.Tests/RustPlusBot.Features.Vending.Tests.csproj | Adds the new test project for the Vending feature. |
| tests/RustPlusBot.Features.Vending.Tests/GridOwnershipTests.cs | Tests grid ownership and offer projection behavior. |
| tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs | Updates test fake to return MapMarkersSnapshot including vending machines. |
| tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs | Adds coverage ensuring vending machines are published on each poll. |
| tests/RustPlusBot.Features.Commands.Tests/VendingCommandHandlerTests.cs | Adds tests for new in-game vending-related command handlers. |
| tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs | Updates registered command handler-name expectations. |
| tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs | Updates DI tests for new vending handlers/services. |
| tests/RustPlusBot.Abstractions.Tests/UnitPriceTests.cs | Adds tests for exact rational unit-price comparisons. |
| src/RustPlusBot.Persistence/Vending/VendingStore.cs | Implements EF-backed persistence for vending tracking + live notifications. |
| src/RustPlusBot.Persistence/Vending/IVendingStore.cs | Defines persistence contract for vending tracking + notification rows. |
| src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs | Registers IVendingStore in persistence DI. |
| src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs | Updates EF model snapshot for new vending tables/entities. |
| src/RustPlusBot.Persistence/Migrations/20260817210009_VendingTracking.cs | Adds migration creating vending tracking + notification tables. |
| src/RustPlusBot.Persistence/Configurations/VendingStockNotificationConfiguration.cs | EF config for sell-out notification rows. |
| src/RustPlusBot.Persistence/Configurations/VendingNotificationConfiguration.cs | EF config for undercut notification rows. |
| src/RustPlusBot.Persistence/Configurations/VendingListingTrackConfiguration.cs | EF config for manually tracked listing rows. |
| src/RustPlusBot.Persistence/Configurations/VendingGridTrackConfiguration.cs | EF config for grid track rows. |
| src/RustPlusBot.Persistence/BotDbContext.cs | Adds DbSets + applies vending EF configurations. |
| src/RustPlusBot.Localization/Strings.resx | Adds English resource strings for vending commands/embeds/channel name. |
| src/RustPlusBot.Localization/Strings.fr.resx | Adds French resource strings for vending commands/embeds/channel name. |
| src/RustPlusBot.Host/RustPlusBot.Host.csproj | Adds reference to the new Vending feature project. |
| src/RustPlusBot.Host/Program.cs | Wires Vending options + registers the Vending feature in the host. |
| src/RustPlusBot.Host/appsettings.json | Adds Vending:MaxNotificationsPerServer configuration. |
| src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs | Registers IVendingChannelLocator implementation. |
| src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs | Adds workspace channel key for per-server #vending. |
| src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs | Provisions new per-server read-only #vending channel. |
| src/RustPlusBot.Features.Workspace/Locating/VendingChannelLocator.cs | Adds cached channel locator for the #vending channel. |
| src/RustPlusBot.Features.Workspace/Locating/IVendingChannelLocator.cs | Defines interface for resolving #vending channel id. |
| src/RustPlusBot.Features.Vending/VendingServiceCollectionExtensions.cs | Registers Vending feature services + interaction module assembly. |
| src/RustPlusBot.Features.Vending/VendingOptions.cs | Adds options for per-server notification caps. |
| src/RustPlusBot.Features.Vending/Tracking/VendingTrackService.cs | Implements grid + manual listing tracking logic over store + index. |
| src/RustPlusBot.Features.Vending/Searching/VendingSearch.cs | Provides pure ordering/truncation helpers for offers. |
| src/RustPlusBot.Features.Vending/RustPlusBot.Features.Vending.csproj | Introduces new Vending feature project and references. |
| src/RustPlusBot.Features.Vending/Rendering/VendingEmbedRenderer.cs | Renders vending search + notices into Discord embeds with size limits. |
| src/RustPlusBot.Features.Vending/Relaying/VendingWipePurger.cs | Purges vending state/messages on wipe events. |
| src/RustPlusBot.Features.Vending/Relaying/VendingNotificationRelay.cs | Replaces index + reconciles desired notices against posted/persisted state. |
| src/RustPlusBot.Features.Vending/Posting/IVendingChannelPoster.cs | Abstraction for posting/editing/deleting vending embeds. |
| src/RustPlusBot.Features.Vending/Posting/DiscordVendingChannelPoster.cs | Discord implementation for ensuring/deleting vending messages. |
| src/RustPlusBot.Features.Vending/Ownership/GridOwnership.cs | Defines ownership-by-grid and offer projection from snapshots. |
| src/RustPlusBot.Features.Vending/Modules/VendingUntrackAutocompleteHandler.cs | Adds autocomplete for /vending-untrack over grids + manual listings. |
| src/RustPlusBot.Features.Vending/Modules/VendingModule.cs | Adds slash commands: /vending, /vending-track, /vending-untrack, /vending-tracked. |
| src/RustPlusBot.Features.Vending/Indexing/VendingIndex.cs | Implements in-memory per-(guild,server) vending index + read model. |
| src/RustPlusBot.Features.Vending/Hosting/VendingHostedService.cs | Adds hosted service to consume vending-related events. |
| src/RustPlusBot.Features.Vending/Evaluating/UndercutNotice.cs | Defines undercut notice record type. |
| src/RustPlusBot.Features.Vending/Evaluating/UndercutEvaluator.cs | Implements pure undercut evaluation rules. |
| src/RustPlusBot.Features.Vending/Evaluating/StockNotice.cs | Defines stock/sell-out notice record type + signature. |
| src/RustPlusBot.Features.Vending/Evaluating/StockEvaluator.cs | Implements pure sell-out evaluation rules. |
| src/RustPlusBot.Features.ItemData/Naming/ListingDisplay.cs | Centralizes blueprint-listing display marker logic. |
| src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs | Publishes vending observed events from marker poll results. |
| src/RustPlusBot.Features.Connections/Servers/ServerResolver.cs | Makes resolver public for use outside Connections slice. |
| src/RustPlusBot.Features.Connections/Servers/ServerResolution.cs | Makes resolution record public for broader reuse. |
| src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs | Maps vending machine markers into snapshots and returns MapMarkersSnapshot. |
| src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs | Changes marker poll return type to MapMarkersSnapshot. |
| src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs | Splits slash-command help field by group to respect Discord limits. |
| src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs | Registers vending commands in help catalog (in-game + slash). |
| src/RustPlusBot.Features.Commands/Help/CommandGroup.cs | Adds new help command group for Vending. |
| src/RustPlusBot.Features.Commands/Handlers/VUntrackCommandHandler.cs | Adds !vuntrack handler for grid untracking. |
| src/RustPlusBot.Features.Commands/Handlers/VTrackedCommandHandler.cs | Adds !vtracked handler to list tracked grids/listings. |
| src/RustPlusBot.Features.Commands/Handlers/VTrackCommandHandler.cs | Adds !vtrack handler to register owned grid cells. |
| src/RustPlusBot.Features.Commands/Handlers/VendingCommandHandler.cs | Adds !vending handler using the live vending read model. |
| src/RustPlusBot.Features.Commands/Formatting/VendingLine.cs | Adds formatting helper for in-game vending offer lines. |
| src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs | Registers new vending command handlers. |
| src/RustPlusBot.Domain/Vending/VendingStockNotification.cs | Adds domain entity for sell-out notifications. |
| src/RustPlusBot.Domain/Vending/VendingNotification.cs | Adds domain entity for undercut notifications. |
| src/RustPlusBot.Domain/Vending/VendingListingTrack.cs | Adds domain entity for manually tracked listings. |
| src/RustPlusBot.Domain/Vending/VendingGridTrack.cs | Adds domain entity for tracked grid cells. |
| src/RustPlusBot.Abstractions/Vending/VendingOffer.cs | Adds vending offer model for read/display/compare. |
| src/RustPlusBot.Abstractions/Vending/UnitPrice.cs | Adds exact unit-price comparisons via cross-multiplication. |
| src/RustPlusBot.Abstractions/Vending/ListingKey.cs | Adds listing identity type (item/currency + blueprint flags). |
| src/RustPlusBot.Abstractions/Vending/IVendingTrackService.cs | Adds tracking service abstraction + DTOs for tracked state. |
| src/RustPlusBot.Abstractions/Vending/IVendingReadModel.cs | Adds vending read-model abstraction for search + connection status. |
| src/RustPlusBot.Abstractions/Events/VendingMachinesObservedEvent.cs | Adds event published on each marker poll with full vending set. |
| src/RustPlusBot.Abstractions/Connections/VendingOfferSnapshot.cs | Adds raw vending-offer snapshot from Rust+ marker payload. |
| src/RustPlusBot.Abstractions/Connections/VendingMachineSnapshot.cs | Adds raw vending-machine snapshot from Rust+ marker payload. |
| src/RustPlusBot.Abstractions/Connections/MapMarkersSnapshot.cs | Adds marker poll result wrapper including vending machines. |
| RustPlusBot.slnx | Adds Vending feature + tests projects to the solution. |
| README.md | Documents the new vending capabilities and commands. |
| docs/superpowers/specs/2026-08-17-vending-machine-tracking-design.md | Adds 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>Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
!vending <item>/vending <item>!vtrack <grid>!vuntrack <grid>/vending-untrack!vtracked/vending-tracked/vending-track <item> <price> [currency] [quantity] [blueprint]Notifications land in a new read-only per-server
#vendingchannel.How it works
Vending data rides along on the existing 5-second
GetMapMarkerspoll, so there is no new Rust+ request — the bot was already receiving every machine's full offer list and discarding it. The supervisor publishes aVendingMachinesObservedEvent;Features.Vendingkeeps 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
"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.!vtrackbinds 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.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
HelpEmbedRenderersplit 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/ServerResolutionbecame public so a module outsideFeatures.Connectionscan resolve a server; the autocomplete handler feeding that parameter was already public.🤖 Generated with Claude Code