@@ -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 ( )
0 commit comments