C#:
// Reference: 0Harmony // (not required, kept for parity with common plugin templates; remove if your build warns)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using Oxide.Game.Rust.Cui;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("RustGeneticsHelper", "jakman76", "1.0.0")]
[Description("Farming genetics assistant: clone bank scanner and a recipe finder that tells you which clones to place to reach a target genome.")]
public class RustGeneticsHelper : RustPlugin
{
// ------------------------------------------------------------------
// GENETICS MODEL (validated against the in-game crossbreed rules)
// ------------------------------------------------------------------
// * 6 gene slots. 5 gene types:
// G GrowthSpeed (green) Y Yield (green) H Hardiness (green)
// W WaterReq (red) X Empty (red)
// * Crossbreed resolves each of the 6 slots INDEPENDENTLY.
// * The plant being resolved is the "center" (defender). Its touching
// same-species neighbours in the same planter are the "donors".
// * Per slot, sum donor weights per gene type (green = 0.6, red = 1.0).
// The heaviest donor type is the challenger.
// * STRICT-GREATER rule: the challenger overwrites the center's gene
// ONLY IF challengerWeight > centerWeight. Ties/less => center keeps.
// * If several donor types tie for the heaviest AND beat the center,
// the winner is chosen uniformly at random -> that is where the
// probabilities in this plugin come from.
// All four numbers (green/red weight, strict-greater) are config-driven
// so you can adjust if Facepunch ever retunes the values.
// ------------------------------------------------------------------
private const int Slots = 6;
private const string GreenBg = "0.15 0.36 0.14 0.95"; // unified green (title bar, presets, goal letters)
#region Config
private Configuration cfg;
private class Configuration
{
[JsonProperty("Permission required to use")]
public string Permission = "rustgeneticshelper.use";
[JsonProperty("Permission for admin/testing commands (grow)")]
public string AdminPermission = "rustgeneticshelper.admin";
[JsonProperty("Green gene weight (G/Y/H)")]
public float GreenWeight = 0.6f;
[JsonProperty("Red gene weight (W/X)")]
public float RedWeight = 1.0f;
[JsonProperty("Default target genome (order-independent for stats)")]
public string TargetGenome = "GGGYYY";
[JsonProperty("Recipe finder: how many recipes to show")]
public int FinderResults = 5;
[JsonProperty("Scan boxes within this radius for clones (m)")]
public float BoxScanRadius = 4f;
[JsonProperty("Also scan teammates' boxes")]
public bool ScanTeammateBoxes = true;
[JsonProperty("Max distinct clones listed per page")]
public int PanelCloneRows = 6;
[JsonProperty("Target presets (name -> genome)")]
public Dictionary<string, string> TargetPresets = new()
{
{ "Growth", "GGGGGG" },
{ "Yield", "YYYYYY" },
{ "Balanced", "GGGYYY" },
{ "Hardy", "GGYYHH" },
};
[JsonProperty("Look-at ray distance (m)")]
public float RayDistance = 5f;
[JsonProperty("Planter neighbour scan radius (m)")]
public float PlanterScanRadius = 2.0f;
[JsonProperty("Green colour (rich-text hex)")]
public string GreenColorHex = "#7BFF52";
[JsonProperty("Red colour (rich-text hex)")]
public string RedColorHex = "#FF5B5B";
}
protected override void LoadDefaultConfig() => cfg = new Configuration();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
cfg = Config.ReadObject<Configuration>();
if (cfg == null) throw new Exception("null config");
}
catch
{
PrintWarning("Config invalid or missing – creating a fresh one.");
cfg = new Configuration();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(cfg);
#endregion
#region Gene helpers
// Compile-time reference to the game's enum is safe – it is public.
private static readonly Dictionary<GrowableGenetics.GeneType, char> TypeToChar = new()
{
{ GrowableGenetics.GeneType.GrowthSpeed, 'G' },
{ GrowableGenetics.GeneType.Yield, 'Y' },
{ GrowableGenetics.GeneType.Hardiness, 'H' },
{ GrowableGenetics.GeneType.WaterRequirement, 'W' },
{ GrowableGenetics.GeneType.Empty, 'X' },
};
private static bool IsRed(char c) => c == 'W' || c == 'X';
private static bool IsGreen(char c) => c == 'G' || c == 'Y' || c == 'H';
private static bool IsValidGene(char c) => "GYHWX".IndexOf(c) >= 0;
// Read the six gene letters off a live plant.
private string ReadGenome(GrowableEntity plant) => plant == null ? null : ReadGenomeFromGenetics(plant.Genes);
// Read genes from a GrowableGenetics object (from a plant OR a decoded item).
// Uses reflection so an internal rename by Facepunch won't break the plugin.
private string ReadGenomeFromGenetics(object genesObj)
{
if (genesObj == null) return null;
IEnumerable<object> elements = ExtractGeneElements(genesObj);
if (elements == null) return null;
var sb = new StringBuilder(Slots);
foreach (var el in elements)
{
if (el is GrowableGenetics.GeneType gt)
{
if (TypeToChar.TryGetValue(gt, out var ch)) sb.Append(ch);
}
else
{
object typeVal = GetMember(el, "Type") ?? FindMemberOfType(el, typeof(GrowableGenetics.GeneType));
if (typeVal is GrowableGenetics.GeneType g2 && TypeToChar.TryGetValue(g2, out var ch))
sb.Append(ch);
}
if (sb.Length >= Slots) break;
}
return sb.Length == Slots ? sb.ToString() : null;
}
private static IEnumerable<object> ExtractGeneElements(object genesObj)
{
const BindingFlags F = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
// Try common member names first.
foreach (var nm in new[] { "Genes", "genes", "Slots", "slots", "geneList" })
{
var l = ToGeneList(GetMember(genesObj, nm));
if (LooksLikeGenes(l)) return l;
}
// Otherwise scan every enumerable member and keep the one that looks like genes.
var t = genesObj.GetType();
foreach (var f in t.GetFields(F))
{
if (!typeof(System.Collections.IEnumerable).IsAssignableFrom(f.FieldType) || f.FieldType == typeof(string)) continue;
var l = ToGeneList(SafeGet(() => f.GetValue(genesObj)));
if (LooksLikeGenes(l)) return l;
}
foreach (var p in t.GetProperties(F))
{
if (!p.CanRead || !typeof(System.Collections.IEnumerable).IsAssignableFrom(p.PropertyType) || p.PropertyType == typeof(string)) continue;
if (p.GetIndexParameters().Length > 0) continue;
var l = ToGeneList(SafeGet(() => p.GetValue(genesObj)));
if (LooksLikeGenes(l)) return l;
}
return null;
}
private static object SafeGet(Func<object> f) { try { return f(); } catch { return null; } }
private static List<object> ToGeneList(object o)
{
if (o is not System.Collections.IEnumerable e || o is string) return null;
var list = new List<object>();
foreach (var x in e) list.Add(x);
return list;
}
// True if this enumerable is actually the 6 gene slots (each element is a
// GeneType, or an object exposing a GeneType member).
private static bool LooksLikeGenes(List<object> list)
{
if (list == null || list.Count < Slots) return false;
var first = list[0];
if (first is GrowableGenetics.GeneType) return true;
return GetMember(first, "Type") is GrowableGenetics.GeneType
|| FindMemberOfType(first, typeof(GrowableGenetics.GeneType)) is GrowableGenetics.GeneType;
}
private static object GetMember(object obj, string name)
{
if (obj == null) return null;
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (f != null) return f.GetValue(obj);
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
return p != null && p.CanRead ? p.GetValue(obj) : null;
}
private static object FindMemberOfType(object obj, Type wanted)
{
if (obj == null) return null;
const BindingFlags F = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var t = obj.GetType();
foreach (var f in t.GetFields(F))
if (f.FieldType == wanted) return f.GetValue(obj);
foreach (var p in t.GetProperties(F))
if (p.PropertyType == wanted && p.CanRead && p.GetIndexParameters().Length == 0) return SafeGet(() => p.GetValue(obj));
return null;
}
#endregion
#region Neighbour detection
private GrowableEntity GetLookedAtPlant(BasePlayer player)
{
if (player == null) return null;
var ray = player.eyes.HeadRay();
// Find the surface point the player is aiming at (terrain, planter, foundation…).
Vector3 aim = ray.origin + ray.direction * Mathf.Min(cfg.RayDistance, 2f);
var hits = Physics.RaycastAll(ray, cfg.RayDistance, ~0, QueryTriggerInteraction.Collide);
if (hits != null && hits.Length > 0)
{
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
bool haveAim = false;
foreach (var h in hits)
{
var g = ResolveGrowable(h);
if (g != null) return g; // rare: growable with a collider
var child = NearestChildGrowableToRay(h.GetEntity(), ray, 0.5f);
if (child != null) return child; // planter-hosted plants
if (!haveAim) { aim = h.point; haveAim = true; }
}
}
// Growables usually have NO server-side collider, so physics can't see them.
// Query Rust's entity grid around the aim point and pick the plant the
// crosshair is pointing at (smallest perpendicular distance to the ray).
return NearestGrowableToRayGrid(ray, aim, 1.5f, 0.5f);
}
private static readonly BaseEntity[] _queryBuffer = new BaseEntity[256];
private List<GrowableEntity> FindGrowablesNear(Vector3 pos, float radius)
{
var list = new List<GrowableEntity>();
try
{
int count = BaseEntity.Query.Server.GetInSphere(pos, radius, _queryBuffer,
e => e is GrowableEntity ge && ge != null && !ge.IsDestroyed);
for (int i = 0; i < count && i < _queryBuffer.Length; i++)
if (_queryBuffer[i] is GrowableEntity g) list.Add(g);
}
catch (Exception ex) { PrintWarning($"entity query failed: {ex.Message}"); }
return list;
}
private GrowableEntity NearestGrowableToRayGrid(Ray ray, Vector3 center, float searchRadius, float maxPerp)
{
GrowableEntity best = null;
float bestPerp = maxPerp;
foreach (var g in FindGrowablesNear(center, searchRadius))
{
float perp = PerpDistToRay(ray, g.transform.position);
if (perp < bestPerp) { bestPerp = perp; best = g; }
}
return best;
}
private static GrowableEntity ResolveGrowable(RaycastHit h)
{
var ent = h.GetEntity();
if (ent is GrowableEntity g1) return g1;
var g2 = h.collider.ToBaseEntity() as GrowableEntity;
return g2 ?? h.collider.GetComponentInParent<GrowableEntity>();
}
// Nearest child GrowableEntity of 'host' to the aim ray (perpendicular
// distance), within maxPerp metres. Handles plants parented to a planter.
private static GrowableEntity NearestChildGrowableToRay(BaseEntity host, Ray ray, float maxPerp)
{
if (host?.children == null) return null;
GrowableEntity best = null;
float bestPerp = maxPerp;
foreach (var ch in host.children)
{
if (ch is not GrowableEntity g) continue;
float perp = PerpDistToRay(ray, g.transform.position);
if (perp < bestPerp) { bestPerp = perp; best = g; }
}
return best;
}
private static float PerpDistToRay(Ray ray, Vector3 point)
{
Vector3 toPoint = point - ray.origin;
if (Vector3.Dot(toPoint, ray.direction) < 0f) return float.MaxValue; // behind the player
return Vector3.Cross(toPoint, ray.direction.normalized).magnitude;
}
#endregion
#region Recipe finder (deterministic, allocation-free)
// A one-round recipe: center + donor multiset, with success probability P.
// AnyCenter = throwaway/sacrificial center (the donors dominate it).
private class DetRecipe { public string Center; public List<string> Donors; public double P = 1.0; public bool AnyCenter; }
// A two-round bridge path: round 1 breeds a clean bridge donor, round 2 uses
// it (throwaway center both rounds) to finish the target.
private class TwoStagePath
{
public string Bridge;
public List<string> R1Donors; // throwaway center -> bridge
public List<string> R2Donors; // throwaway center (+ bridge) -> target
public double P; // combined round-1 x round-2 probability
}
private float Wt(char c) => (c == 'W' || c == 'X') ? cfg.RedWeight : cfg.GreenWeight;
private static bool Approx(float a, float b) => a > b - 1e-4f && a < b + 1e-4f;
// Does the donor multiset make slot 's' deterministically equal 'want' (given the center)?
// Allocation-free: five weight accumulators, no dictionaries or LINQ.
private bool SlotYields(char center, List<string> donors, int s, char want)
{
float g = 0, y = 0, h = 0, w = 0, x = 0;
for (int i = 0; i < donors.Count; i++)
{
char d = donors[i][s];
float wt = Wt(d);
if (d == 'G') g += wt; else if (d == 'Y') y += wt; else if (d == 'H') h += wt; else if (d == 'W') w += wt; else x += wt;
}
float max = g; char win = 'G';
if (y > max) { max = y; win = 'Y'; }
if (h > max) { max = h; win = 'H'; }
if (w > max) { max = w; win = 'W'; }
if (x > max) { max = x; win = 'X'; }
float cw = Wt(center);
if (max <= 0f || !(max > cw + 1e-4f)) return center == want; // center defends
int ties = 0;
if (Approx(g, max)) ties++; if (Approx(y, max)) ties++; if (Approx(h, max)) ties++;
if (Approx(w, max)) ties++; if (Approx(x, max)) ties++;
return ties == 1 && win == want; // unique winner, and it's what we want
}
private bool YieldsTarget(string center, List<string> donors, string target)
{
for (int s = 0; s < Slots; s++)
if (!SlotYields(center[s], donors, s, target[s])) return false;
return true;
}
// Probability that slot 's' ends up == 'want' (accounts for ties = coin flips).
private double SlotP(char center, List<string> donors, int s, char want)
{
float g = 0, y = 0, h = 0, w = 0, x = 0;
for (int i = 0; i < donors.Count; i++)
{
char d = donors[i][s];
float wt = Wt(d);
if (d == 'G') g += wt; else if (d == 'Y') y += wt; else if (d == 'H') h += wt; else if (d == 'W') w += wt; else x += wt;
}
float max = g;
if (y > max) max = y; if (h > max) max = h; if (w > max) max = w; if (x > max) max = x;
float cw = Wt(center);
if (max <= 0f || !(max > cw + 1e-4f)) return center == want ? 1.0 : 0.0; // center defends
int ties = 0;
if (Approx(g, max)) ties++; if (Approx(y, max)) ties++; if (Approx(h, max)) ties++;
if (Approx(w, max)) ties++; if (Approx(x, max)) ties++;
float wantAcc = want == 'G' ? g : want == 'Y' ? y : want == 'H' ? h : want == 'W' ? w : x;
return Approx(wantAcc, max) ? 1.0 / ties : 0.0;
}
private double GenomeP(string center, List<string> donors, string target)
{
double p = 1.0;
for (int s = 0; s < Slots; s++)
{
p *= SlotP(center[s], donors, s, target[s]);
if (p <= 0.0) return 0.0;
}
return p;
}
// With-replacement combinations of pool strings, up to maxK; onCombo returns false to abort.
private bool EnumCombos(List<string> pool, int start, int maxK, List<string> cur, Func<List<string>, bool> onCombo)
{
if (cur.Count > 0 && !onCombo(cur)) return false;
if (cur.Count == maxK) return true;
for (int i = start; i < pool.Count; i++)
{
cur.Add(pool[i]);
bool ok = EnumCombos(pool, i, maxK, cur, onCombo);
cur.RemoveAt(cur.Count - 1);
if (!ok) return false;
}
return true;
}
private static int WrongSlots(string a, string b)
{
int n = 0;
for (int i = 0; i < Slots; i++) if (a[i] != b[i]) n++;
return n;
}
// Find a clean donor multiset (2 copies of up to 3 bank clones) that turns
// 'center' into 'target' deterministically. Returns the donor genomes or null.
private List<string> TryFinish(string center, string target, List<string> bank)
{
var wrong = new List<int>();
for (int s = 0; s < Slots; s++) if (center[s] != target[s]) wrong.Add(s);
if (wrong.Count == 0) return new List<string>(); // already the target
// Candidate donors: only clones that supply a needed target gene.
var cand = new List<string>();
foreach (var d in bank)
if (wrong.Any(s => d[s] == target[s])) cand.Add(d);
if (cand.Count == 0) return null;
var donors = new List<string>(6);
// size 1 (fewest donors first)
for (int i = 0; i < cand.Count; i++)
{
donors.Clear(); donors.Add(cand[i]); donors.Add(cand[i]);
if (YieldsTarget(center, donors, target)) return new List<string>(donors);
}
// size 2
for (int i = 0; i < cand.Count; i++)
for (int j = i + 1; j < cand.Count; j++)
{
donors.Clear();
donors.Add(cand[i]); donors.Add(cand[i]); donors.Add(cand[j]); donors.Add(cand[j]);
if (YieldsTarget(center, donors, target)) return new List<string>(donors);
}
// size 3
for (int i = 0; i < cand.Count; i++)
for (int j = i + 1; j < cand.Count; j++)
for (int k = j + 1; k < cand.Count; k++)
{
donors.Clear();
donors.Add(cand[i]); donors.Add(cand[i]);
donors.Add(cand[j]); donors.Add(cand[j]);
donors.Add(cand[k]); donors.Add(cand[k]);
if (YieldsTarget(center, donors, target)) return new List<string>(donors);
}
return null;
}
// Donor pool: closest-to-target clones (reinforcers) PLUS guaranteed suppliers
// (>=2 per slot) so filler donors carrying one rare gene aren't excluded.
private List<string> DonorPool(List<string> bank, string target, int cap)
{
var pool = bank.OrderBy(d => WrongSlots(d, target)).Take(cap).ToList();
for (int s = 0; s < Slots; s++)
{
int have = pool.Count(d => d[s] == target[s]);
if (have < 2)
foreach (var d in bank.Where(x => x[s] == target[s] && !pool.Contains(x))
.OrderBy(x => WrongSlots(x, target)).Take(2 - have))
pool.Add(d);
}
return pool;
}
// Probability the donor multiset alone yields 'target' around a THROWAWAY center.
// Requires the winning gene to dominate (weight > any center = >1.0), i.e. 2+ donors.
private double GenomePDonorOnly(List<string> donors, string target)
{
double p = 1.0;
for (int s = 0; s < Slots; s++)
{
float g = 0, y = 0, h = 0, w = 0, x = 0;
for (int i = 0; i < donors.Count; i++)
{
char d = donors[i][s];
float wt = Wt(d);
if (d == 'G') g += wt; else if (d == 'Y') y += wt; else if (d == 'H') h += wt; else if (d == 'W') w += wt; else x += wt;
}
float max = g;
if (y > max) max = y; if (h > max) max = h; if (w > max) max = w; if (x > max) max = x;
if (max <= cfg.RedWeight + 1e-4f) return 0.0; // must beat any center (red = 1.0)
int ties = 0;
if (Approx(g, max)) ties++; if (Approx(y, max)) ties++; if (Approx(h, max)) ties++;
if (Approx(w, max)) ties++; if (Approx(x, max)) ties++;
float wantAcc = target[s] == 'G' ? g : target[s] == 'Y' ? y : target[s] == 'H' ? h : target[s] == 'W' ? w : x;
if (!Approx(wantAcc, max)) return 0.0;
p *= 1.0 / ties;
}
return p;
}
// Best throwaway-center recipe: a donor multiset that produces the target
// around ANY center (the near-perfects are used as donors, not the center).
private (double p, List<string> donors) BestFinishDonorOnly(string target, List<string> bank, int poolCap, int maxD)
{
var pool = DonorPool(bank, target, poolCap);
if (pool.Count == 0) return (0.0, null);
double bestP = 0.0; List<string> bestD = null; int evals = 0; const int cap = 150000;
EnumCombos(pool, 0, Mathf.Clamp(maxD, 2, 8), new List<string>(), donors =>
{
if (++evals > cap) return false;
if (donors.Count < 2) return true; // need 2+ to dominate a center
double p = GenomePDonorOnly(donors, target);
if (p > bestP) { bestP = p; bestD = new List<string>(donors); }
return true;
});
return (bestP, bestD);
}
// Highest-probability donor multiset that turns 'center' into 'target'.
// poolCap/maxD bound the search; up to 8 donors = a full planter around the center.
private (double p, List<string> donors) BestFinish(string center, string target, List<string> bank, int poolCap, int maxD)
{
var needed = new List<int>();
for (int s = 0; s < Slots; s++) if (center[s] != target[s]) needed.Add(s);
if (needed.Count == 0) return (1.0, new List<string>());
var pool = DonorPool(bank, target, poolCap);
if (pool.Count == 0) return (0.0, null);
double bestP = 0.0; List<string> bestD = null; int evals = 0; const int cap = 120000;
EnumCombos(pool, 0, Mathf.Clamp(maxD, 2, 8), new List<string>(), donors =>
{
if (++evals > cap) return false;
double p = GenomeP(center, donors, target);
if (p > bestP) { bestP = p; bestD = new List<string>(donors); }
return true;
});
return (bestP, bestD);
}
// Best one-round recipes (any probability), ranked. Tries near-perfect centers
// AND the throwaway-center arrangement (donors dominate any center).
private List<DetRecipe> FindBestOneStep(List<string> bank, string target)
{
var res = new List<DetRecipe>();
foreach (var center in bank.OrderBy(g => WrongSlots(g, target)).Take(8))
{
var (p, donors) = BestFinish(center, target, bank, 10, 8);
if (p > 0.01) res.Add(new DetRecipe { Center = center, Donors = donors ?? new List<string>(), P = p });
}
var (dp, dd) = BestFinishDonorOnly(target, bank, 14, 8);
if (dp > 0.01) res.Add(new DetRecipe { AnyCenter = true, Donors = dd, P = dp });
return res.OrderByDescending(r => r.P).ThenBy(r => r.Donors.Count).Take(cfg.FinderResults).ToList();
}
// Clean-donor bridge candidates: genomes 1-2 genes off the target with at most
// one red, not already in the bank. These are the intermediates you breed first.
private List<string> GenerateBridges(string target, List<string> bank)
{
var bankSet = new HashSet<string>(bank);
var set = new HashSet<string>();
var genes = new[] { 'G', 'Y', 'H', 'W', 'X' };
var arr = target.ToCharArray();
for (int s = 0; s < Slots; s++)
{
char o = arr[s];
foreach (var ch in genes)
{
if (ch == o) continue;
arr[s] = ch; string b = new string(arr);
if (b.Count(IsRed) <= 1 && !bankSet.Contains(b)) set.Add(b);
}
arr[s] = o;
}
for (int s1 = 0; s1 < Slots; s1++)
for (int s2 = s1 + 1; s2 < Slots; s2++)
{
char o1 = arr[s1], o2 = arr[s2];
foreach (var c1 in genes)
foreach (var c2 in genes)
{
if (c1 == o1 || c2 == o2) continue;
arr[s1] = c1; arr[s2] = c2; string b = new string(arr);
if (b.Count(IsRed) <= 1 && !bankSet.Contains(b)) set.Add(b);
}
arr[s1] = o1; arr[s2] = o2;
}
return set.OrderBy(b => b.Count(IsRed)).ThenBy(b => WrongSlots(b, target)).Take(24).ToList();
}
// Best two-round bridge paths: breed a clean bridge donor (round 1), then finish
// the target with a throwaway-center combine that uses the bridge (round 2).
private List<TwoStagePath> FindDetPaths(List<string> bank, string target)
{
var paths = new List<TwoStagePath>();
foreach (var bridge in GenerateBridges(target, bank))
{
var (p1, d1) = BestFinishDonorOnly(bridge, bank, 10, 6); // round 1: bank -> bridge
if (p1 <= 0.1 || d1 == null) continue;
var bankPlus = new List<string>(bank) { bridge };
var (p2, d2) = BestFinishDonorOnly(target, bankPlus, 12, 6); // round 2: bank + bridge -> target
if (p2 <= 0.1 || d2 == null || !d2.Contains(bridge)) continue;
paths.Add(new TwoStagePath { Bridge = bridge, R1Donors = d1, R2Donors = d2, P = p1 * p2 });
}
return paths.OrderByDescending(p => p.P).Take(3).ToList();
}
private string CenterLabel(DetRecipe r)
=> r.AnyCenter ? "<i><color=#9fb0a0>any plant</color></i>" : ColorGenesC(r.Center);
// Short per-option stock check: what to clone up before running this recipe.
private string StockStatus(List<string> needed, Dictionary<string, int> have)
{
var demand = new Dictionary<string, int>();
foreach (var g in needed) demand[g] = (demand.TryGetValue(g, out var n) ? n : 0) + 1;
var shorts = new List<string>();
var lasts = new List<string>();
foreach (var kv in demand.OrderByDescending(k => k.Value))
{
int has = have.TryGetValue(kv.Key, out var h) ? h : 0;
if (has < kv.Value) shorts.Add($"{kv.Value}x{ColorGenesC(kv.Key)}<color=#9fb0a0>(have {has})</color>");
else if (has == kv.Value) lasts.Add(ColorGenesC(kv.Key));
}
if (shorts.Count > 0) return $"<color=#FF5B5B>clone up:</color> {string.Join(" ", shorts)}";
if (lasts.Count > 0) return $"<color=#ffd34d>last copies:</color> {string.Join(" ", lasts)} <color=#9fb0a0>— clone a spare</color>";
return "<color=#7BFF52>you have enough</color>";
}
private string FmtDonorList(List<string> donors)
=> donors == null || donors.Count == 0
? "<color=#7BFF52>clone it</color>"
: string.Join(" ", donors.GroupBy(x => x).Select(g => $"{g.Count()}x{ColorGenesC(g.Key)}"));
// ---- cached route (only recomputes when clones or target change) ----
private class RouteResult
{
public List<DetRecipe> OneStep = new(); // ranked by probability
public List<TwoStagePath> Paths = new(); // guaranteed two-stage
public List<string> Advice = new();
public bool Has100;
}
private readonly Dictionary<ulong, (string key, RouteResult res)> routeCache = new();
private RouteResult GetRoute(BasePlayer player, List<ScannedClone> clones, string typeKey, string target)
{
string key = typeKey + "|" + target + "|" +
string.Join(",", clones.Select(c => c.Genes).OrderBy(x => x));
if (routeCache.TryGetValue(player.userID, out var cached) && cached.key == key)
return cached.res;
var res = new RouteResult();
var bank = clones.Select(c => c.Genes).Distinct().ToList();
if (bank.Count == 0)
{
res.Advice = BuildAdvice(clones, target);
}
else
{
res.OneStep = FindBestOneStep(bank, target);
res.Has100 = res.OneStep.Any(r => r.P >= 0.999);
if (!res.Has100)
{
res.Paths = FindDetPaths(bank, target); // guaranteed two-stage, if any
if (res.Paths.Count == 0 && res.OneStep.Count == 0)
res.Advice = BuildAdvice(clones, target);
}
}
routeCache[player.userID] = (key, res);
return res;
}
#endregion
#region Data (per-player targets)
private class BankData
{
public Dictionary<ulong, string> Targets = new();
}
private BankData data;
private const string DataFile = "RustGeneticsHelper_Banks";
private void LoadData()
{
try { data = Interface.Oxide.DataFileSystem.ReadObject<BankData>(DataFile) ?? new BankData(); }
catch { data = new BankData(); }
data.Targets ??= new Dictionary<ulong, string>();
}
private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject(DataFile, data);
private string GetTarget(BasePlayer p)
=> data.Targets.TryGetValue(p.userID, out var t) && NormalizeGenome(t) != null ? t : NormalizeTarget(cfg.TargetGenome);
private void SetTarget(BasePlayer p, string genome)
{
data.Targets[p.userID] = genome;
SaveData();
}
#endregion
#region Shared UI helpers
private string ColorGenes(string genome)
{
var sb = new StringBuilder();
foreach (var ch in genome)
{
string col = IsRed(ch) ? cfg.RedColorHex : cfg.GreenColorHex;
sb.Append($"<color={col}><b>{ch}</b></color> ");
}
return sb.ToString().TrimEnd();
}
// Compact (no spaces between letters) — used in dense recipe lines.
private string ColorGenesC(string genome)
{
var sb = new StringBuilder();
foreach (var ch in genome)
sb.Append($"<color={(IsRed(ch) ? cfg.RedColorHex : cfg.GreenColorHex)}><b>{ch}</b></color>");
return sb.ToString();
}
private static string Pct(double p) => $"{Math.Round(p * 100)}%";
#endregion
#region Clone scanning + main panel
private const string MainPanel = "geneticshelper.main";
private const string MainContent = "geneticshelper.main.content";
private readonly HashSet<ulong> mainPanelOpen = new();
private readonly Dictionary<ulong, int> clonePage = new();
private readonly Dictionary<ulong, int> typeIndex = new();
private readonly HashSet<ulong> flushPending = new();
private static string F(float v) => v.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture);
// ---- item gene decoding ----
// Rust stores clone/seed genes in item.instanceData.dataInt, decoded by
// GrowableGeneEncoding.DecodeIntToGeneString(int) -> "GYHWXG".
private static bool _decodeResolved;
private static MethodInfo _decodeStr; // int -> string (preferred)
private static MethodInfo _decodeFill; // (int, GrowableGenes) fill
private static Type _genesType;
private static void ResolveGeneDecoder()
{
_decodeResolved = true;
try
{
var encType = AppDomain.CurrentDomain.GetAssemblies().SelectMany(SafeGetTypes)
.FirstOrDefault(t => t.Name == "GrowableGeneEncoding");
if (encType == null) return;
foreach (var m in encType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (m.Name.IndexOf("Previous", StringComparison.OrdinalIgnoreCase) >= 0) continue; // pre-crossbreed genes
var ps = m.GetParameters();
if (_decodeStr == null && ps.Length == 1 && ps[0].ParameterType == typeof(int)
&& m.ReturnType == typeof(string) && m.Name.IndexOf("Decode", StringComparison.OrdinalIgnoreCase) >= 0)
_decodeStr = m;
if (_decodeFill == null && ps.Length == 2 && ps[0].ParameterType == typeof(int)
&& m.ReturnType == typeof(void) && ps[1].ParameterType.Name.IndexOf("Gene", StringComparison.OrdinalIgnoreCase) >= 0)
{
_decodeFill = m;
_genesType = ps[1].ParameterType;
}
}
}
catch { }
}
private static IEnumerable<Type> SafeGetTypes(Assembly a)
{
try { return a.GetTypes(); } catch { return Array.Empty<Type>(); }
}
private string ReadItemGenome(Item item)
{
try
{
if (item?.instanceData == null) return null;
int enc = item.instanceData.dataInt;
if (enc == 0) return null;
if (!_decodeResolved) ResolveGeneDecoder();
string raw = null;
if (_decodeStr != null)
{
raw = _decodeStr.Invoke(null, new object[] { enc }) as string;
}
else if (_decodeFill != null && _genesType != null)
{
object genes = Activator.CreateInstance(_genesType, true);
_decodeFill.Invoke(null, new object[] { enc, genes });
raw = ReadGenomeFromGenetics(genes);
}
return CleanGenome(raw);
}
catch { return null; }
}
// Uppercase, keep only gene letters, require exactly 6.
private static string CleanGenome(string s)
{
if (string.IsNullOrEmpty(s)) return null;
var sb = new StringBuilder(Slots);
foreach (var ch in s.ToUpper())
if (IsValidGene(ch)) sb.Append(ch);
return sb.Length == Slots ? sb.ToString() : null;
}
// Count (destroy=false) or destroy (destroy=true) all clones with >3 reds
// in the player's own inventory and their own nearby boxes. Returns the copy count.
private int FlushJunk(BasePlayer player, bool destroy)
{
int count = 0;
var junk = new List<Item>();
foreach (var it in AllInventoryItems(player))
{
string g = ReadItemGenome(it);
if (g != null && g.Count(IsRed) > 3) { count += Math.Max(1, it.amount); junk.Add(it); }
}
foreach (var box in GetNearbyOwnedContainers(player, cfg.BoxScanRadius))
{
if (box == null || box.OwnerID != player.userID || box.inventory?.itemList == null) continue;
foreach (var it in box.inventory.itemList)
{
string g = ReadItemGenome(it);
if (g != null && g.Count(IsRed) > 3) { count += Math.Max(1, it.amount); junk.Add(it); }
}
}
if (destroy) foreach (var it in junk) it.Remove();
return count;
}
private static IEnumerable<Item> AllInventoryItems(BasePlayer player)
{
var list = new List<Item>();
var inv = player?.inventory;
if (inv == null) return list;
if (inv.containerMain?.itemList != null) list.AddRange(inv.containerMain.itemList);
if (inv.containerBelt?.itemList != null) list.AddRange(inv.containerBelt.itemList);
if (inv.containerWear?.itemList != null) list.AddRange(inv.containerWear.itemList);
return list;
}
// Move the clones a recipe needs from nearby owned boxes into the player's
// main inventory, up to the counts required and while there is room.
private void PullClones(BasePlayer player, List<string> needed)
{
if (needed == null || needed.Count == 0) { player.ChatMessage("Nothing to pull for that round."); return; }
var need = new Dictionary<string, int>();
foreach (var g in needed) need[g] = (need.TryGetValue(g, out var n) ? n : 0) + 1;
// Subtract what's already on the player.
foreach (var it in AllInventoryItems(player))
{
string g = ReadItemGenome(it);
if (g != null && need.TryGetValue(g, out var want) && want > 0)
need[g] = Math.Max(0, want - Math.Max(1, it.amount));
}
// Already have everything the recipe needs?
if (need.Values.All(v => v <= 0))
{
player.ChatMessage("You already have the needed clones in your inventory.");
return;
}
var main = player.inventory.containerMain;
int pulled = 0; bool full = false;
foreach (var box in GetNearbyOwnedContainers(player, cfg.BoxScanRadius))
{
if (box?.inventory?.itemList == null) continue;
foreach (var item in box.inventory.itemList.ToList())
{
string g = ReadItemGenome(item);
if (g == null || !need.TryGetValue(g, out var want) || want <= 0) continue;
int take = Math.Min(want, Math.Max(1, item.amount));
Item moving = take >= item.amount ? item : item.SplitItem(take);
if (moving == null) continue;
if (!moving.MoveToContainer(main))
{
if (moving != item) moving.MoveToContainer(box.inventory); // put split back
full = true; break;
}
need[g] = want - take; pulled += take;
}
if (full) break;
}
int missing = need.Values.Sum(v => Math.Max(0, v));
string msg;
if (pulled > 0)
{
msg = $"Pulled {pulled} clone(s) into your inventory.";
if (full) msg += " Inventory is full — free space and pull again.";
else if (missing > 0) msg += $" {missing} more aren't in nearby boxes — grow or breed them.";
}
else
{
msg = full
? "Inventory is full — free space and pull again."
: "The remaining clones aren't in nearby boxes — grow or breed them.";
}
player.ChatMessage(msg);
}
// ---- scanning inventory + owned nearby boxes ----
private class ScannedClone { public string Genes; public int Count; public int InvCount; public int BoxCount; public bool Inv; public bool Box; public string TypeKey; public string TypeName; }
private List<ScannedClone> ScanClones(BasePlayer player, out int boxCount)
{
var map = new Dictionary<string, ScannedClone>();
void Add(Item it, bool inv)
{
if (it == null) return;
string g = ReadItemGenome(it);
if (g == null) return;
string typeKey = it.info?.shortname ?? "clone";
string key = typeKey + "\u0001" + g;
if (!map.TryGetValue(key, out var sc))
map[key] = sc = new ScannedClone { Genes = g, TypeKey = typeKey, TypeName = PrettyTypeName(typeKey) };
int amt = Math.Max(1, it.amount);
sc.Count += amt;
if (inv) { sc.Inv = true; sc.InvCount += amt; }
else { sc.Box = true; sc.BoxCount += amt; }
}
foreach (var it in AllInventoryItems(player)) Add(it, true);
var boxes = GetNearbyOwnedContainers(player, cfg.BoxScanRadius);
boxCount = boxes.Count;
foreach (var box in boxes)
if (box?.inventory?.itemList != null)
foreach (var it in box.inventory.itemList) Add(it, false);
return map.Values
.OrderBy(s => s.Genes.Count(IsRed))
.ThenByDescending(s => s.Count)
.ToList();
}
// "clone.blue.berry" -> "BLUE BERRY", "clone.hemp" -> "HEMP"
private static string PrettyTypeName(string shortname)
{
string s = shortname ?? "clone";
if (s.StartsWith("clone.")) s = s.Substring(6);
else if (s.StartsWith("seed.")) s = s.Substring(5);
return s.Replace(".", " ").Replace("_", " ").ToUpper();
}
private List<StorageContainer> GetNearbyOwnedContainers(BasePlayer player, float radius)
{
var result = new List<StorageContainer>();
try
{
int count = BaseEntity.Query.Server.GetInSphere(player.transform.position, radius, _queryBuffer,
e => e is StorageContainer);
for (int i = 0; i < count && i < _queryBuffer.Length; i++)
{
if (_queryBuffer[i] is StorageContainer sc && !sc.IsDestroyed && sc.inventory != null)
{
if (sc.OwnerID == player.userID || (cfg.ScanTeammateBoxes && IsTeammate(player, sc.OwnerID)))
result.Add(sc);
}
}
}
catch { }
return result;
}
private static bool IsTeammate(BasePlayer player, ulong other)
{
if (other == 0 || player.currentTeam == 0) return false;
var team = RelationshipManager.ServerInstance?.FindTeam(player.currentTeam);
return team?.members != null && team.members.Contains(other);
}
// ---- advice when no recipe is reachable ----
private List<string> BuildAdvice(List<ScannedClone> clones, string target)
{
var advice = new List<string>();
for (int s = 0; s < Slots; s++)
{
char want = target[s];
if (IsRed(want)) continue;
int sources = clones.Count(c => c.Genes[s] == want);
if (sources == 0)
advice.Add($"Slot {s + 1}: no clone has <b>{ColorGenes(want.ToString())}</b> — grow seeds to find one.");
else if (sources == 1)
advice.Add($"Slot {s + 1}: only 1 source of <b>{ColorGenes(want.ToString())}</b> — get a 2nd to beat reds.");
}
if (advice.Count == 0)
advice.Add("<color=#9fb0a0>You have every gene, but they're spread across reddy clones. Grow cleaner clones (fewer W/X) so a bridge becomes possible.</color>");
return advice;
}
// ---- the panel ----
// Split into a static SHELL (built once; owns the cursor) and a DYNAMIC
// content layer that is refreshed in place. Refreshing only the content
// means the cursor never recenters and there's no full-panel flash.
private void OpenMainPanel(BasePlayer player)
{
mainPanelOpen.Add(player.userID);
clonePage[player.userID] = 0;
BuildShell(player);
RefreshContent(player);
}
private void CloseMainPanel(BasePlayer player)
{
mainPanelOpen.Remove(player.userID);
CuiHelper.DestroyUi(player, MainContent);
CuiHelper.DestroyUi(player, MainPanel);
}
private void BuildShell(BasePlayer player)
{
CuiHelper.DestroyUi(player, MainPanel);
var c = new CuiElementContainer();
string root = c.Add(new CuiPanel
{
Image = { Color = "0.06 0.07 0.06 0.98" },
RectTransform = { AnchorMin = "0.30 0.15", AnchorMax = "0.70 0.87" },
CursorEnabled = true
}, "Overlay", MainPanel);
// title bar (static parts)
// ... [Code preview truncated due to length. Full source in attached file] ...