Dealer System
S1API provides a comprehensive dealer system that allows you to create custom NPCs who can work as dealers for the player, distributing products to customers and managing contracts.
Overview
Dealer NPCs are special NPCs that can:
- Accept recruitment by the player
- Take on distribution contracts
- Deliver products to customers
- Keep a cut of the profits
- Recommend other dealers
- Have customizable signing fees and payment structures
Creating a Dealer NPC
To create a dealer NPC, set IsDealer = true and configure dealer defaults using the EnsureDealer() builder method:
using S1API.Entities;
using S1API.Economy;
using S1API.Entities.NPCs.Suburbia;
using UnityEngine;
public class MyDealerNPC : NPC
{
public override bool IsPhysical => true;
public override bool IsDealer => true;
protected override void ConfigurePrefab(NPCPrefabBuilder builder)
{
builder.WithIdentity("my_dealer", "John", "Distributor")
.WithSpawnPosition(new Vector3(0, 0, 0))
.WithAppearanceDefaults(av =>
{
av.Gender = 0.0f;
av.Height = 1.0f;
})
.EnsureDealer()
.WithDealerDefaults(dd =>
{
dd.WithSigningFee(1000f) // Cost to recruit
.WithCut(0.15f) // Dealer keeps 15%
.WithDealerType(DealerType.PlayerDealer)
.WithHomeName("North Apartments") // Home building
.AllowInsufficientQuality(false) // Won't sell low quality
.AllowExcessQuality(true) // Can sell high quality
.WithCompletedDealsVariable("my_dealer_deals")
.WithRecommendation(r => r
.FromCustomer(NPC.Get<JeremyWilkinson>())
.OnDealCompleted());
})
.WithRelationshipDefaults(r =>
{
r.WithDelta(2.0f)
.SetUnlocked(false)
.SetUnlockType(NPCRelationship.UnlockType.DirectApproach);
});
}
protected override void OnCreated()
{
base.OnCreated();
Appearance.Build();
// Wire up dealer events
WireDealerEvents();
Aggressiveness = 2f;
Region = Region.Northtown;
Schedule.Enable();
}
protected override void OnDestroyed()
{
base.OnDestroyed();
UnwireDealerEvents();
}
private void WireDealerEvents()
{
if (Dealer == null) return;
Dealer.OnRecruited += HandleRecruited;
Dealer.OnContractAccepted += HandleContractAccepted;
Dealer.OnRecommended += HandleRecommended;
}
private void UnwireDealerEvents()
{
if (Dealer == null) return;
Dealer.OnRecruited -= HandleRecruited;
Dealer.OnContractAccepted -= HandleContractAccepted;
Dealer.OnRecommended -= HandleRecommended;
}
private void HandleRecruited()
{
SendTextMessage("I'm ready to work for you!");
}
private void HandleContractAccepted()
{
SendTextMessage("I've got a new contract to handle.");
}
private void HandleRecommended()
{
SendTextMessage("Thanks for the recommendation!");
}
}
Dealer Configuration Options
WithDealerDefaults() Methods
The DealerDataBuilder provides several configuration methods:
.WithDealerDefaults(dd =>
{
// Recruitment fee paid by player
dd.WithSigningFee(1000f)
// Percentage of profits the dealer keeps
.WithCut(0.15f) // 15% commission
// Type of dealer
.WithDealerType(DealerType.PlayerDealer) // Works for player
// Home building name (where dealer lives)
.WithHomeName("North Apartments")
// Quality control
.AllowInsufficientQuality(false) // Rejects low quality products
.AllowExcessQuality(true) // Accepts high quality products
// Variable to track completed deals
.WithCompletedDealsVariable("dealer_completed_deals")
// Automatically recommend this dealer when the customer completes a deal
.WithRecommendation(r => r
.FromCustomer(NPC.Get<JeremyWilkinson>())
.OnDealCompleted())
});
This helper wires the configured customer's OnDealCompleted event to the dealer recommendation flow for you. It does not replace relationship unlock metadata such as UnlockType; it simply streamlines the separate native recommendation step that the base game requires for dealers.
Dealer Types
public enum DealerType
{
PlayerDealer, // Works for the player (standard)
IndependentDealer, // Independent operation
RivalDealer // Works for competition
}
Dealer Events
Dealers provide several events you can subscribe to:
OnRecruited
Fired when the dealer is successfully recruited by the player:
Dealer.OnRecruited += () =>
{
MelonLogger.Msg($"Dealer {ID} has been recruited!");
SendTextMessage("I'm ready to start working!");
};
OnContractAccepted
Fired when the dealer accepts a new distribution contract:
Dealer.OnContractAccepted += () =>
{
MelonLogger.Msg($"Dealer {ID} accepted a contract");
SendTextMessage("I've got a new job to do.");
};
OnRecommended
Fired when the dealer is recommended to another NPC:
Dealer.OnRecommended += () =>
{
MelonLogger.Msg($"Dealer {ID} was recommended");
SendTextMessage("Thanks for spreading the word!");
};
Complete Dealer Example
Here's a complete example with all dealer features:
using System;
using MelonLoader;
using S1API.Economy;
using S1API.Entities;
using S1API.Entities.Schedule;
using S1API.Entities.NPCs.Northtown;
using S1API.Map.Buildings;
using UnityEngine;
public sealed class ProfessionalDealer : NPC
{
public override bool IsPhysical => true;
public override bool IsDealer => true;
private Action _dealerRecruitedHandler;
private Action _dealerContractAcceptedHandler;
private Action _dealerRecommendedHandler;
protected override void ConfigurePrefab(NPCPrefabBuilder builder)
{
var homeBuilding = Building.Get<NorthApartments>();
Vector3 homePosition = new Vector3(-28f, 1.065f, 62f);
Vector3 spawnPos = new Vector3(-53f, 1.065f, 68f);
builder.WithIdentity("professional_dealer", "Marcus", "Stone")
.WithSpawnPosition(spawnPos)
.WithAppearanceDefaults(av =>
{
av.Gender = 0.0f;
av.Height = 1.1f;
av.Weight = 0.6f;
var skinColor = new Color32(140, 110, 90, 255);
av.SkinColor = skinColor;
av.LeftEyeLidColor = av.SkinColor;
av.RightEyeLidColor = av.SkinColor;
av.HairColor = new Color(0.1f, 0.1f, 0.1f);
av.HairPath = "Avatar/Hair/Buzzcut/Buzzcut";
av.WithBodyLayer("Avatar/Layers/Top/T-Shirt", new Color(0.1f, 0.1f, 0.1f));
av.WithBodyLayer("Avatar/Layers/Bottom/Jeans", new Color(0.2f, 0.2f, 0.3f));
av.WithAccessoryLayer("Avatar/Accessories/Feet/Sneakers/Sneakers", Color.black);
})
.EnsureDealer()
.WithDealerDefaults(dd =>
{
dd.WithSigningFee(2500f) // Higher fee = more experienced
.WithCut(0.12f) // Lower cut = more loyal
.WithDealerType(DealerType.PlayerDealer)
.WithHomeName("North Apartments")
.AllowInsufficientQuality(false) // Quality-focused
.AllowExcessQuality(true)
.WithCompletedDealsVariable("professional_dealer_deals");
})
.WithRelationshipDefaults(r =>
{
r.WithDelta(3.0f) // Starts with good relationship
.SetUnlocked(false)
.SetUnlockType(NPCRelationship.UnlockType.DirectApproach)
.WithConnections(Get<KyleCooley>(), Get<LudwigMeyer>());
})
.WithSchedule(plan =>
{
plan.EnsureDealSignal() // Required for dealer functionality
.WalkTo(homePosition, 800)
.StayInBuilding(homeBuilding, 1000, 120)
.WalkTo(spawnPos, 1200)
.LocationDialogue(spawnPos, 1400);
})
.WithInventoryDefaults(inv =>
{
inv.WithStartupItems("baseballbat")
.WithRandomCash(min: 200, max: 800)
.WithClearInventoryEachNight(false);
});
}
public ProfessionalDealer() : base()
{
}
protected override void OnCreated()
{
try
{
base.OnCreated();
Appearance.Build();
SendTextMessage("Looking for work. Got product to move?");
WireDealerEvents();
Aggressiveness = 2f;
Region = Region.Northtown;
Schedule.Enable();
}
catch (Exception ex)
{
MelonLogger.Error($"ProfessionalDealer OnCreated failed: {ex.Message}");
}
}
protected override void OnDestroyed()
{
base.OnDestroyed();
UnwireDealerEvents();
}
private void WireDealerEvents()
{
if (Dealer == null)
{
MelonLogger.Warning($"Dealer component missing for {ID}");
return;
}
_dealerRecruitedHandler ??= HandleDealerRecruited;
_dealerContractAcceptedHandler ??= HandleContractAccepted;
_dealerRecommendedHandler ??= HandleDealerRecommended;
Dealer.OnRecruited -= _dealerRecruitedHandler;
Dealer.OnRecruited += _dealerRecruitedHandler;
Dealer.OnContractAccepted -= _dealerContractAcceptedHandler;
Dealer.OnContractAccepted += _dealerContractAcceptedHandler;
Dealer.OnRecommended -= _dealerRecommendedHandler;
Dealer.OnRecommended += _dealerRecommendedHandler;
}
private void UnwireDealerEvents()
{
if (Dealer == null) return;
if (_dealerRecruitedHandler != null)
Dealer.OnRecruited -= _dealerRecruitedHandler;
if (_dealerContractAcceptedHandler != null)
Dealer.OnContractAccepted -= _dealerContractAcceptedHandler;
if (_dealerRecommendedHandler != null)
Dealer.OnRecommended -= _dealerRecommendedHandler;
}
private void HandleDealerRecruited()
{
MelonLogger.Msg($"Dealer {ID} recruited!");
SendTextMessage("I won't let you down. I'm professional.");
}
private void HandleContractAccepted()
{
MelonLogger.Msg($"Dealer {ID} accepted new contract");
SendTextMessage("Got the contract. I'll handle it.");
}
private void HandleDealerRecommended()
{
MelonLogger.Msg($"Dealer {ID} recommended");
SendTextMessage("Appreciate the recommendation.");
}
}
Dealer Schedule
Dealers must have EnsureDealSignal() in their schedule to properly handle distribution contracts:
.WithSchedule(plan =>
{
plan.EnsureDealSignal() // REQUIRED for dealer functionality
.WalkTo(position, time)
.StayInBuilding(building, time);
});
Without EnsureDealSignal(), the dealer won't be able to accept or complete contracts.
Best Practices
Always Include EnsureDealSignal(): Dealers need this in their schedule to function properly
Event Cleanup: Unsubscribe from dealer events in
OnDestroyed()to prevent memory leaksQuality Control: Configure
AllowInsufficientQualityandAllowExcessQualitybased on dealer personality- Professional dealers should reject low quality (
AllowInsufficientQuality(false)) - Desperate dealers might accept anything
- Professional dealers should reject low quality (
Signing Fees: Balance signing fees with cut percentages
- Higher signing fee + lower cut = one-time investment
- Lower signing fee + higher cut = ongoing cost
Home Building: Always set a valid home building name that exists in the game
Relationships: Start dealers with
SetUnlocked(false)so players must discover them
Common Issues
Dealer Not Accepting Contracts
Problem: Dealer isn't accepting contracts when assigned.
Solution: Ensure EnsureDealSignal() is called in the schedule configuration:
.WithSchedule(plan =>
{
plan.EnsureDealSignal() // This is required!
.WalkTo(position, time);
});
Events Not Firing
Problem: Dealer events (OnRecruited, etc.) aren't being called.
Solution: Check that:
- You're subscribing to events after
base.OnCreated() - The
Dealercomponent isn't null before subscribing - You're not unsubscribing prematurely
Complete Dealer Example
For a production-ready dealer NPC implementation, see ExamplePhysicalDealerNPC from the S1API NPC Example Repository.
This example includes dealer defaults, a dealer-ready schedule, and safe event subscribe/unsubscribe patterns.
See Also
- ExamplePhysicalDealerNPC - Reference implementation
- Custom NPCs - Core NPC creation guide
- Customer Behavior - For creating customer NPCs
- S1API.Economy - Economy API Reference
- S1API.Entities.Dealer - Dealer API Reference