commit ac90535b5ece1d60608bb22db01d8b16bae03501 Author: smthspec Date: Thu Jun 18 21:11:55 2026 +0300 Initial commit: YAInteraction DayZ mod Searchable furniture (med cabinet, fridge, vending machine) with weighted, config-driven loot tables, and a two-stage repairable sink (repair then gather water). Data-driven registry, in-memory cooldowns/repair state with v2 CF hooks. Co-Authored-By: Claude Opus 4.8 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e19ff1f --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# YAInteraction + +A DayZ server mod that adds interactable furniture (search for loot) and a +two-stage repairable sink (repair, then gather water). + +## Features + +- **Searchable furniture** — medical cabinet, fridge, and vending machine yield + random items from weighted, config-defined loot tables. Per-object cooldown. +- **Repairable sink** — repair with a `Wrench`/`PipeWrench`, then gather water + into a held/inventory container. Per-player cooldown, configurable amount. +- **Data-driven** — every interactable is one line in + [`YAInteractionConfig`](scripts/4_World/YAInteraction/Config/YAInteractionConfig.c). + No target/loot classnames exist anywhere else in the codebase. + +## Target objects (v1) + +| Classname | Action | Loot table | +|-----------|--------|-----------| +| `StaticObj_Furniture_case_sink_a` | Repair → Gather Water | — | +| `StaticObj_Furniture_lekarnicka` | Search | MedTable | +| `StaticObj_Furniture_fridge` | Search | FoodTable | +| `StaticObj_Furniture_vending_machine` | Search | VendingTable | + +## Structure + +``` +config.cpp / mod.cpp mod + script-module registration, metadata +scripts/3_Game shared game-layer placeholder (v2 hooks) +scripts/4_World/YAInteraction + Config/YAInteractionConfig.c single source of truth (registry + loot tables) + YALootTableManager.c weighted pick + item spawning + Entities/YAInteractionManager.c registry lookup + repair state + Actions/ Search / RepairSink / GatherWater actions + YAInteractionInit.c modded BuildingBase action injection +scripts/5_Mission mission-layer placeholder (v2 hooks) +``` + +## Notes + +- Cooldowns and sink repair state are tracked in memory and **reset on server + restart**. CF persistence hooks are marked with `// TODO (v2)`. +- All item and tool classnames are verified against the base game data. + +## Adding a new interactable + +Add one line to `GetInteractables()` in `YAInteractionConfig.c`: + +```c +m_Interactables.Insert(new YAInteractableEntry("Your_Classname", ACTION_SEARCH, TABLE_FOOD, 7200)); +``` diff --git a/config.cpp b/config.cpp new file mode 100644 index 0000000..a6b712e --- /dev/null +++ b/config.cpp @@ -0,0 +1,70 @@ +class CfgPatches +{ + class YAInteraction + { + units[] = {}; + weapons[] = {}; + requiredVersion = 0.1; + requiredAddons[] = + { + "DZ_Data", + "DZ_Scripts" + }; + }; +}; + +class CfgMods +{ + class YAInteraction + { + dir = "YAInteraction"; + picture = ""; + action = ""; + hideName = 1; + hidePicture = 1; + name = "YAInteraction"; + credits = "drasikas"; + author = "drasikas"; + authorID = "0"; + version = "1.0"; + extra = 0; + type = "mod"; + dependencies[] = + { + "Game", + "World", + "Mission" + }; + + // Script module registration. We only ship 4_World code in v1, but all three + // modules are declared so future hooks can drop into the correct layer without + // touching this file again. + class defs + { + class gameScriptModule + { + value = ""; + files[] = + { + "YAInteraction/scripts/3_Game" + }; + }; + class worldScriptModule + { + value = ""; + files[] = + { + "YAInteraction/scripts/4_World" + }; + }; + class missionScriptModule + { + value = ""; + files[] = + { + "YAInteraction/scripts/5_Mission" + }; + }; + }; + }; +}; diff --git a/mod.cpp b/mod.cpp new file mode 100644 index 0000000..214d5c8 --- /dev/null +++ b/mod.cpp @@ -0,0 +1,18 @@ +// Launcher / in-game mod metadata for YAInteraction. +class CfgMods +{ + class YAInteraction + { + name = "YAInteraction"; + author = "drasikas"; + authorID = "0"; + version = "1.0"; + picture = ""; + logo = ""; + logoSmall = ""; + logoOver = ""; + tooltip = "Search furniture and gather water from sinks."; + overview = "YAInteraction adds searchable interactables (shelves, fridges, computers, medical cabinets) with configurable, weighted loot tables, plus water gathering from sinks. Loot is driven by config classes; cooldowns are tracked in-memory (reset on server restart)."; + action = ""; + }; +}; diff --git a/scripts/3_Game/YAInteraction/YAInteractionGame.c b/scripts/3_Game/YAInteraction/YAInteractionGame.c new file mode 100644 index 0000000..90c3aa7 --- /dev/null +++ b/scripts/3_Game/YAInteraction/YAInteractionGame.c @@ -0,0 +1,12 @@ +// 3_Game layer placeholder for YAInteraction. +// +// The mod's runtime logic lives in 4_World (config, manager, actions, entity hooks). +// This file keeps the declared gameScriptModule non-empty and gives v2 a home for any +// game-layer constants or shared enums that need to be visible to lower modules. +// +// TODO (v2): If CF persistence is added, the shared persistence keys / version constants +// can live here so both the manager and any settings sync code reference one definition. +class YAInteractionGame +{ + static const string MOD_TAG = "[YAInteraction]"; +} diff --git a/scripts/4_World/YAInteraction/Actions/ActionYAGatherWater.c b/scripts/4_World/YAInteraction/Actions/ActionYAGatherWater.c new file mode 100644 index 0000000..134a8c7 --- /dev/null +++ b/scripts/4_World/YAInteraction/Actions/ActionYAGatherWater.c @@ -0,0 +1,187 @@ +// ============================================================================ +// ActionYAGatherWater +// ---------------------------------------------------------------------------- +// Stage 2 of the sink. Only usable once the sink has been repaired (state in +// YAInteractionManager). Fills a liquid container in the player's hands (or +// the first fillable container in their inventory) with a random amount of +// clean water. +// +// Cooldown model (v1): per-player, keyed by player UID, in a static map. +// Resets on server restart. +// TODO (v2): swap the static map for CF persistence in IsOnCooldown()/ +// StampCooldown(). +// ============================================================================ + +class ActionYAGatherWaterCB : ActionContinuousBaseCB +{ + override void CreateActionComponent() + { + m_ActionData.m_ActionComponent = new CAContinuousTime(YAInteractionConfig.GATHER_WATER_TIME); + } +} + +class ActionYAGatherWater : ActionContinuousBase +{ + // playerUID -> server time (ms) when the cooldown expires. + protected static ref map m_PlayerCooldowns = new map(); + + void ActionYAGatherWater() + { + m_CallbackClass = ActionYAGatherWaterCB; + m_CommandUID = DayZPlayerConstants.CMD_ACTIONFB_FILLBOTTLEWELL; // reuse "fill from fixture" anim + m_FullBody = true; + m_StanceMask = DayZPlayerConstants.STANCEMASK_ERECT | DayZPlayerConstants.STANCEMASK_CROUCH; + m_Text = "Gather Water"; + } + + override typename GetInputType() + { + return ContinuousInteractActionInput; + } + + override void CreateConditionComponents() + { + m_ConditionItem = new CCINone(); + m_ConditionTarget = new CCTNonRuined(UAMaxDistances.DEFAULT); + } + + override bool HasTarget() + { + return true; + } + + // Sink interactable + repaired + player carries a fillable container. + override bool ActionCondition(PlayerBase player, ActionTarget target, ItemBase item) + { + Object targetObject = target.GetObject(); + if (!targetObject) + return false; + + YAInteractableEntry entry = YAInteractionManager.GetEntry(targetObject.GetType()); + if (!entry || entry.m_ActionType != YAInteractionConfig.ACTION_REPAIR_WATER) + return false; + + if (!YAInteractionManager.IsRepaired(targetObject.GetNetworkIDString())) + return false; + + return FindFillableContainer(player) != null; + } + + override bool IsLockTargetOnUse() + { + return false; + } + + override void OnFinishProgressServer(ActionData action_data) + { + PlayerBase player = action_data.m_Player; + Object targetObject = action_data.m_Target.GetObject(); + if (!player || !player.GetIdentity() || !targetObject) + return; + + YAInteractableEntry entry = YAInteractionManager.GetEntry(targetObject.GetType()); + if (!entry) + return; + + // Defensive re-check (repair state could change mid-progress). + if (!YAInteractionManager.IsRepaired(targetObject.GetNetworkIDString())) + { + player.MessageStatus("The sink needs to be repaired first."); + return; + } + + string uid = player.GetIdentity().GetId(); + + // --- Per-player cooldown gate --- + if (IsOnCooldown(uid)) + { + int remaining = GetCooldownRemaining(uid); + Print(string.Format("[YAInteraction] GatherWater on cooldown (%1s) for %2", remaining, uid)); + player.MessageStatus(string.Format("You need to wait before gathering water again. (%1s)", remaining)); + return; + } + + ItemBase container = FindFillableContainer(player); + if (!container) + { + player.MessageStatus("You have nothing to fill with water."); + return; + } + + float amount = Math.RandomFloatInclusive(YAInteractionConfig.WATER_MIN_AMOUNT, YAInteractionConfig.WATER_MAX_AMOUNT); + Liquid.FillContainer(container, YAInteractionConfig.WATER_LIQUID_TYPE, amount); + + StampCooldown(uid, entry.m_CooldownSec); + + Print(string.Format("[YAInteraction] %1 gathered %2 water into '%3'", uid, amount, container.GetType())); + player.MessageStatus(string.Format("You filled %1 with %2 ml of water.", container.GetDisplayName(), Math.Round(amount))); + } + + // ------------------------------------------------------------------ + // Container discovery: hands first, then inventory. + // ------------------------------------------------------------------ + protected ItemBase FindFillableContainer(PlayerBase player) + { + if (!player) + return null; + + ItemBase inHands = ItemBase.Cast(player.GetItemInHands()); + if (CanFill(inHands)) + return inHands; + + array items = new array(); + player.GetInventory().EnumerateInventory(InventoryTraversalType.PREORDER, items); + foreach (EntityAI ent : items) + { + ItemBase candidate = ItemBase.Cast(ent); + if (CanFill(candidate)) + return candidate; + } + + return null; + } + + protected bool CanFill(ItemBase item) + { + if (!item || !item.IsLiquidContainer()) + return false; + + if (item.GetQuantity() >= item.GetQuantityMax()) + return false; // already full + + return Liquid.CanFillContainer(item, YAInteractionConfig.WATER_LIQUID_TYPE); + } + + // ------------------------------------------------------------------ + // Cooldown helpers (persistence-aware surface area). + // ------------------------------------------------------------------ + + // TODO (v2): read last-gather time from CF storage instead of the static map. + protected static bool IsOnCooldown(string uid) + { + int expiry; + if (!m_PlayerCooldowns.Find(uid, expiry)) + return false; + return GetGame().GetTime() < expiry; + } + + protected static int GetCooldownRemaining(string uid) + { + int expiry; + if (!m_PlayerCooldowns.Find(uid, expiry)) + return 0; + + int remainingMs = expiry - GetGame().GetTime(); + if (remainingMs < 0) + remainingMs = 0; + return remainingMs / 1000; + } + + // TODO (v2): persist this stamp via CF so it survives server restarts. + protected static void StampCooldown(string uid, float cooldownSec) + { + int expiry = GetGame().GetTime() + (int)(cooldownSec * 1000); + m_PlayerCooldowns.Set(uid, expiry); + Print(string.Format("[YAInteraction] Water cooldown stamped for %1 (%2s)", uid, cooldownSec)); + } +} diff --git a/scripts/4_World/YAInteraction/Actions/ActionYARepairSink.c b/scripts/4_World/YAInteraction/Actions/ActionYARepairSink.c new file mode 100644 index 0000000..90a1b95 --- /dev/null +++ b/scripts/4_World/YAInteraction/Actions/ActionYARepairSink.c @@ -0,0 +1,99 @@ +// ============================================================================ +// ActionYARepairSink +// ---------------------------------------------------------------------------- +// Stage 1 of the sink. Requires a Wrench or PipeWrench in hands and an +// un-repaired sink. On completion it flags the object as repaired in the +// per-object repair map (YAInteractionManager), which then unlocks +// ActionYAGatherWater on the same object. +// +// Repair state is in-memory and resets on server restart. +// TODO (v2): persist repair state via CF (see YAInteractionManager). +// ============================================================================ + +class ActionYARepairSinkCB : ActionContinuousBaseCB +{ + override void CreateActionComponent() + { + m_ActionData.m_ActionComponent = new CAContinuousTime(YAInteractionConfig.REPAIR_TIME); + } +} + +class ActionYARepairSink : ActionContinuousBase +{ + void ActionYARepairSink() + { + m_CallbackClass = ActionYARepairSinkCB; + m_CommandUID = DayZPlayerConstants.CMD_ACTIONFB_CRAFTING; // kneeling craft/repair anim + m_FullBody = true; + m_StanceMask = DayZPlayerConstants.STANCEMASK_ERECT | DayZPlayerConstants.STANCEMASK_CROUCH; + m_Text = "Repair Sink"; + } + + override typename GetInputType() + { + return ContinuousInteractActionInput; + } + + override void CreateConditionComponents() + { + m_ConditionItem = new CCINone(); // tool requirement checked in ActionCondition + m_ConditionTarget = new CCTNonRuined(UAMaxDistances.DEFAULT); + } + + override bool HasTarget() + { + return true; + } + + // Sink interactable + a wrench in hands + not already repaired. + override bool ActionCondition(PlayerBase player, ActionTarget target, ItemBase item) + { + Object targetObject = target.GetObject(); + if (!targetObject) + return false; + + YAInteractableEntry entry = YAInteractionManager.GetEntry(targetObject.GetType()); + if (!entry || entry.m_ActionType != YAInteractionConfig.ACTION_REPAIR_WATER) + return false; + + if (!IsRepairTool(item)) + return false; + + return !YAInteractionManager.IsRepaired(targetObject.GetNetworkIDString()); + } + + override bool IsLockTargetOnUse() + { + return false; + } + + override void OnFinishProgressServer(ActionData action_data) + { + PlayerBase player = action_data.m_Player; + Object targetObject = action_data.m_Target.GetObject(); + if (!player || !targetObject) + return; + + string key = targetObject.GetNetworkIDString(); + + // Re-check on the server (state may have changed during the progress). + if (YAInteractionManager.IsRepaired(key)) + { + player.MessageStatus("This sink is already repaired."); + return; + } + + YAInteractionManager.SetRepaired(key, true); + + Print(string.Format("[YAInteraction] %1 repaired sink %2", player.GetIdentity().GetId(), key)); + player.MessageStatus("You repaired the sink. You can now gather water."); + } + + // A wrench-type tool held in hands. IsKindOf covers any modded variants. + protected bool IsRepairTool(ItemBase item) + { + if (!item) + return false; + return item.IsKindOf("Wrench") || item.IsKindOf("PipeWrench"); + } +} diff --git a/scripts/4_World/YAInteraction/Actions/ActionYASearch.c b/scripts/4_World/YAInteraction/Actions/ActionYASearch.c new file mode 100644 index 0000000..1e787f7 --- /dev/null +++ b/scripts/4_World/YAInteraction/Actions/ActionYASearch.c @@ -0,0 +1,141 @@ +// ============================================================================ +// ActionYASearch +// ---------------------------------------------------------------------------- +// Generic "Search" action. The target's registry entry (resolved by classname +// via YAInteractionManager) decides the loot table and the per-object cooldown. +// No classnames live here - everything comes from config. +// +// Cooldown model (v1): per-object, keyed by network id, in a static map. +// Resets on server restart. +// TODO (v2): swap the static map for CF persistence in IsOnCooldown()/ +// StampCooldown() - those two methods are the only persistence surface. +// ============================================================================ + +class ActionYASearchCB : ActionContinuousBaseCB +{ + override void CreateActionComponent() + { + m_ActionData.m_ActionComponent = new CAContinuousTime(YAInteractionConfig.SEARCH_TIME); + } +} + +class ActionYASearch : ActionContinuousBase +{ + // network id string -> server time (ms) when the cooldown expires. + protected static ref map m_ObjectCooldowns = new map(); + + void ActionYASearch() + { + m_CallbackClass = ActionYASearchCB; + m_CommandUID = DayZPlayerConstants.CMD_ACTIONFB_INTERACT; // generic full-body interact anim + m_FullBody = true; + m_StanceMask = DayZPlayerConstants.STANCEMASK_ERECT | DayZPlayerConstants.STANCEMASK_CROUCH; + m_Text = "Search"; + } + + override typename GetInputType() + { + return ContinuousInteractActionInput; + } + + override void CreateConditionComponents() + { + m_ConditionItem = new CCINone(); + m_ConditionTarget = new CCTNonRuined(UAMaxDistances.DEFAULT); + } + + override bool HasTarget() + { + return true; + } + + // Empty-handed; target must be a registered "search" interactable. + override bool ActionCondition(PlayerBase player, ActionTarget target, ItemBase item) + { + Object targetObject = target.GetObject(); + if (!targetObject) + return false; + + if (player.GetItemInHands()) + return false; + + YAInteractableEntry entry = YAInteractionManager.GetEntry(targetObject.GetType()); + return entry != null && entry.m_ActionType == YAInteractionConfig.ACTION_SEARCH; + } + + override bool IsLockTargetOnUse() + { + return false; + } + + override void OnFinishProgressServer(ActionData action_data) + { + PlayerBase player = action_data.m_Player; + Object targetObject = action_data.m_Target.GetObject(); + if (!player || !targetObject) + return; + + YAInteractableEntry entry = YAInteractionManager.GetEntry(targetObject.GetType()); + if (!entry || entry.m_ActionType != YAInteractionConfig.ACTION_SEARCH) + return; + + string key = targetObject.GetNetworkIDString(); + + // --- Per-object cooldown gate --- + if (IsOnCooldown(key)) + { + int remaining = GetCooldownRemaining(key); + Print(string.Format("[YAInteraction] Search on cooldown (%1s left) for %2", remaining, key)); + player.MessageStatus(string.Format("You already searched this. (%1s)", remaining)); + return; + } + + // --- Roll & spawn --- + array table = YAInteractionManager.GetLootTableForEntry(entry); + int rolls = Math.RandomIntInclusive(YAInteractionConfig.SEARCH_ROLLS_MIN, YAInteractionConfig.SEARCH_ROLLS_MAX); + array spawned = YALootTableManager.GetInstance().SpawnFromTable(table, rolls, player, player.GetPosition()); + + // Stamp cooldown even on an empty result so the object is gated either way. + StampCooldown(key, entry.m_CooldownSec); + + Print(string.Format("[YAInteraction] %1 searched '%2' -> %3 item(s)", player.GetIdentity().GetId(), targetObject.GetType(), spawned.Count())); + + if (spawned.Count() > 0) + player.MessageStatus(string.Format("You found %1 item(s).", spawned.Count())); + else + player.MessageStatus("You found nothing useful."); + } + + // ------------------------------------------------------------------ + // Cooldown helpers (the only persistence-aware surface area). + // ------------------------------------------------------------------ + + // TODO (v2): read last-search time from CF storage instead of the static map. + protected static bool IsOnCooldown(string key) + { + int expiry; + if (!m_ObjectCooldowns.Find(key, expiry)) + return false; + return GetGame().GetTime() < expiry; + } + + protected static int GetCooldownRemaining(string key) + { + int expiry; + if (!m_ObjectCooldowns.Find(key, expiry)) + return 0; + + int remainingMs = expiry - GetGame().GetTime(); + if (remainingMs < 0) + remainingMs = 0; + return remainingMs / 1000; + } + + // TODO (v2): persist this stamp via CF so it survives server restarts. + protected static void StampCooldown(string key, float cooldownSec) + { + int expiry = GetGame().GetTime() + (int)(cooldownSec * 1000); + m_ObjectCooldowns.Set(key, expiry); + Print(string.Format("[YAInteraction] Search cooldown stamped for %1 (%2s)", key, cooldownSec)); + } +} diff --git a/scripts/4_World/YAInteraction/Config/YAInteractionConfig.c b/scripts/4_World/YAInteraction/Config/YAInteractionConfig.c new file mode 100644 index 0000000..b1977f5 --- /dev/null +++ b/scripts/4_World/YAInteraction/Config/YAInteractionConfig.c @@ -0,0 +1,159 @@ +// ============================================================================ +// YAInteractionConfig - SINGLE SOURCE OF TRUTH +// ---------------------------------------------------------------------------- +// Everything tunable lives here. To add a new interactable object you add ONE +// line to GetInteractables() (and, if it searches, define/extend a loot table). +// No other file in the mod contains a target classname or loot classname. +// +// YAInteractableEntry - one registered object (classname -> behaviour) +// YALootEntry - one row of a named loot table +// +// Cooldowns / repair state are tracked in memory and reset on server restart. +// TODO (v2): CF persistence hooks are marked in YAInteractionManager and the +// action classes - this config stays the built-in default set. +// ============================================================================ + +// One row of a loot table: what to spawn, how likely, and quantity range. +class YALootEntry +{ + string m_ClassName; // item classname (verified to exist in P:\DZ) + int m_Weight; // relative weight within its table (higher = more likely) + int m_MinQty; // min stack quantity (<=0 => leave item default) + int m_MaxQty; // max stack quantity (<=0 => leave item default) + + void YALootEntry(string className, int weight, int minQty = 0, int maxQty = 0) + { + m_ClassName = className; + m_Weight = weight; + m_MinQty = minQty; + m_MaxQty = maxQty; + } +} + +// One registered interactable object. +class YAInteractableEntry +{ + string m_ClassName; // target object classname + string m_ActionType; // YAInteractionConfig.ACTION_SEARCH or ACTION_REPAIR_WATER + string m_LootTableName; // named loot table (empty for repair_water) + float m_CooldownSec; // search: per-object cooldown / water: per-player cooldown + + void YAInteractableEntry(string className, string actionType, string lootTableName, float cooldownSec) + { + m_ClassName = className; + m_ActionType = actionType; + m_LootTableName = lootTableName; + m_CooldownSec = cooldownSec; + } +} + +class YAInteractionConfig +{ + // ---- Action type ids (referenced instead of magic strings) ----------- + static const string ACTION_SEARCH = "search"; + static const string ACTION_REPAIR_WATER = "repair_water"; + + // ---- Loot table names ------------------------------------------------ + static const string TABLE_MED = "MedTable"; + static const string TABLE_FOOD = "FoodTable"; + static const string TABLE_VENDING = "VendingTable"; + + // ---- Global tunables ------------------------------------------------- + static const float WATER_MIN_AMOUNT = 50.0; // ml added per gather (min) + static const float WATER_MAX_AMOUNT = 200.0; // ml added per gather (max) + static const int WATER_LIQUID_TYPE = LIQUID_CLEANWATER; + + static const int SEARCH_ROLLS_MIN = 1; // weighted picks per search (min) + static const int SEARCH_ROLLS_MAX = 2; // weighted picks per search (max) + + // Progress-bar durations (seconds) for each action. + static const float SEARCH_TIME = 3.0; + static const float GATHER_WATER_TIME = 4.0; + static const float REPAIR_TIME = 5.0; + + // ==================================================================== + // THE REGISTRY - add one YAInteractableEntry line per new object. + // (classname, actionType, lootTableName, cooldownSec) + // ==================================================================== + private static ref array m_Interactables; + + static array GetInteractables() + { + if (!m_Interactables) + { + m_Interactables = new array(); + + // ---------- ADD NEW INTERACTABLES BELOW (one line each) ---------- + m_Interactables.Insert(new YAInteractableEntry("StaticObj_Furniture_case_sink_a", ACTION_REPAIR_WATER, "", 300)); // 5 min per-player water cooldown + m_Interactables.Insert(new YAInteractableEntry("StaticObj_Furniture_lekarnicka", ACTION_SEARCH, TABLE_MED, 7200)); // 2 h per-object + m_Interactables.Insert(new YAInteractableEntry("StaticObj_Furniture_fridge", ACTION_SEARCH, TABLE_FOOD, 7200)); + m_Interactables.Insert(new YAInteractableEntry("StaticObj_Furniture_vending_machine", ACTION_SEARCH, TABLE_VENDING, 7200)); + // ----------------------------------------------------------------- + } + return m_Interactables; + } + + // ---- Named loot tables ---------------------------------------------- + private static ref map> m_LootTables; + + static array GetLootTable(string name) + { + if (!m_LootTables) + BuildLootTables(); + + array table; + if (m_LootTables.Find(name, table)) + return table; + + Print(string.Format("[YAInteraction] WARNING: loot table '%1' not found", name)); + return null; + } + + private static void BuildLootTables() + { + m_LootTables = new map>(); + m_LootTables.Set(TABLE_MED, BuildMedTable()); + m_LootTables.Set(TABLE_FOOD, BuildFoodTable()); + m_LootTables.Set(TABLE_VENDING, BuildVendingTable()); + } + + // --- Table definitions (all classnames verified in P:\DZ) ------------- + + private static array BuildMedTable() + { + array t = new array(); + t.Insert(new YALootEntry("BandageDressing", 40, 1, 2)); + t.Insert(new YALootEntry("CharcoalTablets", 25, 1, 1)); + t.Insert(new YALootEntry("TetracyclineAntibiotics", 15, 1, 1)); + t.Insert(new YALootEntry("SalineBag", 10, 1, 1)); + t.Insert(new YALootEntry("Morphine", 6, 1, 1)); // rarer + t.Insert(new YALootEntry("Epinephrine", 4, 1, 1)); // rarest + return t; + } + + private static array BuildFoodTable() + { + array t = new array(); + t.Insert(new YALootEntry("Apple", 30, 1, 2)); + t.Insert(new YALootEntry("BakedBeansCan", 20, 1, 1)); // "can of beans" + t.Insert(new YALootEntry("SardinesCan", 15, 1, 1)); // "can of sardines" + t.Insert(new YALootEntry("Rice", 15, 1, 1)); + // NOTE: vanilla has no "dried banana" - using Banana (closest match in P:\DZ). + t.Insert(new YALootEntry("Banana", 12, 1, 2)); + t.Insert(new YALootEntry("PowderedMilk", 8, 1, 1)); + return t; + } + + private static array BuildVendingTable() + { + array t = new array(); + // NOTE: "SodaCan_Lemonade" does not exist in P:\DZ; using verified vanilla + // flavours. "PET_BottledWater" does not exist; the water bottle is WaterBottle. + t.Insert(new YALootEntry("SodaCan_Cola", 20)); + t.Insert(new YALootEntry("SodaCan_Spite", 20)); + t.Insert(new YALootEntry("SodaCan_Fronta", 20)); + t.Insert(new YALootEntry("SodaCan_Pipsi", 15)); + t.Insert(new YALootEntry("WaterBottle", 25)); + return t; + } +} diff --git a/scripts/4_World/YAInteraction/Entities/YAInteractionManager.c b/scripts/4_World/YAInteraction/Entities/YAInteractionManager.c new file mode 100644 index 0000000..36cc77f --- /dev/null +++ b/scripts/4_World/YAInteraction/Entities/YAInteractionManager.c @@ -0,0 +1,120 @@ +// ============================================================================ +// YAInteractionManager +// ---------------------------------------------------------------------------- +// Reads the YAInteractionConfig.INTERACTABLES registry once and builds the +// runtime lookup maps. This is the ONLY place that consumes the registry, and +// nothing here contains a hardcoded target classname - everything is derived +// from config. Also owns per-object repair state for sinks. +// ============================================================================ +class YAInteractionManager +{ + // lowercased classname -> registry entry + private static ref map m_EntryByClass; + private static bool m_Initialized = false; + + // per-object repair state, keyed by network id string. + // TODO (v2): back this with CF persistence so repairs survive a restart. + private static ref map m_RepairState = new map(); + + // ------------------------------------------------------------------ + // Lazy init: fold the registry into a case-insensitive lookup map. + // ------------------------------------------------------------------ + static void Init() + { + if (m_Initialized) + return; + + m_EntryByClass = new map(); + + array entries = YAInteractionConfig.GetInteractables(); + foreach (YAInteractableEntry e : entries) + { + if (!e || e.m_ClassName == "") + continue; + + string key = e.m_ClassName; + key.ToLower(); + m_EntryByClass.Set(key, e); + Print(string.Format("[YAInteraction] Registered interactable '%1' (%2)", e.m_ClassName, e.m_ActionType)); + } + + m_Initialized = true; + Print(string.Format("[YAInteraction] Manager init complete: %1 interactable(s)", m_EntryByClass.Count())); + } + + // ------------------------------------------------------------------ + // Registry lookups. + // ------------------------------------------------------------------ + static YAInteractableEntry GetEntry(string className) + { + Init(); + + if (className == "") + return null; + + string key = className; + key.ToLower(); + + YAInteractableEntry e; + if (m_EntryByClass.Find(key, e)) + return e; + return null; + } + + static bool IsInteractable(string className) + { + return GetEntry(className) != null; + } + + static array GetLootTableForEntry(YAInteractableEntry entry) + { + if (!entry) + return null; + return YAInteractionConfig.GetLootTable(entry.m_LootTableName); + } + + // ------------------------------------------------------------------ + // Action injection mapping. Returns the action typenames to register + // on a given classname. Keeps YAInteractionInit free of any classname + // strings or action-type branching. + // ------------------------------------------------------------------ + static array GetActionTypesFor(string className) + { + array result = new array(); + + YAInteractableEntry entry = GetEntry(className); + if (!entry) + return result; + + if (entry.m_ActionType == YAInteractionConfig.ACTION_SEARCH) + { + result.Insert(ActionYASearch); + } + else if (entry.m_ActionType == YAInteractionConfig.ACTION_REPAIR_WATER) + { + // Both always registered; their ActionCondition gates which is usable. + result.Insert(ActionYARepairSink); + result.Insert(ActionYAGatherWater); + } + + return result; + } + + // ------------------------------------------------------------------ + // Per-object repair state (sinks). + // TODO (v2): read/write through CF persistence instead of the static map. + // ------------------------------------------------------------------ + static bool IsRepaired(string networkKey) + { + bool repaired; + if (m_RepairState.Find(networkKey, repaired)) + return repaired; + return false; + } + + static void SetRepaired(string networkKey, bool value = true) + { + m_RepairState.Set(networkKey, value); + Print(string.Format("[YAInteraction] Repair state for %1 set to %2", networkKey, value)); + } +} diff --git a/scripts/4_World/YAInteraction/YAInteractionInit.c b/scripts/4_World/YAInteraction/YAInteractionInit.c new file mode 100644 index 0000000..8c58d0a --- /dev/null +++ b/scripts/4_World/YAInteraction/YAInteractionInit.c @@ -0,0 +1,37 @@ +// ============================================================================ +// YAInteractionInit +// ---------------------------------------------------------------------------- +// Injects the mod's actions onto the target objects, driven entirely by the +// config registry. There are no classname strings and no action-type branching +// here - YAInteractionManager.GetActionTypesFor() returns exactly which actions +// to register for a given classname. +// +// BASE CLASS - verified against P:\scripts and P:\DZ: +// The four targets are config classes that inherit: +// StaticObj_Furniture_* -> HouseNoDestruct -> House -> Static +// There is NO script class named StaticObj or HouseNoDestruct; "House" is the +// first script-backed ancestor (House : BuildingBase). So every one of these +// objects resolves to the BuildingBase script class - that is where we hook. +// +// We override SetActions() rather than GetActions(): BuildingBase implements +// a GetActions()/SetActions() split where GetActions() lazily calls +// SetActions() ONCE and caches the result per object type. SetActions() is +// therefore the correct extension point. (Overriding GetActions() directly +// would re-append to the cached array on every query and duplicate actions.) +// Calling super.SetActions() first preserves any inherited/base actions. +// ============================================================================ + +modded class BuildingBase +{ + override void SetActions() + { + super.SetActions(); // preserve inherited actions (doors, fireplaces, etc.) + + array actionsToAdd = YAInteractionManager.GetActionTypesFor(GetType()); + foreach (typename actionType : actionsToAdd) + { + AddAction(actionType); + Print("[YAInteraction] Registered " + actionType + " on '" + GetType() + "'"); + } + } +} diff --git a/scripts/4_World/YAInteraction/YALootTableManager.c b/scripts/4_World/YAInteraction/YALootTableManager.c new file mode 100644 index 0000000..a946579 --- /dev/null +++ b/scripts/4_World/YAInteraction/YALootTableManager.c @@ -0,0 +1,145 @@ +// ============================================================================ +// YALootTableManager +// ---------------------------------------------------------------------------- +// Singleton that turns a loot table (array) into spawned items. +// Selection is weighted by YALootEntry.m_Weight. Server-authoritative: all +// spawning is expected to run from server-side action code. Items spawn into a +// target container when one is supplied, falling back to the ground. +// ============================================================================ +class YALootTableManager +{ + private static ref YALootTableManager s_Instance; + + static YALootTableManager GetInstance() + { + if (!s_Instance) + { + s_Instance = new YALootTableManager(); + Print("[YAInteraction] YALootTableManager instantiated"); + } + return s_Instance; + } + + // ------------------------------------------------------------------ + // Weighted pick: returns one entry from the table, or null if empty. + // ------------------------------------------------------------------ + YALootEntry PickWeighted(array table) + { + if (!table || table.Count() == 0) + return null; + + int totalWeight = 0; + foreach (YALootEntry e : table) + { + if (e && e.m_Weight > 0) + totalWeight += e.m_Weight; + } + + if (totalWeight <= 0) + return null; + + int roll = Math.RandomInt(0, totalWeight); // [0, totalWeight) + int cursor = 0; + foreach (YALootEntry entry : table) + { + if (!entry || entry.m_Weight <= 0) + continue; + + cursor += entry.m_Weight; + if (roll < cursor) + return entry; + } + + return null; // should be unreachable + } + + // ------------------------------------------------------------------ + // Spawn a single entry. Tries the container first, falls back to the + // ground at groundPos. Returns the spawned entity, or null on failure. + // ------------------------------------------------------------------ + EntityAI SpawnEntry(YALootEntry entry, EntityAI container, vector groundPos) + { + if (!entry || entry.m_ClassName == "") + return null; + + EntityAI spawned; + + if (container) + { + spawned = container.GetInventory().CreateInInventory(entry.m_ClassName); + if (spawned) + Print(string.Format("[YAInteraction] Spawned '%1' into %2", entry.m_ClassName, container.GetType())); + } + + // Container full or not provided -> drop on the ground. + if (!spawned) + { + Object obj = GetGame().CreateObjectEx(entry.m_ClassName, groundPos, ECE_PLACE_ON_SURFACE); + spawned = EntityAI.Cast(obj); + if (spawned) + Print(string.Format("[YAInteraction] Spawned '%1' on ground at %2", entry.m_ClassName, groundPos.ToString())); + } + + if (!spawned) + { + Print(string.Format("[YAInteraction] FAILED to spawn '%1' (bad classname?)", entry.m_ClassName)); + return null; + } + + ApplyQuantity(spawned, entry); + return spawned; + } + + // ------------------------------------------------------------------ + // Run 'rolls' weighted picks against a table and spawn each result. + // Returns the list of spawned entities (for caller feedback / logging). + // ------------------------------------------------------------------ + array SpawnFromTable(array table, int rolls, EntityAI container, vector groundPos) + { + array result = new array(); + + if (!table || table.Count() == 0 || rolls <= 0) + return result; + + for (int i = 0; i < rolls; i++) + { + YALootEntry entry = PickWeighted(table); + if (!entry) + continue; + + EntityAI spawned = SpawnEntry(entry, container, groundPos); + if (spawned) + result.Insert(spawned); + } + + Print(string.Format("[YAInteraction] SpawnFromTable produced %1 item(s) from %2 roll(s)", result.Count(), rolls)); + return result; + } + + // ------------------------------------------------------------------ + // Apply a random quantity in [m_MinQty, m_MaxQty] when configured (>0). + // ------------------------------------------------------------------ + private void ApplyQuantity(EntityAI ent, YALootEntry entry) + { + if (entry.m_MaxQty <= 0) + return; // leave the item's default quantity + + ItemBase item = ItemBase.Cast(ent); + if (!item || !item.HasQuantity()) + return; + + int minQ = entry.m_MinQty; + if (minQ < 1) + minQ = 1; + + int qty = Math.RandomIntInclusive(minQ, entry.m_MaxQty); + + // Clamp to the item's own stack max so a generous table can't overflow. + float maxQ = item.GetQuantityMax(); + if (maxQ > 0 && qty > maxQ) + qty = maxQ; + + item.SetQuantity(qty); + Print(string.Format("[YAInteraction] Set quantity %1 on '%2'", qty, entry.m_ClassName)); + } +} diff --git a/scripts/5_Mission/YAInteraction/YAInteractionMission.c b/scripts/5_Mission/YAInteraction/YAInteractionMission.c new file mode 100644 index 0000000..60cb19e --- /dev/null +++ b/scripts/5_Mission/YAInteraction/YAInteractionMission.c @@ -0,0 +1,10 @@ +// 5_Mission layer placeholder for YAInteraction. +// +// Nothing mission-specific is required in v1; the action injection happens via modded +// entity classes in 4_World. This keeps the declared missionScriptModule non-empty. +// +// TODO (v2): Mission-side hooks for an admin/debug menu (e.g. clearing cooldowns live, +// reloading loot tables) would be wired up here. +class YAInteractionMission +{ +}