openthebox/src/OpenTheBox/Simulation/InteractionEngine.cs
Samuel Bouchet 41bfb54a2c Implement chain reaction system replacing simple auto-activation
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
2026-03-15 18:43:42 +01:00

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;
}
}