Redesign the interaction mechanic into a recursive chain reaction system where items can trigger cascading reactions. Keys now open themed chests which produce items that may trigger further reactions, with chain bonus rewards for multi-step chains (x2/x3/x4+). - Add 6 themed chests + mysterious chest + alchemist stone catalyst - Rewrite InteractionEngine with recursive chain loop (max depth 10) - Add ConsumeTrigger field to InteractionRule for catalyst support - Add ChainBonusEvent and enrich InteractionTriggeredEvent with context - Update rendering to show both reacting items and chain indicators - Add item descriptions with anticipation hints for chain partners - Update GDD Section 5 with full chain reaction specification
199 lines
7.2 KiB
C#
199 lines
7.2 KiB
C#
using OpenTheBox.Core;
|
|
using OpenTheBox.Core.Enums;
|
|
using OpenTheBox.Core.Interactions;
|
|
using OpenTheBox.Core.Items;
|
|
using OpenTheBox.Data;
|
|
using OpenTheBox.Simulation.Events;
|
|
|
|
namespace OpenTheBox.Simulation;
|
|
|
|
/// <summary>
|
|
/// Evaluates interaction rules against newly received items and the current game state,
|
|
/// triggering automatic chain reactions that can cascade recursively.
|
|
/// </summary>
|
|
public class InteractionEngine(ContentRegistry registry)
|
|
{
|
|
private const int MaxChainDepth = 10;
|
|
|
|
/// <summary>
|
|
/// Runs the full chain reaction loop: checks new items for interactions,
|
|
/// executes them, and re-checks any produced items until no more reactions fire.
|
|
/// Returns all events produced across the entire chain.
|
|
/// </summary>
|
|
public List<GameEvent> RunChainReactions(List<ItemInstance> newItems, GameState state, Random rng)
|
|
{
|
|
var allEvents = new List<GameEvent>();
|
|
var itemsToCheck = new List<ItemInstance>(newItems);
|
|
int chainStep = 0;
|
|
|
|
while (itemsToCheck.Count > 0 && chainStep < MaxChainDepth)
|
|
{
|
|
var producedItems = new List<ItemInstance>();
|
|
|
|
foreach (var newItem in itemsToCheck)
|
|
{
|
|
var itemDef = registry.GetItem(newItem.DefinitionId);
|
|
if (itemDef is null)
|
|
continue;
|
|
|
|
var matchingRules = FindMatchingRules(itemDef, state);
|
|
|
|
if (matchingRules.Count == 0)
|
|
{
|
|
// Special case: key without a matching openable item
|
|
if (itemDef.Tags.Contains("Key"))
|
|
{
|
|
var hasMatchingOpenable = state.Inventory.Any(i =>
|
|
{
|
|
var def = registry.GetItem(i.DefinitionId);
|
|
return def is not null && def.Tags.Contains("Openable");
|
|
});
|
|
|
|
if (!hasMatchingOpenable)
|
|
{
|
|
allEvents.Add(new MessageEvent("interaction.key_no_match"));
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
var automaticRules = matchingRules
|
|
.Where(r => r.IsAutomatic)
|
|
.OrderByDescending(r => r.Priority)
|
|
.ToList();
|
|
|
|
if (automaticRules.Count >= 1)
|
|
{
|
|
var rule = automaticRules[0];
|
|
var ruleEvents = ExecuteRule(rule, newItem, state, chainStep);
|
|
allEvents.AddRange(ruleEvents);
|
|
|
|
// Collect items produced by this reaction for the next chain iteration
|
|
foreach (var evt in ruleEvents)
|
|
{
|
|
if (evt is ItemReceivedEvent received)
|
|
producedItems.Add(received.Item);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (producedItems.Count == 0)
|
|
break;
|
|
|
|
itemsToCheck = producedItems;
|
|
chainStep++;
|
|
}
|
|
|
|
// Award chain bonus if chain was 2+ steps
|
|
if (chainStep >= 2)
|
|
{
|
|
string bonusItemId = chainStep >= 4 ? "box_legendary"
|
|
: chainStep >= 3 ? "box_cool"
|
|
: "box_ok_tier";
|
|
|
|
var bonusInstance = ItemInstance.Create(bonusItemId);
|
|
state.AddItem(bonusInstance);
|
|
allEvents.Add(new ItemReceivedEvent(bonusInstance));
|
|
allEvents.Add(new ChainBonusEvent(chainStep, bonusItemId));
|
|
}
|
|
|
|
return allEvents;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds all interaction rules that match the given item definition and game state.
|
|
/// </summary>
|
|
private List<InteractionRule> FindMatchingRules(ItemDefinition itemDef, GameState state)
|
|
{
|
|
var matching = new List<InteractionRule>();
|
|
|
|
foreach (var rule in registry.InteractionRules)
|
|
{
|
|
// Check required tags
|
|
var tagsMatch = rule.RequiredItemTags.All(tag => itemDef.Tags.Contains(tag));
|
|
if (!tagsMatch)
|
|
continue;
|
|
|
|
// Check required item ids (if specified, all must be in inventory)
|
|
if (rule.RequiredItemIds is not null && rule.RequiredItemIds.Count > 0)
|
|
{
|
|
var hasRequiredItem = rule.RequiredItemIds.All(id => state.HasItem(id));
|
|
if (!hasRequiredItem)
|
|
continue;
|
|
}
|
|
|
|
matching.Add(rule);
|
|
}
|
|
|
|
return matching;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes a single interaction rule, consuming items and producing results.
|
|
/// </summary>
|
|
private List<GameEvent> ExecuteRule(InteractionRule rule, ItemInstance triggerItem, GameState state, int chainStep)
|
|
{
|
|
var events = new List<GameEvent>();
|
|
|
|
// Find the partner item (first matching required item id) for the interaction message
|
|
string? partnerItemId = null;
|
|
if (rule.RequiredItemIds is not null && rule.RequiredItemIds.Count > 0)
|
|
{
|
|
partnerItemId = rule.RequiredItemIds[0];
|
|
}
|
|
|
|
// Consume the trigger item (unless it's a catalyst)
|
|
if (rule.ConsumeTrigger)
|
|
{
|
|
state.RemoveItem(triggerItem.Id);
|
|
events.Add(new ItemConsumedEvent(triggerItem.Id));
|
|
}
|
|
|
|
// Consume required inventory items (the partner items)
|
|
if (rule.RequiredItemIds is not null)
|
|
{
|
|
foreach (var requiredId in rule.RequiredItemIds)
|
|
{
|
|
var invItem = state.Inventory.FirstOrDefault(i => i.DefinitionId == requiredId);
|
|
if (invItem is not null)
|
|
{
|
|
state.RemoveItem(invItem.Id);
|
|
events.Add(new ItemConsumedEvent(invItem.Id));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit the interaction event with context
|
|
events.Add(new InteractionTriggeredEvent(rule.Id, rule.DescriptionKey, triggerItem.DefinitionId, partnerItemId, chainStep));
|
|
|
|
// Handle result based on ResultData
|
|
if (rule.ResultData is not null)
|
|
{
|
|
if (rule.ResultData.StartsWith("adventure:") || rule.ResultData.StartsWith("adventure_unlock:"))
|
|
{
|
|
var themeName = rule.ResultData.Contains("adventure_unlock:")
|
|
? rule.ResultData["adventure_unlock:".Length..]
|
|
: rule.ResultData["adventure:".Length..];
|
|
if (Enum.TryParse<AdventureTheme>(themeName, out var theme)
|
|
&& state.UnlockedAdventures.Add(theme))
|
|
{
|
|
events.Add(new AdventureUnlockedEvent(theme));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Produce result items
|
|
var resultItemIds = rule.ResultData.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
foreach (var resultItemId in resultItemIds)
|
|
{
|
|
var resultInstance = ItemInstance.Create(resultItemId);
|
|
state.AddItem(resultInstance);
|
|
events.Add(new ItemReceivedEvent(resultInstance));
|
|
}
|
|
}
|
|
}
|
|
|
|
return events;
|
|
}
|
|
}
|