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 <noreply@anthropic.com>
This commit is contained in:
commit
ac90535b5e
12 changed files with 1049 additions and 0 deletions
51
README.md
Normal file
51
README.md
Normal file
|
|
@ -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));
|
||||
```
|
||||
70
config.cpp
Normal file
70
config.cpp
Normal file
|
|
@ -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"
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
18
mod.cpp
Normal file
18
mod.cpp
Normal file
|
|
@ -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 = "";
|
||||
};
|
||||
};
|
||||
12
scripts/3_Game/YAInteraction/YAInteractionGame.c
Normal file
12
scripts/3_Game/YAInteraction/YAInteractionGame.c
Normal file
|
|
@ -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]";
|
||||
}
|
||||
187
scripts/4_World/YAInteraction/Actions/ActionYAGatherWater.c
Normal file
187
scripts/4_World/YAInteraction/Actions/ActionYAGatherWater.c
Normal file
|
|
@ -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<string, int> m_PlayerCooldowns = new map<string, int>();
|
||||
|
||||
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<EntityAI> items = new array<EntityAI>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
99
scripts/4_World/YAInteraction/Actions/ActionYARepairSink.c
Normal file
99
scripts/4_World/YAInteraction/Actions/ActionYARepairSink.c
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
141
scripts/4_World/YAInteraction/Actions/ActionYASearch.c
Normal file
141
scripts/4_World/YAInteraction/Actions/ActionYASearch.c
Normal file
|
|
@ -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<string, int> m_ObjectCooldowns = new map<string, int>();
|
||||
|
||||
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<ref YALootEntry> table = YAInteractionManager.GetLootTableForEntry(entry);
|
||||
int rolls = Math.RandomIntInclusive(YAInteractionConfig.SEARCH_ROLLS_MIN, YAInteractionConfig.SEARCH_ROLLS_MAX);
|
||||
array<EntityAI> 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));
|
||||
}
|
||||
}
|
||||
159
scripts/4_World/YAInteraction/Config/YAInteractionConfig.c
Normal file
159
scripts/4_World/YAInteraction/Config/YAInteractionConfig.c
Normal file
|
|
@ -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<ref YAInteractableEntry> m_Interactables;
|
||||
|
||||
static array<ref YAInteractableEntry> GetInteractables()
|
||||
{
|
||||
if (!m_Interactables)
|
||||
{
|
||||
m_Interactables = new array<ref YAInteractableEntry>();
|
||||
|
||||
// ---------- 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<string, ref array<ref YALootEntry>> m_LootTables;
|
||||
|
||||
static array<ref YALootEntry> GetLootTable(string name)
|
||||
{
|
||||
if (!m_LootTables)
|
||||
BuildLootTables();
|
||||
|
||||
array<ref YALootEntry> 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<string, ref array<ref YALootEntry>>();
|
||||
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<ref YALootEntry> BuildMedTable()
|
||||
{
|
||||
array<ref YALootEntry> t = new array<ref YALootEntry>();
|
||||
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<ref YALootEntry> BuildFoodTable()
|
||||
{
|
||||
array<ref YALootEntry> t = new array<ref YALootEntry>();
|
||||
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<ref YALootEntry> BuildVendingTable()
|
||||
{
|
||||
array<ref YALootEntry> t = new array<ref YALootEntry>();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
120
scripts/4_World/YAInteraction/Entities/YAInteractionManager.c
Normal file
120
scripts/4_World/YAInteraction/Entities/YAInteractionManager.c
Normal file
|
|
@ -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<string, ref YAInteractableEntry> 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<string, bool> m_RepairState = new map<string, bool>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lazy init: fold the registry into a case-insensitive lookup map.
|
||||
// ------------------------------------------------------------------
|
||||
static void Init()
|
||||
{
|
||||
if (m_Initialized)
|
||||
return;
|
||||
|
||||
m_EntryByClass = new map<string, ref YAInteractableEntry>();
|
||||
|
||||
array<ref YAInteractableEntry> 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<ref YALootEntry> 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<typename> GetActionTypesFor(string className)
|
||||
{
|
||||
array<typename> result = new array<typename>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
37
scripts/4_World/YAInteraction/YAInteractionInit.c
Normal file
37
scripts/4_World/YAInteraction/YAInteractionInit.c
Normal file
|
|
@ -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<typename> actionsToAdd = YAInteractionManager.GetActionTypesFor(GetType());
|
||||
foreach (typename actionType : actionsToAdd)
|
||||
{
|
||||
AddAction(actionType);
|
||||
Print("[YAInteraction] Registered " + actionType + " on '" + GetType() + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
145
scripts/4_World/YAInteraction/YALootTableManager.c
Normal file
145
scripts/4_World/YAInteraction/YALootTableManager.c
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// ============================================================================
|
||||
// YALootTableManager
|
||||
// ----------------------------------------------------------------------------
|
||||
// Singleton that turns a loot table (array<YALootEntry>) 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<ref YALootEntry> 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<EntityAI> SpawnFromTable(array<ref YALootEntry> table, int rolls, EntityAI container, vector groundPos)
|
||||
{
|
||||
array<EntityAI> result = new array<EntityAI>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
10
scripts/5_Mission/YAInteraction/YAInteractionMission.c
Normal file
10
scripts/5_Mission/YAInteraction/YAInteractionMission.c
Normal file
|
|
@ -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
|
||||
{
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue