Skip to content

Commit b52ef0b

Browse files
committed
Release v2.1.0: fix packaged samples, product picker + quality pricing
- Fix "That ain't packaged" when giving Marco a sample: read the inventory instead of the in-hand item (the game empties it during conversations) - Sample discount is now per single package, not the whole remaining stack - Add a picker: choose which packaged product to hand Marco - Marco now visibly consumes the sample (animation, sound, effects) - Quality scales the discount (Trash 0.6x up to Heavenly 2.0x) - Re-point the quest marker after a menu reload so the wrench no longer breaks - Debug-only "rvtest" console helper to spawn packaged test products
1 parent 2684e37 commit b52ef0b

5 files changed

Lines changed: 220 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ All notable changes to RVRepairVan are documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
55
project adheres to Semantic Versioning.
66

7+
## [2.1.0] - 2026-06-24
8+
9+
### Fixed
10+
- Giving Marco a packaged sample no longer fails with "That ain't packaged" while you are
11+
holding sealed product. The check now reads your inventory instead of the in-hand item,
12+
which the game empties while a conversation is open.
13+
- Each sample's discount is now based on the value of the single package you hand over,
14+
rather than scaling with how many were left in the stack.
15+
- The quest marker (the wrench) could end up pointing nowhere after returning to the main
16+
menu and reloading a save; it now re-points to the right target once the world finishes
17+
loading.
18+
19+
### Added
20+
- You can now choose which packaged product to give Marco: the dialogue lists each packaged
21+
product you are carrying together with the discount it would give.
22+
- Marco actually consumes the sample you hand him now (the matching smoke/snort/eat
23+
animation and effects), instead of just taking it.
24+
- Marco pays more for cleaner product and less for junk: the per-sample discount is scaled
25+
by quality (Trash 0.6x up to Heavenly 2.0x).
26+
- Experimental host-authoritative co-op support. Single-player is unaffected; co-op has
27+
only had limited testing, so please report any multiplayer issues.
28+
729
## [2.0.1] - 2026-06-17
830

931
### Added

Core.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
using RVRepairVan.Persistence;
88
using RVRepairVan.Quests;
99

10-
[assembly: MelonInfo(typeof(RVRepairVan.Core), "RVRepairVan", "2.0.1", "DooDesch", null)]
10+
[assembly: MelonInfo(typeof(RVRepairVan.Core), "RVRepairVan", "2.1.0", "DooDesch", null)]
1111
[assembly: MelonGame("TVGS", "Schedule I")]
1212

1313
namespace RVRepairVan

