Mega Tree API
Full example
A complete mod with two nodes, a built-in icon, a custom PNG, a gate, a store level requirement and a real effect. This is the code that ships in the Mega Tree repo.
Everything on this page is the ExampleMegaTreeMod project that comes
with Mega Tree's source. It builds, it loads, and the two nodes do what they say.
| Node | Levels | What it does |
|---|---|---|
mtexample.loyal_shoppers | 3 | +3% on every sale, per level |
mtexample.golden_hour | 2 | Multiplies the line above. Locked until Loyal Shoppers I, and needs store level 8, then 15 |
Plugin.cs
using System.Collections.Generic;
using BepInEx;
using BepInEx.Logging;
using HarmonyLib;
using MegaTree.Api;
namespace ExampleMegaTreeMod;
// NOTE FOR ANYONE COMING FROM SUPERMARKET SIMULATOR: this is BepInEx 5 and Mono.
// `BasePlugin`, `Load()`, `ClassInjector` and `Il2CppInterop` do not exist here.
// It is `BaseUnityPlugin` and `Awake()`, exactly like a normal Unity mod.
[BepInPlugin(Guid, "Example Mega Tree Mod", "1.0.0")]
[BepInDependency(MegaTreeApi.ModGuid)] // without this, your Awake can run before Mega Tree's
public sealed class Plugin : BaseUnityPlugin
{
public const string Guid = "ExampleMegaTreeMod";
// Ids live in three places: the registration, the gate below, and the patch in
// Effects.cs. Typing the same string three times is how a typo becomes a bug
// that logs nothing, so they are constants.
//
// They are also the key in the save file. Never change one after release.
public const string LoyalShoppers = "mtexample.loyal_shoppers";
public const string GoldenHour = "mtexample.golden_hour";
// The effect strength lives here, and BOTH the text the player reads and the
// code in Effects.cs are derived from it. Write "3%" in one file and 0.02f in
// the other and the player reads one number and gets another.
public const float BonusPerLevel = 0.03f;
internal static ManualLogSource Log;
private void Awake()
{
Log = Logger;
// One call, once. "Example" is the label of your sub-tab inside the Mods tab.
var tree = MegaTreeApi.ForPlugin(Guid, "Example");
// -- Node 1: the plain one --------------------------------------------
var loyal = tree.Register(new UpgradeDefinition
{
Id = LoyalShoppers,
DisplayName = "Loyal Shoppers",
MaxLevel = 3,
BaseCost = 1200,
CostMultiplier = 1.8f,
SortOrder = 0,
Icon = UpgradeIcon.Builtin("shopping-cart"),
// The percentage comes from the same constant the effect uses, so the
// two can never drift apart.
DescribeLevel = lvl => $"+{lvl * BonusPerLevel * 100:0}% on every sale",
});
// Register never throws - it runs inside YOUR Awake, and an exception here
// would abort YOUR plugin over a typo. Checking the result is the only way
// to find out something went wrong.
if (!loyal.Success) Log.LogError($"Loyal Shoppers refused: {loyal.Result}");
// -- Node 2: own art, a gate, a level requirement and a callback -------
var golden = tree.Register(new UpgradeDefinition
{
Id = GoldenHour,
DisplayName = "Golden Hour",
MaxLevel = 2,
BaseCost = 2500,
CostMultiplier = 2f,
SortOrder = 1,
// Coloured artwork, flattened to a white silhouette for you.
Icon = UpgradeIcon.FromPng(IconData.Heart, whiteSilhouette: true),
DescribeLevel = lvl => lvl >= 2
? "Triples the Loyal Shoppers bonus"
: "Doubles the Loyal Shoppers bonus",
// The gate. Note what it does NOT need: any knowledge of the game's
// internals. It asks the public API about another node, and that is
// the whole dependency.
IsUnlocked = () => MegaTreeApi.GetLevel(LoyalShoppers) > 0,
// Always give a reason. A grey circle with a bare "Locked" is
// indistinguishable from a bug.
LockedReason = () => "Buy Loyal Shoppers first",
// Optional, and null by default. The number is the game's own player
// level - the same one that gates hiring and store growth.
RequiredStoreLevel = lvl => lvl == 1 ? 8 : 15,
// Fires on a purchase AND on a sell-back.
OnLevelChanged = lvl => Log.LogInfo($"Golden Hour is now level {lvl}"),
});
if (!golden.Success) Log.LogError($"Golden Hour refused: {golden.Result}");
// Translations are optional. Without this the English DisplayName is what
// every language gets, and nothing warns you.
//
// "pt-BR" and "Portuguese" both work: the game names its languages, and
// Mega Tree accepts the BCP-47 code too.
tree.AddTranslations("pt-BR", new Dictionary<string, string>
{
[LoyalShoppers + ".name"] = "Clientes Fiéis",
[GoldenHour + ".name"] = "Hora de Ouro",
});
// Nothing to do with Mega Tree: this installs the patch in Effects.cs.
var harmony = new Harmony(Guid);
harmony.PatchAll(typeof(Plugin).Assembly);
// Worth logging what actually got patched. A patch that fails to install
// is silent otherwise: the node works, the effect never fires, and there
// is nothing in the log pointing at the cause.
var nomes = new List<string>();
foreach (var m in harmony.GetPatchedMethods())
nomes.Add($"{m.DeclaringType?.Name}.{m.Name}");
Log.LogInfo($"Example Mega Tree Mod loaded. Applied {nomes.Count} Harmony patch(es): {string.Join(", ", nomes)}");
}
}Effects.cs
The half Mega Tree knows nothing about.
using HarmonyLib;
using MegaTree.Api;
namespace ExampleMegaTreeMod;
// THE EFFECT IS ALWAYS YOUR JOB.
//
// Mega Tree owns the node: the circle, the price, the gate, the save file, the
// hover card. It never knows what your upgrade does, and it deliberately has no
// way to find out. Everything below is ordinary Harmony against the game.
//
// WHY A POSTFIX AND NOT A PREFIX
//
// The bonus is paid as EXTRA MONEY after the sale, not as a bigger price on the
// sale itself. `OnPaymentFinished` has already told the statistics system what
// the profit was by the time it returns - rewriting the price underneath would
// make the end-of-day screen disagree with the bank balance.
[HarmonyPatch(typeof(CheckoutManager), nameof(CheckoutManager.OnPaymentFinished))]
internal static class Effects
{
// NOTIFYING **IS** PAYING.
//
// `EventManager.NotifyEvent(ADD_SOFT_CURRENCY, x)` is not a notice about a
// payment that already happened - the EconomyManager listens to that event
// and IS what moves the balance.
//
// So you call it, and you call it ONCE. Calling AddSoftCurrency as well pays
// the player twice, and that mistake is invisible until someone notices their
// money going up faster than it should.
private static void Postfix(float takenAmount)
{
int nivel = MegaTreeApi.GetLevel(Plugin.LoyalShoppers);
if (nivel <= 0) return;
// Golden Hour multiplies the line above instead of adding a second bonus.
int ouro = MegaTreeApi.GetLevel(Plugin.GoldenHour);
float fator = 1f + ouro;
float bonus = takenAmount * nivel * Plugin.BonusPerLevel * fator;
if (bonus <= 0.005f) return;
EventManager.NotifyEvent(EconomyEvents.ADD_SOFT_CURRENCY, bonus);
Plugin.Log.LogInfo($"[example] sale of ${takenAmount:0.00} paid ${bonus:0.00} extra " +
$"(loyal {nivel}, golden {ouro})");
}
}This is worth reading twice, because it is the one thing in this game that costs real money to get wrong - and Mega Tree itself got it wrong first.
No game code calls AddSoftCurrency or RemoveSoftCurrency directly. Every purchase and every sale goes through EventManager.NotifyEvent, and the EconomyManager subscribes to those events. Doing both charges twice.
Mega Tree paid a node's price with the method and the event, and the balance went negative. The symptom is silent in the other direction too: a mod paying a bonus twice just looks like a generous mod.
IconData.cs
using System;
namespace ExampleMegaTreeMod;
// A loose png next to the dll is a file players lose, and then you get a bug
// report about a missing icon you cannot reproduce. Baking it into the assembly
// removes the whole class of problem.
internal static class IconData
{
private static byte[] _bytes;
// Decoded once, the first time somebody actually asks for it.
public static byte[] Heart => _bytes ?? (_bytes = Convert.FromBase64String(Base64));
private const string Base64 = "iVBORw0KGgoAAAANSUhEUgAAAIAAAA...";
}The csproj
Short, because Mono needs so little. Project setup explains each line.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<AssemblyName>ExampleMegaTreeMod</AssemblyName>
<LangVersion>latest</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<GameDir>C:\Program Files (x86)\Steam\steamapps\common\Megastore Simulator</GameDir>
<Managed>$(GameDir)\Megastore Simulator_Data\Managed</Managed>
</PropertyGroup>
<ItemGroup>
<Reference Include="MegaTree">
<HintPath>$(GameDir)\BepInEx\plugins\rodopoulos\MegaTree.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="BepInEx">
<HintPath>$(GameDir)\BepInEx\core\BepInEx.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(GameDir)\BepInEx\core\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>$(Managed)\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>$(Managed)\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>$(Managed)\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>What to take from it
- One constant, two readers.
BonusPerLevelfeeds the text the player reads and the code that pays them. They cannot drift apart. - Ids as constants. Three files use them; a typo in any one is a silent failure.
- Check the handle.
Registernever throws, so an unchecked result is a node that silently is not there. - Log the patch count. It is the difference between "my mod is broken" and "my patch did not install".
- A gate built on the public API alone. Node 2 depends on node 1 without touching a single game internal.