From 41bfb54a2c52e1ed104536ccbb393b4d7467a401 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Sun, 15 Mar 2026 18:43:42 +0100 Subject: [PATCH] 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 --- docs/GDD.md => GDD.md | 127 ++++++++++---- README.md | 2 +- content/adventures/medieval/intro.fr.lor | 2 +- content/adventures/medieval/intro.lor | 2 +- content/data/boxes.json | 23 ++- content/data/interactions.json | 90 +++++++--- content/data/items.json | 26 ++- content/strings/en.json | 33 +++- content/strings/fr.json | 33 +++- src/OpenTheBox/Core/Enums/ItemCategory.cs | 5 +- .../Core/Interactions/InteractionRule.cs | 3 +- src/OpenTheBox/Program.cs | 32 +++- src/OpenTheBox/Simulation/Events/GameEvent.cs | 9 +- src/OpenTheBox/Simulation/GameSimulation.cs | 6 +- .../Simulation/InteractionEngine.cs | 166 +++++++++++------- tests/snapshots/item_utility_report.txt | 143 ++++++++------- 16 files changed, 490 insertions(+), 212 deletions(-) rename docs/GDD.md => GDD.md (88%) diff --git a/docs/GDD.md b/GDD.md similarity index 88% rename from docs/GDD.md rename to GDD.md index c2aed35..5b75d8d 100644 --- a/docs/GDD.md +++ b/GDD.md @@ -15,7 +15,7 @@ 2. [Mecanique principale : ouverture de boites](#2-mecanique-principale--ouverture-de-boites) 3. [Systeme de progression CLI (Phases 0-8)](#3-systeme-de-progression-cli-phases-0-8) 4. [Systeme de boites](#4-systeme-de-boites) -5. [Auto-activation (cle + coffre, interactions automatiques)](#5-auto-activation-cle--coffre-interactions-automatiques) +5. [Reactions en chaine (interactions automatiques)](#5-reactions-en-chaine-interactions-automatiques) 6. [Personnalisation](#6-personnalisation) 7. [Materiaux et Craft](#7-materiaux-et-craft) 8. [Ressources](#8-ressources) @@ -400,49 +400,116 @@ Les fragments de lore se collectionnent et, une fois assembles, revelent des pan --- -## 5. Auto-activation (cle + coffre, interactions automatiques) +## 5. Reactions en chaine (interactions automatiques) ### Principe -L'auto-activation est un systeme central d'Open The Box. Lorsqu'un objet est ajoute a l'inventaire du joueur, le systeme verifie automatiquement si cet objet peut interagir avec un autre objet deja present. +Les reactions en chaine sont le systeme spectaculaire d'Open The Box. Lorsqu'un objet est ajoute a l'inventaire du joueur, le systeme verifie automatiquement si cet objet peut reagir avec un autre objet deja present. Si une reaction se produit, elle peut generer de nouveaux objets qui declenchent eux-memes d'autres reactions -- creant une cascade visible et excitante. -### Types d'interactions automatiques +### Philosophie -| Interaction | Declencheur | Resultat | -|---------------------------|--------------------------------------|-------------------------------------| -| **Cle + Coffre** | Cle et coffre correspondants | Le coffre s'ouvre, revelant son contenu | -| **Carte + Lieu** | Carte d'un lieu + badge d'exploration| Le lieu est deverrouille | -| **Token + Aventure** | AdventureToken + seuil de progression| L'aventure devient accessible | -| **Blueprint + Materiaux** | Blueprint + materiaux requis | La station de craft est construite | -| **Fragment + Fragment** | Fragments complementaires | Un lore complet est revele | -| **Consommable + Ressource** | Consommable + ressource cible | La ressource est restauree | +Le joueur n'initie jamais une reaction manuellement. Il ouvre une boite, recoit des objets, et observe les consequences. Les reactions en chaine sont le "feu d'artifice" du jeu : imprevisibles, spectaculaires, et recompensant l'accumulation strategique d'objets dans l'inventaire. -### Flux d'auto-activation +Les descriptions des objets reactifs indiquent toujours leur potentiel de combinaison, creant de l'anticipation chez le joueur qui possede un objet en attente de son partenaire. + +### Coffres thematiques + +Chaque theme d'aventure possede un **coffre** (tag `Openable`) qui apparait dans les boites d'aventure correspondantes. Les coffres sont des objets inertes tant que le joueur ne possede pas la cle correspondante. + +| Coffre | Cle correspondante | Contenu a l'ouverture | +|-------------------------|-----------------------|----------------------------------------------------| +| Coffre spatial | Cle spatiale | Carte stellaire + Coordonnees spatiales | +| Coffre medieval | Cle medievale | Blason royal + Parchemin ancien | +| Coffre pirate | Cle pirate | Carte au tresor + Boussole pirate | +| Coffre contemporain | Cle contemporaine | Badge VIP + Cle USB | +| Coffre dark fantasy | Cle dark fantasy | Grimoire maudit + Anneau sombre | +| Coffre mysterieux | Cle mysterieuse | Objet aleatoire legendaire | + +> **Note** : La cle mysterieuse (`mysterious_key`) reagit avec n'importe quel coffre. C'est un joker rare. + +### Catalyseurs + +Les **catalyseurs** sont des objets rares qui participent aux reactions sans etre consommes. Ils restent dans l'inventaire et peuvent declencher plusieurs chaines au fil du temps. + +| Catalyseur | Effet | Source | +|-------------------------|-----------------------------------------------------|----------------------------| +| Pierre alchimique | Participe a toute reaction de type Combine | Boite legendaire | + +### Flux de reaction en chaine ``` 1. Objet ajoute a l'inventaire -2. Pour chaque objet de l'inventaire : - a. Verifier si une interaction est possible avec le nouvel objet - b. Si oui, executer l'interaction - c. L'interaction peut produire de nouveaux objets - d. Si de nouveaux objets sont produits, repeter depuis l'etape 2 -3. Fin de la chaine d'auto-activation +2. Le systeme cherche des reactions possibles avec les objets existants +3. Si une reaction est trouvee : + a. Les objets reactifs sont consommes (sauf les catalyseurs) + b. La reaction produit de nouveaux objets et/ou deblocages + c. Compteur de chaine : +1 + d. Les nouveaux objets sont re-injectes dans l'etape 2 +4. Fin de la chaine quand plus aucune reaction n'est possible +5. Si chaine >= 2 : bonus de chaine applique ``` -### Chaines d'activation +### Compteur de chaine et bonus -Les auto-activations peuvent creer des **chaines** : ouvrir un coffre peut reveler une cle qui ouvre un autre coffre, qui contient un fragment qui complete un lore, qui debloque un personnage. Ces chaines sont l'un des moments les plus satisfaisants du jeu. +Le compteur de chaine est affiche en temps reel pendant la cascade. -### Type de resultat d'interaction (InteractionResultType) +| Longueur de chaine | Affichage | Bonus | +|---------------------|-------------------|-------------------------------------------------| +| x1 | *(pas de bonus)* | Reaction simple, pas de bonus | +| x2 | ⚡ Chaine x2 ! | Objet bonus aleatoire (rarete Uncommon+) | +| x3 | ⚡⚡ Chaine x3 ! | Boite rare en bonus | +| x4+ | ⚡⚡⚡ Chaine x4+ ! | Boite legendaire en bonus | -Chaque interaction produit un resultat type parmi : -- **OpenBox** -- ouverture d'une boite ou d'un coffre -- **Craft** -- fabrication d'un objet -- **Transform** -- transformation d'un objet en un autre -- **Consume** -- consommation d'un objet (potion, nourriture) -- **Unlock** -- deblocage d'un contenu (lieu, aventure, personnage) -- **Combine** -- combinaison de plusieurs objets en un seul -- **Teleport** -- deplacement vers un nouveau lieu +### Types de reaction (InteractionResultType) + +Chaque reaction produit un resultat parmi : +- **OpenBox** -- la cle ouvre le coffre, revelant son contenu (les objets produits peuvent declencher d'autres reactions) +- **Unlock** -- deblocage d'un contenu (aventure, personnage) +- **Combine** -- combinaison de plusieurs objets en un resultat (carte + boussole = localisation du tresor) + +### Regles de reaction + +Chaque regle dans `interactions.json` definit : +- `requiredItemTags` : tags que le nouvel objet doit posseder pour declencher la regle +- `requiredItemIds` : objets specifiques devant etre presents dans l'inventaire +- `consumeTrigger` : si `false`, l'objet declencheur n'est pas consomme (catalyseur) +- `resultData` : les objets/effets produits par la reaction +- `descriptionKey` : le message narratif affiche au joueur (avec placeholders `{0}` et `{1}` pour les noms des objets impliques) +- `isAutomatic` : toujours `true` pour les reactions en chaine +- `priority` : ordre de resolution quand plusieurs reactions sont possibles + +### Descriptions d'objets et anticipation + +Les objets reactifs portent dans leur description un indice de combinaison. Exemples : + +| Objet | Description | +|------------------|--------------------------------------------------------------------------| +| Cle medievale | *"Une cle ornee d'un blason. Elle cherche un coffre a sa mesure..."* | +| Coffre medieval | *"Un coffre verrouille, orne du meme blason qu'une certaine cle..."* | +| Carte au tresor | *"X marque l'emplacement... mais sans boussole, impossible de s'orienter."* | +| Boussole pirate | *"L'aiguille pointe vers un tresor... si seulement tu avais la carte."* | +| Cle mysterieuse | *"Cette cle semble pouvoir ouvrir n'importe quel coffre..."* | + +### Exemple de chaine complete + +``` +Boite d'aventure pirate ouverte ! + +Tu as recu : + - Coffre pirate [Rare] + - Plume de perroquet [Commun] + +⚡ Reaction ! Ta Cle pirate et ton Coffre pirate reagissent... + La cle tourne, le coffre s'ouvre ! + → Carte au tresor [Epic] + → Boussole pirate [Rare] + +⚡⚡ Chaine x2 ! Ta Carte au tresor et ta Boussole pirate s'alignent... + Les coordonnees se revelent ! + → 🎉 Aventure 'Iles Perdues' debloquee ! + +⚡⚡ Bonus de chaine x2 : Boite coolos en recompense ! +``` --- diff --git a/README.md b/README.md index c78b8d0..73d79b0 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ openthebox/ | +-- data/ # boxes.json, items.json, interactions.json, recipes.json | +-- strings/ # en.json, fr.json | +-- adventures/ # 9 themes, each with .lor + .fr.lor files -+-- docs/GDD.md # Game Design Document (French) ++-- GDD.md # Game Design Document (French) +-- init.ps1 # Setup script +-- global.json # Pins .NET 10 SDK ``` diff --git a/content/adventures/medieval/intro.fr.lor b/content/adventures/medieval/intro.fr.lor index 4a5fed8..ac71b5a 100644 --- a/content/adventures/medieval/intro.fr.lor +++ b/content/adventures/medieval/intro.fr.lor @@ -167,7 +167,7 @@ Combattre le dragon Essayer de se faufiler pendant que le dragon est distrait #opt-charm // "Flash your most dazzling smile at the dragon|||Something about your magnetic personality might work here..." -Adresser votre plus beau sourire au dragon|||Quelque chose dans votre personnalité magnétique pourrait fonctionner ici... +Adresser votre plus beau sourire au dragon|||Pas assez de charisme… #charm-smile // "You step forward, lock eyes with Scorchtangle, and deliver the most radiant smile in the kingdom's history." Vous faites un pas en avant, croisez le regard de Scorchtangle, et offrez le sourire le plus radieux de l'histoire du royaume. diff --git a/content/adventures/medieval/intro.lor b/content/adventures/medieval/intro.lor index 211a4c0..56b38ef 100644 --- a/content/adventures/medieval/intro.lor +++ b/content/adventures/medieval/intro.lor @@ -124,7 +124,7 @@ beat DragonApproach -> DragonFight Try to sneak past while the dragon is distracted #opt-sneak -> SneakPast - Flash your most dazzling smile at the dragon|||Something about your magnetic personality might work here... #opt-charm if hasStat("Charisma", 10) + Flash your most dazzling smile at the dragon|||Not enough charisma… #opt-charm if hasStat("Charisma", 10) -> DragonCharmer beat DragonCharmer diff --git a/content/data/boxes.json b/content/data/boxes.json index 7e094fd..62e28ed 100644 --- a/content/data/boxes.json +++ b/content/data/boxes.json @@ -176,6 +176,7 @@ {"itemDefinitionId": "cosmetic_arms_wings", "weight": 1}, {"itemDefinitionId": "tint_void", "weight": 1}, {"itemDefinitionId": "tint_rainbow", "weight": 1}, + {"itemDefinitionId": "alchemist_stone", "weight": 1}, {"itemDefinitionId": "lore_6", "weight": 1}, {"itemDefinitionId": "lore_10", "weight": 1}, {"itemDefinitionId": "box_meta_basics", "weight": 2}, @@ -347,9 +348,8 @@ "entries": [ {"itemDefinitionId": "space_badge", "weight": 3}, {"itemDefinitionId": "space_phone", "weight": 3}, - {"itemDefinitionId": "space_coordinates", "weight": 3}, - {"itemDefinitionId": "space_key", "weight": 2}, - {"itemDefinitionId": "space_map", "weight": 2} + {"itemDefinitionId": "space_chest", "weight": 3}, + {"itemDefinitionId": "space_key", "weight": 3} ] } }, @@ -364,10 +364,10 @@ "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, "entries": [ - {"itemDefinitionId": "medieval_crest", "weight": 3}, - {"itemDefinitionId": "medieval_scroll", "weight": 3}, + {"itemDefinitionId": "medieval_chest", "weight": 3}, {"itemDefinitionId": "medieval_seal", "weight": 2}, {"itemDefinitionId": "medieval_key", "weight": 3}, + {"itemDefinitionId": "medieval_sword", "weight": 2}, {"itemDefinitionId": "mysterious_key", "weight": 2} ] } @@ -380,13 +380,13 @@ "isAutoOpen": false, "adventureTheme": "Pirate", "lootTable": { - "guaranteedRolls": ["pirate_map", "box_of_boxes"], + "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, "entries": [ - {"itemDefinitionId": "pirate_compass", "weight": 3}, + {"itemDefinitionId": "pirate_chest", "weight": 3}, {"itemDefinitionId": "pirate_feather", "weight": 4}, {"itemDefinitionId": "pirate_rum", "weight": 3}, - {"itemDefinitionId": "pirate_key", "weight": 2}, + {"itemDefinitionId": "pirate_key", "weight": 3}, {"itemDefinitionId": "mysterious_key", "weight": 2}, {"itemDefinitionId": "gold_pouch", "weight": 4} ] @@ -405,9 +405,8 @@ "entries": [ {"itemDefinitionId": "contemporary_phone", "weight": 3}, {"itemDefinitionId": "contemporary_card", "weight": 3}, - {"itemDefinitionId": "contemporary_usb", "weight": 2}, + {"itemDefinitionId": "contemporary_chest", "weight": 3}, {"itemDefinitionId": "contemporary_key", "weight": 3}, - {"itemDefinitionId": "contemporary_badge", "weight": 3}, {"itemDefinitionId": "mysterious_key", "weight": 2} ] } @@ -494,8 +493,7 @@ "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, "entries": [ - {"itemDefinitionId": "darkfantasy_ring", "weight": 3}, - {"itemDefinitionId": "darkfantasy_grimoire", "weight": 2}, + {"itemDefinitionId": "darkfantasy_chest", "weight": 3}, {"itemDefinitionId": "darkfantasy_gem", "weight": 1}, {"itemDefinitionId": "darkfantasy_key", "weight": 3}, {"itemDefinitionId": "mysterious_key", "weight": 2}, @@ -549,6 +547,7 @@ "rollCount": 3, "entries": [ {"itemDefinitionId": "mysterious_key", "weight": 3}, + {"itemDefinitionId": "mysterious_chest", "weight": 2}, {"itemDefinitionId": "lore_10", "weight": 2}, {"itemDefinitionId": "cosmetic_gender_error", "weight": 1}, {"itemDefinitionId": "tint_void", "weight": 2}, diff --git a/content/data/interactions.json b/content/data/interactions.json index 12a3bbc..d55e34b 100644 --- a/content/data/interactions.json +++ b/content/data/interactions.json @@ -1,33 +1,63 @@ [ { - "id": "key_chest_auto", - "requiredItemTags": ["Key"], - "requiredItemIds": null, + "id": "key_chest_space", + "requiredItemTags": ["Key", "Space"], + "requiredItemIds": ["space_chest"], "resultType": "OpenBox", - "resultData": null, + "resultData": "space_map,space_coordinates", "isAutomatic": true, "priority": 10, "descriptionKey": "interaction.key_chest" }, { - "id": "badge_adventure_space", - "requiredItemTags": ["Badge", "Space"], - "requiredItemIds": null, - "resultType": "Unlock", - "resultData": "adventure:Space", + "id": "key_chest_medieval", + "requiredItemTags": ["Key", "Medieval"], + "requiredItemIds": ["medieval_chest"], + "resultType": "OpenBox", + "resultData": "medieval_crest,medieval_scroll", "isAutomatic": true, - "priority": 5, - "descriptionKey": "adventure.start" + "priority": 10, + "descriptionKey": "interaction.key_chest" }, { - "id": "phone_character_encounter", - "requiredItemTags": ["PhoneNumber"], - "requiredItemIds": null, - "resultType": "Unlock", - "resultData": "character", - "isAutomatic": false, - "priority": 3, - "descriptionKey": "interaction.phone_call" + "id": "key_chest_pirate", + "requiredItemTags": ["Key", "Pirate"], + "requiredItemIds": ["pirate_chest"], + "resultType": "OpenBox", + "resultData": "pirate_map,pirate_compass", + "isAutomatic": true, + "priority": 10, + "descriptionKey": "interaction.key_chest" + }, + { + "id": "key_chest_contemporary", + "requiredItemTags": ["Key", "Contemporary"], + "requiredItemIds": ["contemporary_chest"], + "resultType": "OpenBox", + "resultData": "contemporary_badge,contemporary_usb", + "isAutomatic": true, + "priority": 10, + "descriptionKey": "interaction.key_chest" + }, + { + "id": "key_chest_darkfantasy", + "requiredItemTags": ["Key", "DarkFantasy"], + "requiredItemIds": ["darkfantasy_chest"], + "resultType": "OpenBox", + "resultData": "darkfantasy_grimoire,darkfantasy_ring", + "isAutomatic": true, + "priority": 10, + "descriptionKey": "interaction.key_chest" + }, + { + "id": "key_chest_mysterious", + "requiredItemTags": ["Key"], + "requiredItemIds": ["mysterious_chest"], + "resultType": "OpenBox", + "resultData": "lore_6,tint_void", + "isAutomatic": true, + "priority": 5, + "descriptionKey": "interaction.key_chest" }, { "id": "coordinates_map_combine", @@ -43,10 +73,30 @@ "id": "pirate_map_compass", "requiredItemTags": [], "requiredItemIds": ["pirate_map", "pirate_compass"], - "resultType": "Unlock", + "resultType": "Combine", "resultData": "adventure:Pirate", "isAutomatic": true, "priority": 8, "descriptionKey": "interaction.treasure_located" + }, + { + "id": "badge_adventure_space", + "requiredItemTags": ["Badge", "Space"], + "requiredItemIds": null, + "resultType": "Unlock", + "resultData": "adventure:Space", + "isAutomatic": true, + "priority": 5, + "descriptionKey": "adventure.start" + }, + { + "id": "badge_adventure_contemporary", + "requiredItemTags": ["Badge", "Contemporary"], + "requiredItemIds": null, + "resultType": "Unlock", + "resultData": "adventure:Contemporary", + "isAutomatic": true, + "priority": 5, + "descriptionKey": "adventure.start" } ] diff --git a/content/data/items.json b/content/data/items.json index 81c9c83..d927bba 100644 --- a/content/data/items.json +++ b/content/data/items.json @@ -67,23 +67,27 @@ {"id": "space_badge", "nameKey": "item.space.badge", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Space"], "adventureTheme": "Space"}, {"id": "space_phone", "nameKey": "item.space.phone", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Space", "PhoneNumber"], "adventureTheme": "Space"}, - {"id": "space_key", "nameKey": "item.space.key", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "Space", "Key"], "adventureTheme": "Space"}, - {"id": "space_map", "nameKey": "item.space.map", "category": "Map", "rarity": "Epic", "tags": ["Adventure", "Space"], "adventureTheme": "Space"}, - {"id": "space_coordinates", "nameKey": "item.space.coordinates", "category": "AdventureToken", "rarity": "Epic", "tags": ["Adventure", "Space", "Coordinates"], "adventureTheme": "Space"}, + {"id": "space_key", "nameKey": "item.space.key", "descriptionKey": "item.space.key.desc", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "Space", "Key"], "adventureTheme": "Space"}, + {"id": "space_chest", "nameKey": "item.space.chest", "descriptionKey": "item.space.chest.desc", "category": "Chest", "rarity": "Rare", "tags": ["Adventure", "Space", "Openable"], "adventureTheme": "Space"}, + {"id": "space_map", "nameKey": "item.space.map", "descriptionKey": "item.space.map.desc", "category": "Map", "rarity": "Epic", "tags": ["Adventure", "Space"], "adventureTheme": "Space"}, + {"id": "space_coordinates", "nameKey": "item.space.coordinates", "descriptionKey": "item.space.coordinates.desc", "category": "AdventureToken", "rarity": "Epic", "tags": ["Adventure", "Space", "Coordinates"], "adventureTheme": "Space"}, {"id": "medieval_crest", "nameKey": "item.medieval.crest", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Medieval"], "adventureTheme": "Medieval"}, {"id": "medieval_sword", "nameKey": "item.medieval.sword", "category": "AdventureToken", "rarity": "Epic", "tags": ["Adventure", "Medieval"], "adventureTheme": "Medieval"}, {"id": "medieval_scroll", "nameKey": "item.medieval.scroll", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Medieval"], "adventureTheme": "Medieval"}, {"id": "medieval_seal", "nameKey": "item.medieval.seal", "category": "AdventureToken", "rarity": "Epic", "tags": ["Adventure", "Medieval"], "adventureTheme": "Medieval"}, - {"id": "medieval_key", "nameKey": "item.medieval.key", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "Medieval", "Key"], "adventureTheme": "Medieval"}, - {"id": "pirate_map", "nameKey": "item.pirate.map", "category": "Map", "rarity": "Epic", "tags": ["Adventure", "Pirate"], "adventureTheme": "Pirate"}, - {"id": "pirate_compass", "nameKey": "item.pirate.compass", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Pirate"], "adventureTheme": "Pirate"}, + {"id": "medieval_key", "nameKey": "item.medieval.key", "descriptionKey": "item.medieval.key.desc", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "Medieval", "Key"], "adventureTheme": "Medieval"}, + {"id": "medieval_chest", "nameKey": "item.medieval.chest", "descriptionKey": "item.medieval.chest.desc", "category": "Chest", "rarity": "Rare", "tags": ["Adventure", "Medieval", "Openable"], "adventureTheme": "Medieval"}, + {"id": "pirate_map", "nameKey": "item.pirate.map", "descriptionKey": "item.pirate.map.desc", "category": "Map", "rarity": "Epic", "tags": ["Adventure", "Pirate"], "adventureTheme": "Pirate"}, + {"id": "pirate_compass", "nameKey": "item.pirate.compass", "descriptionKey": "item.pirate.compass.desc", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Pirate"], "adventureTheme": "Pirate"}, {"id": "pirate_feather", "nameKey": "item.pirate.feather", "category": "AdventureToken", "rarity": "Common", "tags": ["Adventure", "Pirate"], "adventureTheme": "Pirate"}, {"id": "pirate_rum", "nameKey": "item.pirate.rum", "category": "AdventureToken", "rarity": "Uncommon", "tags": ["Adventure", "Pirate"], "adventureTheme": "Pirate"}, - {"id": "pirate_key", "nameKey": "item.pirate.key", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "Pirate", "Key"], "adventureTheme": "Pirate"}, + {"id": "pirate_key", "nameKey": "item.pirate.key", "descriptionKey": "item.pirate.key.desc", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "Pirate", "Key"], "adventureTheme": "Pirate"}, + {"id": "pirate_chest", "nameKey": "item.pirate.chest", "descriptionKey": "item.pirate.chest.desc", "category": "Chest", "rarity": "Rare", "tags": ["Adventure", "Pirate", "Openable"], "adventureTheme": "Pirate"}, {"id": "contemporary_phone", "nameKey": "item.contemporary.phone", "category": "AdventureToken", "rarity": "Common", "tags": ["Adventure", "Contemporary", "PhoneNumber"], "adventureTheme": "Contemporary"}, {"id": "contemporary_card", "nameKey": "item.contemporary.card", "category": "AdventureToken", "rarity": "Uncommon", "tags": ["Adventure", "Contemporary"], "adventureTheme": "Contemporary"}, {"id": "contemporary_usb", "nameKey": "item.contemporary.usb", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Contemporary"], "adventureTheme": "Contemporary"}, - {"id": "contemporary_key", "nameKey": "item.contemporary.key", "category": "Key", "rarity": "Uncommon", "tags": ["Adventure", "Contemporary", "Key"], "adventureTheme": "Contemporary"}, + {"id": "contemporary_key", "nameKey": "item.contemporary.key", "descriptionKey": "item.contemporary.key.desc", "category": "Key", "rarity": "Uncommon", "tags": ["Adventure", "Contemporary", "Key"], "adventureTheme": "Contemporary"}, + {"id": "contemporary_chest", "nameKey": "item.contemporary.chest", "descriptionKey": "item.contemporary.chest.desc", "category": "Chest", "rarity": "Rare", "tags": ["Adventure", "Contemporary", "Openable"], "adventureTheme": "Contemporary"}, {"id": "contemporary_badge", "nameKey": "item.contemporary.badge", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Contemporary", "Badge"], "adventureTheme": "Contemporary"}, {"id": "sentimental_letter", "nameKey": "item.sentimental.letter", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "Sentimental"], "adventureTheme": "Sentimental"}, {"id": "sentimental_flower", "nameKey": "item.sentimental.flower", "category": "AdventureToken", "rarity": "Common", "tags": ["Adventure", "Sentimental"], "adventureTheme": "Sentimental"}, @@ -101,9 +105,13 @@ {"id": "darkfantasy_ring", "nameKey": "item.darkfantasy.ring", "category": "AdventureToken", "rarity": "Rare", "tags": ["Adventure", "DarkFantasy"], "adventureTheme": "DarkFantasy"}, {"id": "darkfantasy_grimoire", "nameKey": "item.darkfantasy.grimoire", "category": "AdventureToken", "rarity": "Epic", "tags": ["Adventure", "DarkFantasy"], "adventureTheme": "DarkFantasy"}, {"id": "darkfantasy_gem", "nameKey": "item.darkfantasy.gem", "category": "AdventureToken", "rarity": "Legendary", "tags": ["Adventure", "DarkFantasy"], "adventureTheme": "DarkFantasy"}, - {"id": "darkfantasy_key", "nameKey": "item.darkfantasy.key", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "DarkFantasy", "Key"], "adventureTheme": "DarkFantasy"}, + {"id": "darkfantasy_key", "nameKey": "item.darkfantasy.key", "descriptionKey": "item.darkfantasy.key.desc", "category": "Key", "rarity": "Rare", "tags": ["Adventure", "DarkFantasy", "Key"], "adventureTheme": "DarkFantasy"}, + {"id": "darkfantasy_chest", "nameKey": "item.darkfantasy.chest", "descriptionKey": "item.darkfantasy.chest.desc", "category": "Chest", "rarity": "Rare", "tags": ["Adventure", "DarkFantasy", "Openable"], "adventureTheme": "DarkFantasy"}, {"id": "mysterious_key", "nameKey": "item.mysterious_key", "descriptionKey": "item.mysterious_key.desc", "category": "Key", "rarity": "Rare", "tags": ["Key"]}, + {"id": "mysterious_chest", "nameKey": "item.mysterious_chest", "descriptionKey": "item.mysterious_chest.desc", "category": "Chest", "rarity": "Epic", "tags": ["Openable"]}, + + {"id": "alchemist_stone", "nameKey": "item.alchemist_stone", "descriptionKey": "item.alchemist_stone.desc", "category": "AdventureToken", "rarity": "Legendary", "tags": ["Catalyst"]}, {"id": "lore_1", "nameKey": "lore.name_1", "category": "LoreFragment", "rarity": "Uncommon", "tags": ["Lore"]}, {"id": "lore_2", "nameKey": "lore.name_2", "category": "LoreFragment", "rarity": "Uncommon", "tags": ["Lore"]}, diff --git a/content/strings/en.json b/content/strings/en.json index 66463f1..6766797 100644 --- a/content/strings/en.json +++ b/content/strings/en.json @@ -276,7 +276,7 @@ "item.cookie_fortune": "Fortune Cookie", "item.mysterious_key": "Mysterious Key", - "item.mysterious_key.desc": "A key to... something. The box knows, but the box isn't talking.", + "item.mysterious_key.desc": "A key that seems to open any chest... if you can find one.", "lore.fragment_1": "In the beginning, there was a box. The box contained another box. And so it was, and so it shall be.", "lore.fragment_2": "The Ancient Order of Box-Openers has a single commandment: Open thy boxes.", @@ -321,6 +321,32 @@ "cookie.19": "Today is a good day to open boxes. Tomorrow too. Every day, really.", "cookie.20": "Your spirit animal is a box. Your power move is opening.", + "item.space.key.desc": "A key shaped like a star. It's looking for a chest somewhere out there...", + "item.space.chest.desc": "A sealed container from a distant galaxy. A star-shaped keyhole glows faintly...", + "item.space.map.desc": "A star chart showing hyperspace routes... but coordinates are needed to navigate.", + "item.space.coordinates.desc": "Encrypted coordinates. Combined with a star chart, they reveal the path...", + "item.medieval.key.desc": "An ornate key bearing a royal crest. It seeks a chest worthy of its lock...", + "item.medieval.chest.desc": "A locked chest adorned with the same royal crest as a certain key...", + "item.pirate.key.desc": "A rusty key found in a bottle. It smells of salt and buried treasure...", + "item.pirate.chest.desc": "A barnacle-encrusted chest. The lock matches the teeth of a certain rusty key...", + "item.pirate.map.desc": "X marks the spot... but without a compass, the treasure stays hidden.", + "item.pirate.compass.desc": "The needle points to treasure... if only you had the map.", + "item.contemporary.key.desc": "A modern keycard with a magnetic strip. It's looking for a secure box...", + "item.contemporary.chest.desc": "A reinforced safe with a card reader. Waiting for the right keycard...", + "item.darkfantasy.key.desc": "A key forged in shadow. It whispers of a cursed chest...", + "item.darkfantasy.chest.desc": "A chest bound in dark chains. The lock pulses with the same shadow as a certain key...", + "item.mysterious_chest": "Mysterious Chest", + "item.mysterious_chest.desc": "An enigmatic chest with no markings. Any key might work...", + "item.alchemist_stone": "Alchemist's Stone", + "item.alchemist_stone.desc": "A shimmering philosopher's stone. It catalyzes reactions without being consumed...", + "item.space.chest": "Space Lockbox", + "item.medieval.chest": "Royal Chest", + "item.pirate.chest": "Pirate Chest", + "item.contemporary.chest": "Secure Safe", + "item.darkfantasy.chest": "Cursed Chest", + "interaction.chain_reaction": "{0} and {1} react in your inventory!", + "interaction.chain_bonus": "Chain x{0} bonus!", + "character.farah": "Farah", "character.malkith": "Malkith", "character.linu": "Linu", @@ -343,7 +369,7 @@ "adventure.item_removed": "Lost: {0}", "adventure.resource_added": "{0} +{1}", - "interaction.key_chest": "You use {0} — the key fits! The chest opens automatically!", + "interaction.key_chest": "{0} and {1} react! The key turns, the chest opens!", "interaction.key_no_match": "This key seems to fit something... but you don't have it yet. Perhaps a future box will provide.", "interaction.treasure_located": "The map and compass align! Treasure located!", "interaction.map_coordinates": "The map reveals mysterious coordinates...", @@ -548,5 +574,6 @@ "category.badge": "Badges", "category.map": "Maps", "category.storyitem": "Story Items", - "category.questitem": "Quest Items" + "category.questitem": "Quest Items", + "category.chest": "Chests" } diff --git a/content/strings/fr.json b/content/strings/fr.json index 758febc..2b0bad2 100644 --- a/content/strings/fr.json +++ b/content/strings/fr.json @@ -276,7 +276,7 @@ "item.cookie_fortune": "Fortune Cookie", "item.mysterious_key": "Clé mystérieuse", - "item.mysterious_key.desc": "Une clé pour... quelque chose. La boîte sait, mais la boîte ne parle pas.", + "item.mysterious_key.desc": "Une cle qui semble pouvoir ouvrir n'importe quel coffre... encore faut-il en trouver un.", "lore.fragment_1": "Au commencement, il y avait une boîte. La boîte contenait une autre boîte. Et c'est ainsi que ça a été, et que ça sera.", "lore.fragment_2": "L'Ancien Ordre des Ouvreurs de Boîtes n'a qu'un seul commandement : Tu ouvriras tes boîtes.", @@ -321,6 +321,32 @@ "cookie.19": "Aujourd'hui est un bon jour pour ouvrir des boîtes. Demain aussi. Tous les jours, en fait.", "cookie.20": "Ton animal totem est une boîte. Ton pouvoir spécial c'est l'ouverture.", + "item.space.key.desc": "Une cle en forme d'etoile. Elle cherche un coffre quelque part dans l'espace...", + "item.space.chest.desc": "Un conteneur scelle venu d'une galaxie lointaine. Un trou de serrure en forme d'etoile brille faiblement...", + "item.space.map.desc": "Une carte stellaire montrant des routes hyperspaciales... mais il faut des coordonnees pour naviguer.", + "item.space.coordinates.desc": "Des coordonnees chiffrees. Combinees a une carte stellaire, elles revelent le chemin...", + "item.medieval.key.desc": "Une cle ornee d'un blason royal. Elle cherche un coffre a sa mesure...", + "item.medieval.chest.desc": "Un coffre verrouille, orne du meme blason royal qu'une certaine cle...", + "item.pirate.key.desc": "Une cle rouillee trouvee dans une bouteille. Elle sent le sel et le tresor enfoui...", + "item.pirate.chest.desc": "Un coffre couvert de bernacles. La serrure correspond aux dents d'une certaine cle rouillee...", + "item.pirate.map.desc": "X marque l'emplacement... mais sans boussole, le tresor reste cache.", + "item.pirate.compass.desc": "L'aiguille pointe vers un tresor... si seulement tu avais la carte.", + "item.contemporary.key.desc": "Un badge magnetique moderne. Il cherche un coffre-fort...", + "item.contemporary.chest.desc": "Un coffre-fort renforce avec un lecteur de badge. En attente de la bonne carte...", + "item.darkfantasy.key.desc": "Une cle forgee dans l'ombre. Elle murmure a propos d'un coffre maudit...", + "item.darkfantasy.chest.desc": "Un coffre enchaine dans les tenebres. La serrure pulse de la meme ombre qu'une certaine cle...", + "item.mysterious_chest": "Coffre mysterieux", + "item.mysterious_chest.desc": "Un coffre enigmatique sans aucune marque. N'importe quelle cle pourrait fonctionner...", + "item.alchemist_stone": "Pierre alchimique", + "item.alchemist_stone.desc": "Une pierre philosophale chatoyante. Elle catalyse les reactions sans etre consommee...", + "item.space.chest": "Coffre spatial", + "item.medieval.chest": "Coffre royal", + "item.pirate.chest": "Coffre pirate", + "item.contemporary.chest": "Coffre-fort", + "item.darkfantasy.chest": "Coffre maudit", + "interaction.chain_reaction": "{0} et {1} reagissent dans ton inventaire !", + "interaction.chain_bonus": "Bonus de chaine x{0} !", + "character.farah": "Farah", "character.malkith": "Malkith", "character.linu": "Linu", @@ -343,7 +369,7 @@ "adventure.item_removed": "Perdu : {0}", "adventure.resource_added": "{0} +{1}", - "interaction.key_chest": "Tu utilises {0} — la clé rentre ! Le coffre s'ouvre automatiquement !", + "interaction.key_chest": "{0} et {1} reagissent ! La cle tourne, le coffre s'ouvre !", "interaction.key_no_match": "Cette clé semble ouvrir quelque chose... mais tu ne l'as pas encore. Peut-être qu'une future boîte le fournira.", "interaction.treasure_located": "La carte et la boussole s'alignent ! Trésor localisé !", "interaction.map_coordinates": "La carte révèle des coordonnées mystérieuses...", @@ -549,5 +575,6 @@ "category.badge": "Badges", "category.map": "Cartes", "category.storyitem": "Objets d'histoire", - "category.questitem": "Objets de quête" + "category.questitem": "Objets de quête", + "category.chest": "Coffres" } diff --git a/src/OpenTheBox/Core/Enums/ItemCategory.cs b/src/OpenTheBox/Core/Enums/ItemCategory.cs index 33f446e..9277a7b 100644 --- a/src/OpenTheBox/Core/Enums/ItemCategory.cs +++ b/src/OpenTheBox/Core/Enums/ItemCategory.cs @@ -53,5 +53,8 @@ public enum ItemCategory CraftedItem, /// An item required to complete a character's personal quest. - QuestItem + QuestItem, + + /// A locked chest that can be opened with a matching key via chain reactions. + Chest } diff --git a/src/OpenTheBox/Core/Interactions/InteractionRule.cs b/src/OpenTheBox/Core/Interactions/InteractionRule.cs index ff502e1..f79784c 100644 --- a/src/OpenTheBox/Core/Interactions/InteractionRule.cs +++ b/src/OpenTheBox/Core/Interactions/InteractionRule.cs @@ -14,5 +14,6 @@ public sealed record InteractionRule( string? ResultData, bool IsAutomatic, int Priority, - string DescriptionKey + string DescriptionKey, + bool ConsumeTrigger = true ); diff --git a/src/OpenTheBox/Program.cs b/src/OpenTheBox/Program.cs index 25fe3ae..3ff37d4 100644 --- a/src/OpenTheBox/Program.cs +++ b/src/OpenTheBox/Program.cs @@ -521,13 +521,37 @@ public static class Program break; case InteractionTriggeredEvent interEvt: - // Defer interaction display until after loot reveal, with trigger item context - var interMsg = interEvt.TriggerItemId is not null - ? _loc.Get(interEvt.DescriptionKey, GetLocalizedName(interEvt.TriggerItemId)) - : _loc.Get(interEvt.DescriptionKey); + // Defer interaction display until after loot reveal + // Build message with both trigger and partner item names + string interMsg; + if (interEvt.TriggerItemId is not null && interEvt.PartnerItemId is not null) + { + var triggerName = GetLocalizedName(interEvt.TriggerItemId); + var partnerName = GetLocalizedName(interEvt.PartnerItemId); + // First show which items react, then show the rule-specific message + var chainIndicator = interEvt.ChainStep > 0 + ? new string(UnicodeSupport.IsUtf8 ? '⚡' : '!', interEvt.ChainStep) + " " + : ""; + var reactionMsg = _loc.Get("interaction.chain_reaction", triggerName, partnerName); + var detailMsg = _loc.Get(interEvt.DescriptionKey, triggerName, partnerName); + interMsg = $"{chainIndicator}{reactionMsg}\n {detailMsg}"; + } + else if (interEvt.TriggerItemId is not null) + { + interMsg = _loc.Get(interEvt.DescriptionKey, GetLocalizedName(interEvt.TriggerItemId)); + } + else + { + interMsg = _loc.Get(interEvt.DescriptionKey); + } deferredInteractions.Add(interMsg); break; + case ChainBonusEvent chainEvt: + var bonusName = GetLocalizedName(chainEvt.BonusItemId); + deferredInteractions.Add(_loc.Get("interaction.chain_bonus", chainEvt.ChainLength) + $" {bonusName}"); + break; + case ResourceChangedEvent resEvt: var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); _renderer.ShowMessage($"{resName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {resEvt.NewValue}"); diff --git a/src/OpenTheBox/Simulation/Events/GameEvent.cs b/src/OpenTheBox/Simulation/Events/GameEvent.cs index d8bfc3e..81c3fe4 100644 --- a/src/OpenTheBox/Simulation/Events/GameEvent.cs +++ b/src/OpenTheBox/Simulation/Events/GameEvent.cs @@ -27,9 +27,14 @@ public sealed record ItemReceivedEvent(ItemInstance Item) : GameEvent; public sealed record ItemConsumedEvent(Guid InstanceId) : GameEvent; /// -/// An interaction rule was triggered. +/// An interaction rule was triggered as part of a chain reaction. /// -public sealed record InteractionTriggeredEvent(string RuleId, string DescriptionKey, string? TriggerItemId = null) : GameEvent; +public sealed record InteractionTriggeredEvent(string RuleId, string DescriptionKey, string? TriggerItemId = null, string? PartnerItemId = null, int ChainStep = 0) : GameEvent; + +/// +/// A chain reaction bonus was awarded after a multi-step chain. +/// +public sealed record ChainBonusEvent(int ChainLength, string BonusItemId) : GameEvent; /// /// A UI feature was unlocked via a meta item. diff --git a/src/OpenTheBox/Simulation/GameSimulation.cs b/src/OpenTheBox/Simulation/GameSimulation.cs index c2a81b6..76abcd4 100644 --- a/src/OpenTheBox/Simulation/GameSimulation.cs +++ b/src/OpenTheBox/Simulation/GameSimulation.cs @@ -101,8 +101,8 @@ public class GameSimulation // Collect newly received items for post-processing var newItems = boxEvents.OfType().Select(e => e.Item).ToList(); - // Run auto-activation pass - events.AddRange(_interactionEngine.CheckAutoActivations(newItems, state)); + // Run chain reaction pass + events.AddRange(_interactionEngine.RunChainReactions(newItems, state, _rng)); // Run meta pass events.AddRange(_metaEngine.ProcessNewItems(newItems, state, _registry)); @@ -139,7 +139,7 @@ public class GameSimulation } // Otherwise, check interaction rules - var interactionEvents = _interactionEngine.CheckAutoActivations([item], state); + var interactionEvents = _interactionEngine.RunChainReactions([item], state, _rng); events.AddRange(interactionEvents); return events; diff --git a/src/OpenTheBox/Simulation/InteractionEngine.cs b/src/OpenTheBox/Simulation/InteractionEngine.cs index e499784..90db37f 100644 --- a/src/OpenTheBox/Simulation/InteractionEngine.cs +++ b/src/OpenTheBox/Simulation/InteractionEngine.cs @@ -9,76 +9,96 @@ namespace OpenTheBox.Simulation; /// /// Evaluates interaction rules against newly received items and the current game state, -/// triggering automatic interactions or requesting player choices when multiple apply. +/// triggering automatic chain reactions that can cascade recursively. /// public class InteractionEngine(ContentRegistry registry) { + private const int MaxChainDepth = 10; + /// - /// Checks all interaction rules against newly received items and returns events for - /// any auto-activations or choice prompts. + /// 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. /// - public List CheckAutoActivations(List newItems, GameState state) + public List RunChainReactions(List newItems, GameState state, Random rng) { - var events = new List(); + var allEvents = new List(); + var itemsToCheck = new List(newItems); + int chainStep = 0; - foreach (var newItem in newItems) + while (itemsToCheck.Count > 0 && chainStep < MaxChainDepth) { - var itemDef = registry.GetItem(newItem.DefinitionId); - if (itemDef is null) - continue; + var producedItems = new List(); - var matchingRules = FindMatchingRules(itemDef, state); - - if (matchingRules.Count == 0) + foreach (var newItem in itemsToCheck) { - // Special case: key without a matching openable item injects a box into future loot tables - 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"); - }); + var itemDef = registry.GetItem(newItem.DefinitionId); + if (itemDef is null) + continue; - if (!hasMatchingOpenable) + var matchingRules = FindMatchingRules(itemDef, state); + + if (matchingRules.Count == 0) + { + // Special case: key without a matching openable item + if (itemDef.Tags.Contains("Key")) { - events.Add(new LootTableModifiedEvent( - BoxId: "starter_box", - AddedEntryId: newItem.DefinitionId, - Reason: $"Key '{newItem.DefinitionId}' has no matching Openable item; injecting into future loot tables" - )); + 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; } - 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); + } + } } - var automaticRules = matchingRules - .Where(r => r.IsAutomatic) - .OrderByDescending(r => r.Priority) - .ToList(); + if (producedItems.Count == 0) + break; - if (automaticRules.Count == 1) - { - // Single automatic match: auto-execute - var rule = automaticRules[0]; - events.AddRange(ExecuteRule(rule, newItem, state)); - } - else if (automaticRules.Count > 1) - { - // Multiple automatic matches: let the player choose - events.Add(new ChoiceRequiredEvent( - Prompt: "prompt.choose_interaction", - Options: automaticRules.Select(r => r.DescriptionKey).ToList() - )); - } - else - { - // Non-automatic rules found but none are automatic -- no auto-activation - } + itemsToCheck = producedItems; + chainStep++; } - return events; + // 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; } /// @@ -95,7 +115,7 @@ public class InteractionEngine(ContentRegistry registry) if (!tagsMatch) continue; - // Check required item ids (if specified, at least one must be in inventory) + // 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)); @@ -110,22 +130,48 @@ public class InteractionEngine(ContentRegistry registry) } /// - /// Executes a single interaction rule, consuming the trigger item and producing results. + /// Executes a single interaction rule, consuming items and producing results. /// - private List ExecuteRule(InteractionRule rule, ItemInstance triggerItem, GameState state) + private List ExecuteRule(InteractionRule rule, ItemInstance triggerItem, GameState state, int chainStep) { var events = new List(); - // Consume the trigger item - state.RemoveItem(triggerItem.Id); - events.Add(new ItemConsumedEvent(triggerItem.Id)); + // 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]; + } - // Handle result based on ResultType + // 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:")) { - // Unlock an adventure theme (e.g., "adventure:Pirate" or "adventure_unlock:Space") var themeName = rule.ResultData.Contains("adventure_unlock:") ? rule.ResultData["adventure_unlock:".Length..] : rule.ResultData["adventure:".Length..]; @@ -137,7 +183,7 @@ public class InteractionEngine(ContentRegistry registry) } else { - // Produce result items (item definition ids, comma-separated) + // Produce result items var resultItemIds = rule.ResultData.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var resultItemId in resultItemIds) { @@ -148,8 +194,6 @@ public class InteractionEngine(ContentRegistry registry) } } - events.Add(new InteractionTriggeredEvent(rule.Id, rule.DescriptionKey, triggerItem.DefinitionId)); - return events; } } diff --git a/tests/snapshots/item_utility_report.txt b/tests/snapshots/item_utility_report.txt index 4f8bc04..87236e4 100644 --- a/tests/snapshots/item_utility_report.txt +++ b/tests/snapshots/item_utility_report.txt @@ -1,51 +1,20 @@ # Item Utility Report -# Total items: 145 +# Total items: 152 # Total boxes: 31 # Total recipes: 18 -## AdventureToken (31 items) +## AdventureToken (32 items) ──────────────────────────────────────────────────────────────────────────────── - [****] space_coordinates (Epic) — Coordonnées mystérieuses - Loot: box_adventure_space + [***] space_coordinates (Epic) — Coordonnées mystérieuses Adventure: Space Craft ingredient: chart_star_navigation @ DrawingTable Interaction: coordinates_map_combine - [***] space_phone (Rare) — Numéro de téléphone alien - Loot: box_adventure_space - Adventure: Space - Interaction: phone_character_encounter - - [***] medieval_crest (Rare) — Blason de chevalier - Loot: box_adventure_medieval - Adventure: Medieval - Craft ingredient: engrave_royal_seal @ EngravingBench - - [***] medieval_scroll (Rare) — Parchemin ancien - Loot: box_adventure_medieval - Adventure: Medieval - Craft ingredient: engrave_royal_seal @ EngravingBench - [***] medieval_seal (Epic) — Sceau royal Loot: box_adventure_medieval Adventure: Medieval Craft output: engrave_royal_seal @ EngravingBench - [***] pirate_compass (Rare) — Boussole enchantée - Loot: box_adventure_pirate - Adventure: Pirate - Interaction: pirate_map_compass - - [***] contemporary_phone (Common) — Smartphone - Loot: box_adventure_contemporary - Adventure: Contemporary - Interaction: phone_character_encounter - - [***] sentimental_phone (Rare) — Numéro de l'ex - Loot: box_adventure_sentimental - Adventure: Sentimental - Interaction: phone_character_encounter - [***] prehistoric_tooth (Uncommon) — Dent de dinosaure Loot: box_adventure_prehistoric Adventure: Prehistoric @@ -82,20 +51,30 @@ Adventure: Microscopic Craft ingredient: splice_glowing_dna @ GeneticModStation - [***] darkfantasy_ring (Rare) — Anneau maudit - Loot: box_adventure_darkfantasy - Adventure: DarkFantasy - Craft ingredient: enchant_dark_grimoire @ TransformationPentacle - - [***] darkfantasy_grimoire (Epic) — Grimoire du nécromancien - Loot: box_adventure_darkfantasy - Adventure: DarkFantasy - Craft output: enchant_dark_grimoire @ TransformationPentacle - [**] space_badge (Rare) — Badge d'astronaute Loot: box_adventure_space Adventure: Space + [**] space_phone (Rare) — Numéro de téléphone alien + Loot: box_adventure_space + Adventure: Space + + [**] medieval_crest (Rare) — Blason de chevalier + Adventure: Medieval + Craft ingredient: engrave_royal_seal @ EngravingBench + + [**] medieval_sword (Epic) — Réplique d'Excalibur + Loot: box_adventure_medieval + Adventure: Medieval + + [**] medieval_scroll (Rare) — Parchemin ancien + Adventure: Medieval + Craft ingredient: engrave_royal_seal @ EngravingBench + + [**] pirate_compass (Rare) — Boussole enchantée + Adventure: Pirate + Interaction: pirate_map_compass + [**] pirate_feather (Common) — Plume de perroquet Loot: box_adventure_pirate Adventure: Pirate @@ -104,17 +83,17 @@ Loot: box_adventure_pirate Adventure: Pirate + [**] contemporary_phone (Common) — Smartphone + Loot: box_adventure_contemporary + Adventure: Contemporary + [**] contemporary_card (Uncommon) — Carte de crédit Loot: box_adventure_contemporary Adventure: Contemporary - [**] contemporary_usb (Rare) — Clé USB suspecte - Loot: box_adventure_contemporary - Adventure: Contemporary - [**] contemporary_badge (Rare) — Badge d'entreprise - Loot: box_adventure_contemporary Adventure: Contemporary + Interaction: badge_adventure_contemporary [**] sentimental_letter (Rare) — Lettre d'amour Loot: box_adventure_sentimental @@ -128,6 +107,10 @@ Loot: box_adventure_sentimental Adventure: Sentimental + [**] sentimental_phone (Rare) — Numéro de l'ex + Loot: box_adventure_sentimental + Adventure: Sentimental + [**] prehistoric_fossil (Epic) — Fossile de trilobite Loot: box_adventure_prehistoric Adventure: Prehistoric @@ -136,6 +119,14 @@ Loot: box_adventure_cosmic, box_black Adventure: Cosmic + [**] darkfantasy_ring (Rare) — Anneau maudit + Adventure: DarkFantasy + Craft ingredient: enchant_dark_grimoire @ TransformationPentacle + + [**] darkfantasy_grimoire (Epic) — Grimoire du nécromancien + Adventure: DarkFantasy + Craft output: enchant_dark_grimoire @ TransformationPentacle + [**] darkfantasy_gem (Legendary) — Gemme d'âme Loot: box_adventure_darkfantasy, box_black Adventure: DarkFantasy @@ -144,8 +135,42 @@ Loot: box_endgame(G) Adventure: Destiny - [*] medieval_sword (Epic) — Réplique d'Excalibur + [*] contemporary_usb (Rare) — Clé USB suspecte + Adventure: Contemporary + + [*] alchemist_stone (Legendary) — Pierre alchimique + Loot: box_legendary + +## Chest (6 items) +──────────────────────────────────────────────────────────────────────────────── + [***] space_chest (Rare) — Coffre spatial + Loot: box_adventure_space + Adventure: Space + Interaction: key_chest_space + + [***] medieval_chest (Rare) — Coffre royal + Loot: box_adventure_medieval Adventure: Medieval + Interaction: key_chest_medieval + + [***] pirate_chest (Rare) — Coffre pirate + Loot: box_adventure_pirate + Adventure: Pirate + Interaction: key_chest_pirate + + [***] contemporary_chest (Rare) — Coffre-fort + Loot: box_adventure_contemporary + Adventure: Contemporary + Interaction: key_chest_contemporary + + [***] darkfantasy_chest (Rare) — Coffre maudit + Loot: box_adventure_darkfantasy + Adventure: DarkFantasy + Interaction: key_chest_darkfantasy + + [**] mysterious_chest (Epic) — Coffre mysterieux + Loot: box_black + Interaction: key_chest_mysterious ## Consumable (6 items) ──────────────────────────────────────────────────────────────────────────────── @@ -345,31 +370,31 @@ Loot: box_adventure_space Adventure: Space Craft output: chart_star_navigation @ DrawingTable - Interaction: key_chest_auto + Interaction: key_chest_space, key_chest_mysterious [***] medieval_key (Rare) — Clé du donjon Loot: box_adventure_medieval Adventure: Medieval - Interaction: key_chest_auto + Interaction: key_chest_medieval, key_chest_mysterious [***] pirate_key (Rare) — Clé du coffre Loot: box_adventure_pirate Adventure: Pirate - Interaction: key_chest_auto + Interaction: key_chest_pirate, key_chest_mysterious [***] contemporary_key (Uncommon) — Clé d'appartement Loot: box_adventure_contemporary Adventure: Contemporary - Interaction: key_chest_auto + Interaction: key_chest_contemporary, key_chest_mysterious [***] darkfantasy_key (Rare) — Clé en os Loot: box_adventure_darkfantasy Adventure: DarkFantasy - Interaction: key_chest_auto + Interaction: key_chest_darkfantasy, key_chest_mysterious [**] mysterious_key (Rare) — Clé mystérieuse Loot: box_adventure_medieval, box_adventure_pirate, box_adventure_contemporary, box_adventure_darkfantasy, box_black - Interaction: key_chest_auto + Interaction: key_chest_mysterious ## LoreFragment (10 items) ──────────────────────────────────────────────────────────────────────────────── @@ -405,14 +430,12 @@ ## Map (2 items) ──────────────────────────────────────────────────────────────────────────────── - [****] space_map (Epic) — Carte stellaire - Loot: box_adventure_space + [***] space_map (Epic) — Carte stellaire Adventure: Space Craft ingredient: chart_star_navigation @ DrawingTable Interaction: coordinates_map_combine - [***] pirate_map (Epic) — Carte au trésor - Loot: box_adventure_pirate(G) + [**] pirate_map (Epic) — Carte au trésor Adventure: Pirate Interaction: pirate_map_compass