Patches/DebugConsolePatch.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#if DEBUG
2+
using System;
3+
using HarmonyLib;
4+
using Il2CppScheduleOne.Product.Packaging; // PackagingDefinition
5+
6+
namespace RVRepairVan.Patches
7+
{
8+
/// <summary>
9+
/// DEBUG-only test helper. Typing <c>rvtest</c> in the dev console drops a set of pre-packaged products at every
10+
/// quality into the inventory, so the Marco sample / quality-multiplier flow can be tested without manually
11+
/// equipping + setquality + packageproduct each one (packageproduct only works on the equipped-in-hand item).
12+
/// Usage: <c>rvtest</c> (jar OG Kush) or <c>rvtest &lt;packaging&gt; &lt;productId&gt;</c>. Compiled out of Release.
13+
/// </summary>
14+
[HarmonyPatch(typeof(Il2CppScheduleOne.Console), nameof(Il2CppScheduleOne.Console.SubmitCommand), new Type[] { typeof(Il2CppSystem.Collections.Generic.List<string>) })]
15+
internal static class DebugConsolePatch
16+
{
17+
private static readonly EQuality[] Qualities =
18+
{ EQuality.Trash, EQuality.Poor, EQuality.Standard, EQuality.Premium, EQuality.Heavenly };
19+
20+
private static bool Prefix(Il2CppSystem.Collections.Generic.List<string> args)
21+
{
22+
try
23+
{
24+
if (args == null || args.Count == 0) return true; // not ours - let the game handle it
25+
if (!string.Equals(args[0], "rvtest", StringComparison.OrdinalIgnoreCase)) return true;
26+
27+
string packaging = args.Count > 1 ? args[1] : "jar";
28+
string product = args.Count > 2 ? args[2] : "ogkush";
29+
GiveTestProducts(packaging, product);
30+
return false; // handled - skip the game's dispatcher (avoids "command not found")
31+
}
32+
catch (Exception e) { Core.Log.Warning("[Debug] rvtest failed: " + e.Message); return false; }
33+
}
34+
35+
private static void GiveTestProducts(string packagingId, string productId)
36+
{
37+
PlayerInventory inv = PlayerSingleton<PlayerInventory>.Instance;
38+
if (inv == null) { Core.Log.Warning("[Debug] rvtest: no PlayerInventory."); return; }
39+
40+
ProductDefinition def = Il2CppScheduleOne.Registry.GetItem(productId.ToLower())?.TryCast<ProductDefinition>();
41+
PackagingDefinition pkg = Il2CppScheduleOne.Registry.GetItem(packagingId.ToLower())?.TryCast<PackagingDefinition>();
42+
if (def == null) { Core.Log.Warning("[Debug] rvtest: unknown product '" + productId + "'."); return; }
43+
if (pkg == null) { Core.Log.Warning("[Debug] rvtest: unknown packaging '" + packagingId + "'."); return; }
44+
45+
int given = 0;
46+
foreach (EQuality q in Qualities)
47+
{
48+
ProductItemInstance inst = def.GetDefaultInstance(1)?.TryCast<ProductItemInstance>();
49+
if (inst == null) continue;
50+
inst.Quality = q;
51+
inst.SetPackaging(pkg); // the exact call the 'packageproduct' console command uses
52+
if (!inv.CanItemFitInInventory(inst)) { Core.Log.Warning("[Debug] rvtest: inventory full at " + q + "."); break; }
53+
inv.AddItemToInventory(inst);
54+
given++;
55+
}
56+
Core.Log.Msg("[Debug] rvtest: gave " + given + " packaged " + def.Name + " (" + pkg.Name + ") - one per quality.");
57+
}
58+
}
59+
}
60+
#endif

Quests/Questline.cs

Lines changed: 134 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ internal static partial class Questline
5858
private static Transform _donnaT, _mingT, _marcoT;
5959
private static DialogueController.DialogueChoice _marcoRepairChoice;
6060

61+
// Sample picker: one Marco dialogue choice per packaged product the player holds (so they choose WHICH to
62+
// hand over). Capped at the hotbar size; _sampleSlots is the live snapshot the choices/handler index into.
63+
private const int MAX_SAMPLE_CHOICES = 8;
64+
private static readonly DialogueController.DialogueChoice[] _sampleChoices = new DialogueController.DialogueChoice[MAX_SAMPLE_CHOICES];
65+
private static readonly System.Collections.Generic.List<ItemSlot> _sampleSlots = new System.Collections.Generic.List<ItemSlot>();
66+
6167
// The host is authoritative: any host-side write to Stage / DiscountTotal auto-replicates to all clients
6268
// (single StageSync broadcast). Offline and client writes don't broadcast (client writes only happen via
6369
// ApplyStageSync, which uses RepairStateStore directly to avoid echoing). This is the one choke point that
@@ -132,6 +138,8 @@ internal static void Reset()
132138
_cratePoint = Vector3.zero;
133139
_donnaT = _mingT = _marcoT = null;
134140
_marcoRepairChoice = null;
141+
System.Array.Clear(_sampleChoices, 0, _sampleChoices.Length);
142+
_sampleSlots.Clear();
135143
}
136144

137145
internal static void Start()
@@ -175,7 +183,22 @@ private static IEnumerator SetupCoroutine()
175183
}
176184
}
177185
if (myGen == _gen)
186+
{
178187
Core.LogDebug($"[Questline] all NPC dialogue injected: Donna={_donnaDone} Ming={_mingDone} Marco={_marcoDone}");
188+
// The NPC transforms are only resolved now. On a fresh load RestoreCoroutine's first SyncEntry runs on
189+
// a fixed ~4s timer - often BEFORE Marco/Ming exist - so MarcoPos() falls back to RvPos()/zero and the
190+
// quest POI (the wrench marker) is left pointing nowhere, never re-pointed. Re-sync once here with the
191+
// real positions (host/offline only; a co-op client's POI is driven by the host snapshot).
192+
try
193+
{
194+
if (!(NetworkBus.Online && !NetworkBus.IsServer) && Active && Stage >= Started && Stage < Done)
195+
{
196+
EnsureQuest();
197+
Core.LogDebug("[Questline] post-inject POI re-sync (Marco resolved=" + (_marcoT != null) + ").");
198+
}
199+
}
200+
catch (Exception e) { Core.Log.Warning("[Questline] post-inject POI sync failed: " + e.Message); }
201+
}
179202
}
180203

181204
// Inject each NPC independently, looked up by id in the game's NPC registry.
@@ -279,10 +302,15 @@ private static void InjectMarco(DialogueController marco, NPC npc)
279302
AddChoice(marco, "I lost your package.", 96,
280303
() => Active && _pickupActive && _hasPackage && _pkgPlaced && !PlayerHasItem(PackageId), null, lostpkg);
281304

282-
// Sample option only appears while you're actually holding packaged product (and there's still room
283-
// above the price floor). Trust must already be earned (Stage >= Trusted).
284-
AddChoice(marco, "Give Marco a packaged sample", 95,
285-
() => Active && Trusted_ && Stage < Paid && HoldingPackaged() && CurrentPrice() > RVRepairVanPreferences.RepairPrice, OnGiveSample);
305+
// One sample entry per packaged product the player holds, so they pick WHICH to hand over. Each entry is
306+
// shown live (SampleChoiceVisible) only while trust is earned (Stage >= Trusted) and the price is still
307+
// above the floor; its label is set to that product's name + per-unit value right before it's drawn.
308+
for (int s = 0; s < MAX_SAMPLE_CHOICES; s++)
309+
{
310+
int idx = s;
311+
_sampleChoices[idx] = AddChoice(marco, "Give Marco a packaged sample", 95 - idx,
312+
() => SampleChoiceVisible(idx), () => OnGiveSample(idx));
313+
}
286314

287315
// Persistent reminder once trust is earned: tells players (who may have skipped the dialogue) HOW to
288316
// keep lowering the price. Shows whenever they're NOT currently holding product to hand over (when they
@@ -649,33 +677,37 @@ private static void OnMarcoPayLoss()
649677
SyncEntry();
650678
}
651679

652-
private static void OnGiveSample()
680+
// Give Marco one packaged product. i indexes the snapshot the choice list was built from (RefreshSampleSlots),
681+
// so the player hands over exactly the product they picked; falls back to the first packaged product if the
682+
// index is stale. Reads from inventory (not the equipped item, which is null during a conversation).
683+
private static void OnGiveSample(int i)
653684
{
654685
try
655686
{
656-
PlayerInventory inv = PlayerSingleton<PlayerInventory>.Instance;
657-
ProductItemInstance product = inv?.EquippedItem?.TryCast<ProductItemInstance>();
687+
RefreshSampleSlots();
688+
ItemSlot slot = (i >= 0 && i < _sampleSlots.Count) ? _sampleSlots[i] : FindPackagedProductSlot();
689+
ProductItemInstance product = slot?.ItemInstance?.TryCast<ProductItemInstance>();
658690
if (product == null || product.AppliedPackaging == null)
659691
{
660692
WorldSay(_marcoT, "That ain't packaged. Hand me something sealed.");
661693
return;
662694
}
663-
int discount = Mathf.Clamp(Mathf.RoundToInt(product.GetMonetaryValue()),
664-
RVRepairVanPreferences.MinSampleDiscount, RVRepairVanPreferences.MaxSampleDiscount);
665-
666-
// Marco TAKES it and consumes it (real consume animation + effects), like handing a free sample
667-
// to a non-customer. We pass removeFromInventory:FALSE and remove exactly ONE unit ourselves:
668-
// SendProduct's native removal quantity is unverifiable (body lives in GameAssembly.dll) and could
669-
// wipe a whole stack >1. Removing one deterministically protects the player's stack either way.
695+
int discount = SampleUnitDiscount(product);
696+
697+
// Marco actually consumes it: NPCBehaviour.ConsumeProduct (the ServerRpc the vanilla sample flow uses)
698+
// both sets the product AND enables the consume behaviour, so he plays the smoke/snort/eat animation,
699+
// sound and particles and the product's effects apply to him - and it replicates in co-op. Earlier we
700+
// only called SendProduct (sets the product, never starts the behaviour), so nothing happened visibly.
701+
// removeFromInventory:FALSE - that flag only touches the NPC's own inventory; we remove exactly one
702+
// unit from the player ourselves below (deterministic; protects a stack > 1).
670703
NPC marco = FindNpc(MarcoId);
671-
var cpb = (marco != null && marco.Behaviour != null) ? marco.Behaviour.ConsumeProductBehaviour : null;
672-
int before = (inv != null && inv.equippedSlot != null) ? inv.equippedSlot.Quantity : -1;
673-
if (cpb != null) cpb.SendProduct(product, false); // consume/animate, but don't let it touch inventory
674-
if (inv != null && inv.equippedSlot != null) RemoveOneFromSlot(inv.equippedSlot); // exactly one (clears the slot if it was the last)
675-
int after = (inv != null && inv.equippedSlot != null) ? inv.equippedSlot.Quantity : -1;
676-
Core.LogDebug("[Questline] sample given: equipped qty " + before + " -> " + after + " (expected -1).");
677-
678-
// The consume above is the ACTING player's local action (per-player inventory; SendProduct is a
704+
int before = slot != null ? slot.Quantity : -1;
705+
if (marco != null && marco.Behaviour != null) marco.Behaviour.ConsumeProduct(product, false);
706+
RemoveOneFromSlot(slot); // exactly one from the slot we found (clears it if it was the last)
707+
int after = slot != null ? slot.Quantity : -1;
708+
Core.LogDebug("[Questline] sample given: hotbar qty " + before + " -> " + after + " (expected -1).");
709+
710+
// The consume above is the ACTING player's local action (per-player inventory; ConsumeProduct is a
679711
// ServerRpc so Marco's eating replicates). The DISCOUNT is shared state - the host owns it. Client:
680712
// send the discount it computed and let the host apply + replicate the new price.
681713
if (RouteIntent(RvOp.GiveSample, discount))
@@ -688,6 +720,68 @@ private static void OnGiveSample()
688720
catch (Exception e) { Core.Log.Warning("[Questline] give sample failed: " + e.Message); }
689721
}
690722

723+
// Per-sample discount = the value of the ONE package handed over, NOT the whole stack. GetMonetaryValue() is
724+
// MarketValue * Quantity * Amount (scales with stack size), so divide by Quantity to get a single unit's
725+
// worth, then clamp to the configured min/max.
726+
private static int SampleUnitDiscount(ProductItemInstance p)
727+
{
728+
int qty = Mathf.Max(1, ((BaseItemInstance)p).Quantity);
729+
// Per-package value (GetMonetaryValue already folds in product type + effects via MarketValue, and the
730+
// packaging size via Amount) times a Marco-specific quality bonus/penalty, finally clamped to min/max.
731+
float unit = (p.GetMonetaryValue() / qty) * QualityMultiplier(p.Quality);
732+
return Mathf.Clamp(Mathf.RoundToInt(unit), RVRepairVanPreferences.MinSampleDiscount, RVRepairVanPreferences.MaxSampleDiscount);
733+
}
734+
735+
// Marco pays more for cleaner product, less for junk. The game's own monetary value is quality-independent,
736+
// so this is a Marco-only sweetener. EQuality order: Trash, Poor, Standard, Premium, Heavenly.
737+
private static float QualityMultiplier(EQuality q)
738+
{
739+
switch (q)
740+
{
741+
case EQuality.Trash: return 0.6f;
742+
case EQuality.Poor: return 0.8f;
743+
case EQuality.Premium: return 1.5f;
744+
case EQuality.Heavenly: return 2.0f;
745+
default: return 1.0f; // Standard
746+
}
747+
}
748+
749+
// Snapshot every inventory slot currently holding a packaged product (capped at the choice count). Rebuilt
750+
// each time the choices are evaluated/picked so the list always reflects what the player holds right now.
751+
private static void RefreshSampleSlots()
752+
{
753+
_sampleSlots.Clear();
754+
var slots = PlayerSingleton<PlayerInventory>.Instance?.GetAllInventorySlots();
755+
if (slots == null) return;
756+
for (int i = 0; i < slots.Count && _sampleSlots.Count < MAX_SAMPLE_CHOICES; i++)
757+
{
758+
ItemSlot slot = slots[i];
759+
ProductItemInstance p = slot?.ItemInstance?.TryCast<ProductItemInstance>();
760+
if (p != null && p.AppliedPackaging != null) _sampleSlots.Add(slot);
761+
}
762+
}
763+
764+
// Live visibility + label for the i-th sample choice. Shown only with trust earned and room above the floor;
765+
// refreshes the snapshot and (the game calls this right before drawing) sets this entry's label to the i-th
766+
// packaged product's name + per-unit value.
767+
private static bool SampleChoiceVisible(int i)
768+
{
769+
if (!(Active && Trusted_ && Stage < Paid && CurrentPrice() > RVRepairVanPreferences.RepairPrice)) return false;
770+
RefreshSampleSlots();
771+
if (i >= _sampleSlots.Count) return false;
772+
ProductItemInstance p = _sampleSlots[i]?.ItemInstance?.TryCast<ProductItemInstance>();
773+
if (p == null) return false;
774+
if (_sampleChoices[i] != null) _sampleChoices[i].ChoiceText = SampleChoiceText(p);
775+
return true;
776+
}
777+
778+
private static string SampleChoiceText(ProductItemInstance p)
779+
{
780+
string name = "product";
781+
try { ItemDefinition def = p.Definition; if (def != null) name = def.Name; } catch { }
782+
return "Give Marco: " + name + " (-" + MoneyManager.FormatAmount(SampleUnitDiscount(p)) + ")";
783+
}
784+
691785
// Host-only (or offline): apply a sample's discount to the shared price. Safe when the host is processing a
692786
// client's GiveSample intent (the client already consumed its own product) - no inventory touch here.
693787
private static void HostGiveSample(int discount)
@@ -1034,16 +1128,28 @@ private static DialogueController.DialogueChoice AddChoice(DialogueController dc
10341128
return choice;
10351129
}
10361130

1037-
/// <summary>True if the player is currently holding a PACKAGED product (peek only, no consume).</summary>
1038-
private static bool HoldingPackaged()
1131+
/// <summary>True if the player has a PACKAGED product anywhere in the hotbar (peek only, no consume).</summary>
1132+
private static bool HoldingPackaged() => FindPackagedProductSlot() != null;
1133+
1134+
/// <summary>The inventory slot holding a PACKAGED product, or null. Scans GetAllInventorySlots (hotbar +
1135+
/// cash) - the exact source the vanilla HandoverScreen sample flow reads - instead of the equipped item:
1136+
/// opening an NPC conversation holsters/unequips the player, so EquippedItem and equippedSlot both go null
1137+
/// mid-dialogue even though the sealed product is still in the inventory.</summary>
1138+
private static ItemSlot FindPackagedProductSlot()
10391139
{
10401140
try
10411141
{
1042-
PlayerInventory inv = PlayerSingleton<PlayerInventory>.Instance;
1043-
ProductItemInstance p = inv?.EquippedItem?.TryCast<ProductItemInstance>();
1044-
return p != null && p.AppliedPackaging != null;
1142+
var slots = PlayerSingleton<PlayerInventory>.Instance?.GetAllInventorySlots();
1143+
if (slots == null) return null;
1144+
for (int i = 0; i < slots.Count; i++)
1145+
{
1146+
ItemSlot slot = slots[i];
1147+
ProductItemInstance p = slot?.ItemInstance?.TryCast<ProductItemInstance>();
1148+
if (p != null && p.AppliedPackaging != null) return slot;
1149+
}
10451150
}
1046-
catch { return false; }
1151+
catch { }
1152+
return null;
10471153
}
10481154

10491155
private static void RefreshRepairChoice()

RVRepairVan.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
<RootNamespace>RVRepairVan</RootNamespace>
2424
<AssemblyName>RVRepairVan</AssemblyName>
2525
<NeutralLanguage>en-US</NeutralLanguage>
26-
<Version>2.0.1</Version>
27-
<AssemblyVersion>2.0.1.0</AssemblyVersion>
28-
<FileVersion>2.0.1.0</FileVersion>
26+
<Version>2.1.0</Version>
27+
<AssemblyVersion>2.1.0.0</AssemblyVersion>
28+
<FileVersion>2.1.0.0</FileVersion>
2929
<!-- Suppress MSB3277/MSB3245 noise from il2cpp interop transitive refs -->
3030
<NoWarn>$(NoWarn);MSB3277;MSB3245</NoWarn>
3131

0 commit comments

Comments
 (0)