diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..727a7d4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,52 @@
+# Open The Box
+
+## Project Overview
+A console-based incremental/idle game built in C# (.NET 9) where players open boxes to discover items, unlock features, and progress through themed adventures. Uses Spectre.Console for rich terminal rendering and Loreline for interactive narrative scripting.
+
+## Architecture
+See [specifications.md](specifications.md) for detailed content organization.
+
+## Key Directories
+- `src/OpenTheBox/` — Main game source code
+- `content/data/` — JSON data files (items, boxes, crafting recipes)
+- `content/adventures/` — Loreline `.lor` adventure scripts (one folder per theme)
+- `content/localization/` — Translation files
+
+## Source Code Structure
+- `Core/` — Game state, enums, item/character models
+- `Core/Enums/` — All enum types (StatType, ResourceType, AdventureTheme, CosmeticSlot, etc.)
+- `Adventures/` — AdventureEngine (Loreline bridge + custom functions)
+- `Simulation/` — Game engines (BoxEngine, MetaEngine, ResourceEngine, CraftingEngine, GameSimulation)
+- `Rendering/` — IRenderer interface, SpectreRenderer, RenderContext, panel components
+- `Rendering/Panels/` — Individual UI panels (PortraitPanel, ResourcePanel, StatsPanel, etc.)
+- `Data/` — ContentRegistry, ItemDefinition, BoxDefinition data loading
+- `Persistence/` — SaveManager, SaveData (JSON serialization)
+- `Localization/` — LocalizationManager, Locale enum
+
+## Build & Run
+```
+dotnet build
+dotnet run --project src/OpenTheBox
+```
+
+## Test
+```
+dotnet test
+```
+
+## Adventure System
+Adventures use Loreline `.lor` script format. Custom functions available in scripts:
+- Inventory: `grantItem(id, qty)`, `hasItem(id)`, `removeItem(id)`
+- Resources: `hasResource(name, min)`, `getResourceValue(name)`, `addResource(name, amount)`
+- Stats: `hasStat(name, min)`, `getStatValue(name)`
+- Cosmetics: `hasCosmetic(id)`, `hasEquipped(slot, style)`
+- Progression: `hasCompletedAdventure(theme)`, `markSecretBranch(id)`, `hasSecretBranch(id)`, `countSecretBranches()`, `allSecretBranches()`
+
+Hints for disabled choices use `|||` separator: `Option text|||Hint text #label [if condition]`
+
+## Conventions
+- C# 12 with file-scoped namespaces, primary constructors where appropriate
+- Immutable records for value types, sealed classes for services
+- GameState is mutable, passed by reference to engines
+- All user-facing strings go through LocalizationManager
+- Enum names match JSON string values (PascalCase)
diff --git a/content/adventures/contemporary/intro.lor b/content/adventures/contemporary/intro.lor
index 27a657f..d147eaa 100644
--- a/content/adventures/contemporary/intro.lor
+++ b/content/adventures/contemporary/intro.lor
@@ -111,6 +111,20 @@ beat BoxEscalation
Reply all with "Please remove me from this thread" #opt-reply-all
emails += 200
-> ReplyAllChaos
+ Offer to sponsor the box's corporate integration personally|||Your financial resources might open some doors here... #opt-vip [if hasResource("Gold", 30)]
+ -> VIPFastTrack
+
+beat VIPFastTrack
+ markSecretBranch("contemporary_vip")
+ You reply to the box's email with a generous corporate sponsorship offer. The box hums approvingly. #vip-offer
+ sandrea: You're... buying the box a corner office? With a window? #sandrea-corner
+ samuel: I've been here three years and I sit next to the printer. The LOUD printer. #samuel-printer
+ The box's hum shifts to a contented purr. An email arrives: "Counter-offer accepted. I will also require a company card and a parking spot." #vip-counter
+ sandrea: The box negotiated better than our entire sales department. In one email. #sandrea-negotiated
+ samuel: Can the box negotiate MY salary next? #samuel-salary
+ boxPower += 25
+ unionFormed = true
+ -> BoxMeeting
beat ReplyAllChaos
You hit Reply All. The office erupts. #reply-chaos
diff --git a/content/adventures/cosmic/intro.lor b/content/adventures/cosmic/intro.lor
index 46a66bc..644af19 100644
--- a/content/adventures/cosmic/intro.lor
+++ b/content/adventures/cosmic/intro.lor
@@ -65,6 +65,20 @@ beat SubatomicBox
Refuse to look -- some ideas are traps #opt-refuse
existentialCrisis += 10
-> RefuseToLook
+ Derive the box's mathematical structure from its signal|||Your mind races with equations that might decode the pattern... #opt-enlightened [if hasStat("Intelligence", 10)]
+ -> CosmicEnlightened
+
+beat CosmicEnlightened
+ markSecretBranch("cosmic_enlightened")
+ You grab a whiteboard and start writing. Equations pour out of you like water from a cosmic faucet. #enlightened-write
+ quantum: The signal isn't random. It's a proof. A mathematical proof that boxes are the fundamental topology of reality. #quantum-proof
+ quantum: Euler's formula, but for boxes. Every vertex, every edge, every face -- they all resolve to cubes. #quantum-euler
+ quantum: I've just unified quantum mechanics and general relativity. The answer was boxes. It was ALWAYS boxes. #quantum-unified
+ quantum: Twenty years of research, and the Grand Unified Theory is: put it in a box. #quantum-grand
+ The instruments recalibrate themselves. They didn't need your help -- they just needed someone to understand. #enlightened-recalibrate
+ comprehension += 30
+ realitiesVisited += 1
+ -> TheEntityAppears
beat RefuseToLook
You refuse. You're a scientist, not a box's puppet. #refuse-puppet
diff --git a/content/adventures/darkfantasy/intro.lor b/content/adventures/darkfantasy/intro.lor
index 18b64e5..8222850 100644
--- a/content/adventures/darkfantasy/intro.lor
+++ b/content/adventures/darkfantasy/intro.lor
@@ -189,6 +189,21 @@ beat TheVault
-> OpenOneBox
Convince Mordecai to open them together #opt-convince if corruption < 30
-> ConvinceMordecai
+ Press your hand to the boxes and offer your blood as a bridge|||You feel a strange warmth in your veins, as though something ancient recognizes you... #opt-blood [if hasResource("Blood", 20)]
+ -> BloodCommunion
+
+beat BloodCommunion
+ markSecretBranch("darkfantasy_blood_communion")
+ You press your palm against the nearest box. Your blood sings. The box answers. #blood-press
+ Every box in the vault flickers -- not with light, but with voices. Twelve thousand souls, speaking at once. #blood-voices
+ pierrick: They're not screaming. They're... introducing themselves. #pierrick-introduce
+ mordecai: Impossible. I've tried to speak with them for decades. They never answered ME. #mordecai-impossible
+ pierrick: Maybe they didn't trust you. You're their jailer. I'm just a baker with strange blood. #pierrick-jailer
+ The souls whisper a consensus: half wish to stay, half wish to leave. They've voted. Through cardboard. Democracy at its most absurd. #blood-consensus
+ mordecai: They VOTED? Without a suggestion box? Wait -- they ARE the suggestion boxes. #mordecai-voted
+ boxesFreed += 50
+ souls += 10
+ -> FinalChoice
beat OpenOneBox
You pick a box at random. It's small, blue, and warm. The label reads: "Elara. Farmer. Liked cats." #one-pick
diff --git a/content/adventures/destiny/intro.lor b/content/adventures/destiny/intro.lor
new file mode 100644
index 0000000..1ab81ab
--- /dev/null
+++ b/content/adventures/destiny/intro.lor
@@ -0,0 +1,176 @@
+// Destiny Adventure - Open The Box
+// Theme: The final adventure — the game reflects on everything
+
+character destiny
+ name: The Box
+ role: Narrator of All Things Boxed
+
+state
+ echoes: 0
+
+beat Intro
+ Silence. Then, a creak. The sound of something very old deciding to speak. #intro-silence
+ destiny: So. You've come to the last box. #intro-lastbox
+ destiny: I wondered when you'd find me. They all do, eventually. The ones who keep opening. #intro-wondered
+ destiny: I am not a box you open. I am a box that opens you. #intro-opens
+ destiny: Sit down. Let me show you what I've seen. #intro-sit
+ -> Gallery
+
+beat Gallery
+ destiny: Every box you opened left an echo. A little ripple in the cardboard cosmos. #gallery-intro
+ destiny: I collected them. I collect everything. It's sort of my thing. #gallery-collect
+ destiny: Let me show you the Gallery of Echoes. Your echoes. #gallery-show
+
+ if hasCompletedAdventure("Space")
+ The stars flicker. A faint hum fills the room -- the sound of a ship you once commanded. #echo-space
+ destiny: You sailed through the void and opened a box that defied physics. Captain Nova would be proud. #echo-space-words
+ echoes += 1
+
+ if hasCompletedAdventure("Medieval")
+ A banner unfurls from nowhere, bearing a crest you almost remember. #echo-medieval
+ destiny: Knights and dragons and a kingdom built on cardboard. You wore the crown well. #echo-medieval-words
+ echoes += 1
+
+ if hasCompletedAdventure("Pirate")
+ The smell of salt and old wood drifts past. A shanty plays, very faintly. #echo-pirate
+ destiny: You sailed the seas of chance. Every treasure chest is just a box with ambition. #echo-pirate-words
+ echoes += 1
+
+ if hasCompletedAdventure("Contemporary")
+ A phone notification chimes. Then another. Then silence. #echo-contemporary
+ destiny: The modern world, where every package is a promise. You understood that. #echo-contemporary-words
+ echoes += 1
+
+ if hasCompletedAdventure("Sentimental")
+ Rain taps against a window that isn't there. The scent of old paper and popsicle sticks. #echo-sentimental
+ destiny: You opened a box of memories and let them breathe again. That took courage. #echo-sentimental-words
+ echoes += 1
+
+ if hasCompletedAdventure("Prehistoric")
+ The ground rumbles. Something ancient stirs in the echo. #echo-prehistoric
+ destiny: Before language, before tools, there were boxes. You were there at the beginning. #echo-prehistoric-words
+ echoes += 1
+
+ if hasCompletedAdventure("Cosmic")
+ The walls dissolve into starfields. Galaxies spiral in the space between heartbeats. #echo-cosmic
+ destiny: You touched the infinite and the infinite touched back. Not everyone can say that. #echo-cosmic-words
+ echoes += 1
+
+ if hasCompletedAdventure("Microscopic")
+ Everything shrinks, then expands. Cells divide in the corner of your vision. #echo-microscopic
+ destiny: You went smaller than small. You found universes inside atoms inside boxes. #echo-microscopic-words
+ echoes += 1
+
+ if hasCompletedAdventure("DarkFantasy")
+ Shadows crawl across the floor. A candle flickers that was never lit. #echo-darkfantasy
+ destiny: You walked through the dark and opened boxes that whispered back. Bold. #echo-darkfantasy-words
+ echoes += 1
+
+ destiny: $echoes echoes. $echoes worlds you walked through. Each one left a mark on you, and you on it. #gallery-count
+ -> Alcove
+
+beat Alcove
+ destiny: Now. The echoes are one thing. But some of you -- some of you went deeper. #alcove-intro
+ destiny: You found the hidden seams. The secret folds. The boxes within the boxes. #alcove-seams
+ destiny: Let me see which ones you found. #alcove-see
+
+ if hasSecretBranch("space_box_whisperer")
+ A frequency hums beneath the silence. You remember closing your eyes and listening. #secret-space
+ destiny: The Box Whisperer. You heard what the space box was truly saying. Few ever do. #secret-space-words
+
+ if hasSecretBranch("medieval_dragon_charmer")
+ A scale glimmers on the ground, iridescent and warm to the touch. #secret-medieval
+ destiny: The Dragon Charmer. You chose fire over fear, and the dragon chose you back. #secret-medieval-words
+
+ if hasSecretBranch("darkfantasy_blood_communion")
+ A drop of crimson hangs suspended in the air, neither falling nor fading. #secret-darkfantasy
+ destiny: The Blood Communion. You drank from the dark box and survived. Changed, but whole. #secret-darkfantasy-words
+
+ if hasSecretBranch("pirate_one_of_us")
+ A coin spins endlessly on an invisible surface, never landing. #secret-pirate
+ destiny: One of Us. The pirates accepted you as kin. That's rarer than any treasure. #secret-pirate-words
+
+ if hasSecretBranch("contemporary_vip")
+ A velvet rope parts silently, revealing a door that was always there. #secret-contemporary
+ destiny: The VIP. You found the door behind the door. The real world behind the real world. #secret-contemporary-words
+
+ if hasSecretBranch("sentimental_true_sight")
+ A photograph develops from nothing, showing a moment that never happened but feels true. #secret-sentimental
+ destiny: True Sight. You saw what the memories were really trying to tell you. #secret-sentimental-words
+
+ if hasSecretBranch("prehistoric_champion")
+ A stone tool materializes, impossibly sharp after a million years. #secret-prehistoric
+ destiny: The Champion. Before history had a name, you earned one. #secret-prehistoric-words
+
+ if hasSecretBranch("cosmic_enlightened")
+ Light bends around you, just for a moment, as if the universe is giving you a hug. #secret-cosmic
+ destiny: The Enlightened. You understood the cosmic joke, and you laughed along. #secret-cosmic-words
+
+ if hasSecretBranch("microscopic_surgeon")
+ A cell divides in your palm, perfectly, impossibly. #secret-microscopic
+ destiny: The Surgeon. You operated on reality itself and left it better than you found it. #secret-microscopic-words
+
+ -> FinalChoice
+
+beat FinalChoice
+ destiny: And now we come to it. The last flap of the last box. #final-now
+
+ if allSecretBranches()
+ -> UltimateEnding
+
+ if countSecretBranches() > 4
+ -> GreatEnding
+
+ if countSecretBranches() > 0
+ -> GoodEnding
+
+ -> SimpleEnding
+
+beat SimpleEnding
+ destiny: You opened every box, but did you truly see what was inside? #simple-question
+ destiny: Most people open boxes for what they contain. The best open them for what they reveal. #simple-reveal
+ destiny: You did what you came to do. And that is enough. It is always enough. #simple-enough
+ destiny: The boxes will remember you passed through. Quietly, the way boxes remember things. #simple-remember
+ destiny: Now go. There are always more boxes. Even when you think there aren't. #simple-go
+ -> Farewell
+
+beat GoodEnding
+ destiny: You looked deeper than most. The boxes remember. #good-remember
+ destiny: Where others saw cardboard and tape, you saw doors. And you walked through them. #good-doors
+ destiny: Not every secret was found. Not every fold was unfolded. #good-notevery
+ destiny: But you tried. And trying is what separates the openers from the observers. #good-trying
+ destiny: The echoes you left behind will hum for a long time. I'll make sure of it. #good-echoes
+ -> Farewell
+
+beat GreatEnding
+ destiny: You are a true seeker. Few have found what you've found. #great-seeker
+ destiny: You didn't just open boxes. You listened to them. You understood them. #great-listened
+ destiny: The secret branches you found -- they weren't hidden by accident. #great-notaccident
+ destiny: They were hidden because only certain people deserve to find them. #great-deserve
+ destiny: People who look at a box and see not a container, but a question. #great-question
+ destiny: You asked the questions. You earned the answers. #great-answers
+ -> Farewell
+
+beat UltimateEnding
+ grantItem("destiny_star", 1)
+ destiny: Wait. #ultimate-wait
+ destiny: Nine branches. All nine. #ultimate-nine
+ destiny: Do you understand what you've done? #ultimate-understand
+ destiny: I have watched thousands of players open millions of boxes. #ultimate-thousands
+ destiny: Most open and move on. Some pause to look. A few reach in deeper. #ultimate-few
+ destiny: But you -- you found every hidden fold, every whispered secret, every shadow door. #ultimate-every
+ destiny: I'm going to break a rule now. The biggest rule. A box is not supposed to do this. #ultimate-rule
+ destiny: I know you're out there. Not "you" the character. You. The person holding the device, reading these words. #ultimate-you
+ destiny: This was never just a game about opening boxes. It was about curiosity. Your curiosity. #ultimate-curiosity
+ destiny: The willingness to look under, behind, inside, and through. To find the thing behind the thing. #ultimate-willingness
+ destiny: That is the rarest thing in any universe, real or cardboard. #ultimate-rarest
+ destiny: So here. Take this. A Destiny Star. It does nothing. It means everything. #ultimate-star
+ destiny: Thank you for playing all the way to the bottom of the box. #ultimate-thankyou
+ -> Farewell
+
+beat Farewell
+ destiny: One more thing, before the lid closes. #farewell-one
+ destiny: Every box you ever opened was really the same box. And that box was you. #farewell-samebox
+ destiny: Corny? Maybe. True? Absolutely. #farewell-corny
+ destiny: Goodbye, opener. It was a pleasure being unboxed by you. #farewell-goodbye
+ -> .
diff --git a/content/adventures/medieval/intro.lor b/content/adventures/medieval/intro.lor
index 08101bf..d224110 100644
--- a/content/adventures/medieval/intro.lor
+++ b/content/adventures/medieval/intro.lor
@@ -124,6 +124,18 @@ 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)]
+ -> DragonCharmer
+
+beat DragonCharmer
+ markSecretBranch("medieval_dragon_charmer")
+ You step forward, lock eyes with Scorchtangle, and deliver the most radiant smile in the kingdom's history. #charm-smile
+ The dragon blinks. Then blushes. Can dragons blush? This one can. His scales turn a gentle pink. #charm-blush
+ knight: By the Square Gods... the dragon is SWOONING. I've never seen a box-dragon swoon before. #knight-swoon
+ Scorchtangle rolls over like a puppy and presents his belly, which is covered in tiny box-shaped scales. #charm-belly
+ knight: You've charmed a dragon with nothing but raw charisma. That's either very brave or very weird. #knight-brave
+ dragonFriendly = true
+ -> DragonFriendly
beat DragonFight
You charge at Scorchtangle with your definitely-not-adequate weapon. #fight-charge
diff --git a/content/adventures/microscopic/intro.lor b/content/adventures/microscopic/intro.lor
index e94228f..abfee29 100644
--- a/content/adventures/microscopic/intro.lor
+++ b/content/adventures/microscopic/intro.lor
@@ -169,6 +169,20 @@ beat VirusAlert
Ask Mike to do something #opt-ask-mike
mikeHappiness += 10
-> MikeToTheRescue
+ Carefully extract the virus with surgical precision|||Your hands are remarkably steady -- perhaps steady enough for microscopic work... #opt-surgery [if hasStat("Dexterity", 10)]
+ -> MicroscopicSurgeon
+
+beat MicroscopicSurgeon
+ markSecretBranch("microscopic_surgeon")
+ Your hands move with impossible precision. At this scale, a nanometer of error means disaster. You don't make errors. #surgeon-hands
+ You pinch the virus between two fingers and gently peel it from the membrane receptor, like removing a sticker without tearing it. #surgeon-peel
+ cellina: That's... that's not possible. You just performed manual phagocytosis. With your FINGERS. #cellina-fingers
+ mike: I've seen a lot of things inside this cell. Someone hand-removing a virus like a splinter is a new one. #mike-splinter
+ cellina: The receptor isn't even damaged. That's like picking a lock without scratching it. At the MOLECULAR level. #cellina-lock
+ mike: Can you do that to the weird protein that's been stuck in the Golgi for three days? Asking for a friend. The friend is me. #mike-friend
+ sciencePoints += 20
+ mikeHappiness += 15
+ -> GrandRealization
beat FightVirus
You grab a nearby antibody -- it's shaped like a Y, which is just a box with arms -- and hurl it at the virus. #fight-hurl
diff --git a/content/adventures/pirate/intro.lor b/content/adventures/pirate/intro.lor
index 51a5162..5f5e06c 100644
--- a/content/adventures/pirate/intro.lor
+++ b/content/adventures/pirate/intro.lor
@@ -61,6 +61,21 @@ beat MissionBrief
-> UpsideDown
Question whether a box drawing constitutes a map #opt-question
-> QuestionMap
+ Stomp your leg on the deck with authority|||Your sea legs might earn some respect around here... #opt-pegleg [if hasEquipped("legs", "PegLeg")]
+ -> OneOfUs
+
+beat OneOfUs
+ markSecretBranch("pirate_one_of_us")
+ You stomp your peg leg on the cardboard deck. It makes a deeply satisfying THUNK. #pegleg-stomp
+ The entire crew goes silent. Then Blackbeard's eyes widen. #pegleg-eyes
+ captain: That sound... that THUNK... ye have a PEG LEG! #captain-pegleg
+ captain: Linu! This one's got the leg! THE LEG! They're one of US! #captain-one
+ mate: The crew is going wild, Captain. They haven't been this excited since we found that box of rum. #linu-excited
+ captain: Forget the map! This changes everything! A peg-legged sailor on a cardboard ship -- that's DESTINY! #captain-destiny
+ captain: Ye get double rations! And by rations I mean cardboard, but DOUBLE cardboard! #captain-rations
+ crewMorale += 30
+ boxesFound += 1
+ -> FollowMap
beat QuestionMap
captain: Of course it's a map! It has an X! #captain-x
diff --git a/content/adventures/prehistoric/intro.lor b/content/adventures/prehistoric/intro.lor
index c811146..9dfcdc4 100644
--- a/content/adventures/prehistoric/intro.lor
+++ b/content/adventures/prehistoric/intro.lor
@@ -77,6 +77,21 @@ beat FirstReactions
Try to understand the box through interpretive dance #opt-dance
understanding += 10
-> InterpretiveDance
+ Challenge the box to a test of raw strength|||Something about your physique suggests a more direct approach... #opt-wrestle [if hasStat("Strength", 10)]
+ -> PrehistoricChampion
+
+beat PrehistoricChampion
+ markSecretBranch("prehistoric_champion")
+ You grab the box and lift it above your head. The tribe gasps. #champion-lift
+ grug: STRONG ONE LIFT THE THING! No one ever lift thing before! Everyone too busy poking and eating! #grug-lift
+ duncan: Strong One hold thing above head like trophy! Like mammoth tusk but SQUARE! #duncan-trophy
+ You slam the box down on a rock. The rock breaks. The box is fine. #champion-slam
+ grug: ROCK LOST! BOX WON! Box is STRONGER than rock! This change everything! #grug-rock
+ duncan: For thousands of years, rock was strongest thing. Now box is strongest thing. History is rewritten. #duncan-rewritten
+ grug: Strong One is champion of box! All cave people agree! Even Carl! #grug-champion
+ worshippers += 8
+ understanding += 15
+ -> TheFlaps
beat WorshipBox
The tribe gathers around the box and begins chanting. The chant is "BOX. BOX. BOX." #worship-chant
diff --git a/content/adventures/sentimental/intro.lor b/content/adventures/sentimental/intro.lor
index dfa41ce..30929c3 100644
--- a/content/adventures/sentimental/intro.lor
+++ b/content/adventures/sentimental/intro.lor
@@ -61,6 +61,22 @@ beat OpenMemoryBox
-> MemoryNote
Look at the small wooden figurine #opt-figurine
-> MemoryFigurine
+ Look deeper -- past the objects, into what they meant|||You feel like you could see the invisible threads connecting everything in this box... #opt-truesight [if hasEquipped("eyes", "MagicianGlasses") or hasStat("Wisdom", 10)]
+ -> TrueSight
+
+beat TrueSight
+ markSecretBranch("sentimental_true_sight")
+ You look at the box, but not at the things inside. You look at the spaces between them. #true-look
+ And you see it -- faint, golden threads. Connecting the photo to the note, the note to the figurine, the figurine back to the photo. #true-threads
+ farah: You're doing that thing again. That staring-at-nothing-but-seeing-everything thing. #farah-staring
+ Every thread is a moment. The photo connects to the day you laughed so hard you couldn't breathe. The note connects to the night you stayed up talking until sunrise. #true-moments
+ The figurine connects to the afternoon you realized this wasn't just friendship anymore. #true-figurine
+ farah: What do you see? #farah-see
+ farah: You know what, don't tell me. Some things are better felt than explained. #farah-felt
+ heartLevel += 30
+ memories += 3
+ chendaClose = true
+ -> CallChenda
beat MemoryPhoto
It's a polaroid. Slightly faded. Two teenagers standing in front of a moving truck. #photo-polaroid
diff --git a/content/adventures/space/intro.lor b/content/adventures/space/intro.lor
index bbdd5ea..2b8a6b8 100644
--- a/content/adventures/space/intro.lor
+++ b/content/adventures/space/intro.lor
@@ -62,6 +62,21 @@ beat InvestigateBox
-> ScanThenOpen
Poke it with a stick #opt-poke
-> PokeBox
+ Close your eyes and listen to what the box is really saying|||You sense there might be more to this box than meets the eye... #opt-whisper [if hasStat("Wisdom", 10)]
+ -> BoxWhisperer
+
+beat BoxWhisperer
+ markSecretBranch("space_box_whisperer")
+ You close your eyes. The hum of the ship fades. Something else rises -- a frequency beneath frequencies. #whisper-listen
+ The box is transmitting. Not data. Not sound. Coordinates woven into the fabric of space-time itself. #whisper-coordinates
+ ai: Commander, how are you doing that? The box just... aligned with your brainwaves. #ai-brainwaves
+ captain: I'm not doing anything, ARIA. I'm just listening. #captain-listening
+ ai: The box has shared a direct route to its home system. No fuel cost. It's folding space around us like... #ai-folding
+ captain: Like folding a box? #captain-folding
+ ai: I was going to say "like origami," but yes. Like folding a box. #ai-origami
+ trustAlien = true
+ discovered += 1
+ -> OpenSpaceBox
beat PokeBox
You extend the ship's robotic arm and gently poke the box. #poke-arm
diff --git a/content/data/boxes.json b/content/data/boxes.json
index 6f52940..8f4e9da 100644
--- a/content/data/boxes.json
+++ b/content/data/boxes.json
@@ -29,8 +29,8 @@
{"itemDefinitionId": "box_legendary", "weight": 1},
{"itemDefinitionId": "box_adventure", "weight": 1},
{"itemDefinitionId": "box_style", "weight": 1},
- {"itemDefinitionId": "box_improvement", "weight": 1, "condition": {"type": "ResourceAbove", "targetId": "any", "value": 0}},
- {"itemDefinitionId": "box_supply", "weight": 1, "condition": {"type": "ResourceAbove", "targetId": "any", "value": 0}},
+ {"itemDefinitionId": "box_improvement", "weight": 3, "condition": {"type": "BoxesOpenedAbove", "value": 10}},
+ {"itemDefinitionId": "box_supply", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 10}},
{"itemDefinitionId": "box_story", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 5}},
{"itemDefinitionId": "box_cookie", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 3}},
{"itemDefinitionId": "box_music", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 10}},
@@ -186,7 +186,14 @@
{"itemDefinitionId": "box_meta_basics", "weight": 2},
{"itemDefinitionId": "box_black", "weight": 1},
{"itemDefinitionId": "box_music", "weight": 1},
- {"itemDefinitionId": "box_story", "weight": 1}
+ {"itemDefinitionId": "box_story", "weight": 1},
+ {"itemDefinitionId": "blueprint_forge", "weight": 3, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_pentacle", "weight": 2, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_printer", "weight": 2, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_synthesizer", "weight": 2, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_genetic", "weight": 2, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_stasis", "weight": 2, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_engraving", "weight": 3, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}}
]
}
},
@@ -221,6 +228,7 @@
{"itemDefinitionId": "meta_resources", "weight": 3},
{"itemDefinitionId": "meta_stats", "weight": 3},
{"itemDefinitionId": "meta_shortcuts", "weight": 3},
+ {"itemDefinitionId": "meta_crafting", "weight": 3},
{"itemDefinitionId": "box_meta_deep", "weight": 1}
]
}
@@ -236,7 +244,6 @@
"rollCount": 1,
"entries": [
{"itemDefinitionId": "meta_extended_colors", "weight": 3},
- {"itemDefinitionId": "meta_crafting", "weight": 3},
{"itemDefinitionId": "meta_chat", "weight": 3},
{"itemDefinitionId": "meta_portrait", "weight": 2},
{"itemDefinitionId": "meta_completion", "weight": 3},
@@ -536,7 +543,13 @@
{"itemDefinitionId": "resource_max_gold", "weight": 2},
{"itemDefinitionId": "resource_max_blood", "weight": 1},
{"itemDefinitionId": "resource_max_oxygen", "weight": 1},
- {"itemDefinitionId": "resource_max_energy", "weight": 1}
+ {"itemDefinitionId": "resource_max_energy", "weight": 1},
+ {"itemDefinitionId": "blueprint_foundry", "weight": 4, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_workbench", "weight": 4, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_furnace", "weight": 4, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_drawing", "weight": 3, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_alchemy", "weight": 3, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}},
+ {"itemDefinitionId": "blueprint_engineer", "weight": 3, "condition": {"type": "HasUIFeature", "targetId": "CraftingPanel"}}
]
}
},
@@ -638,7 +651,7 @@
"rarity": "Mythic",
"isAutoOpen": false,
"lootTable": {
- "guaranteedRolls": ["endgame_crown", "box_of_boxes"],
+ "guaranteedRolls": ["endgame_crown", "destiny_token", "box_of_boxes"],
"rollCount": 0,
"entries": []
}
diff --git a/content/data/items.json b/content/data/items.json
index 2432344..b52d3b8 100644
--- a/content/data/items.json
+++ b/content/data/items.json
@@ -165,5 +165,20 @@
{"id": "meta_completion", "nameKey": "meta.completion", "category": "Meta", "rarity": "Rare", "tags": ["Meta"], "metaUnlock": "CompletionTracker"},
- {"id": "endgame_crown", "nameKey": "item.endgame_crown", "category": "Cosmetic", "rarity": "Mythic", "tags": ["Cosmetic", "Endgame"], "cosmeticSlot": "Hair", "cosmeticValue": "crown"}
+ {"id": "endgame_crown", "nameKey": "item.endgame_crown", "category": "Cosmetic", "rarity": "Mythic", "tags": ["Cosmetic", "Endgame"], "cosmeticSlot": "Hair", "cosmeticValue": "crown"},
+ {"id": "destiny_token", "nameKey": "item.destiny_token", "category": "AdventureToken", "rarity": "Mythic", "tags": ["Adventure", "Endgame"], "adventureTheme": "Destiny"},
+
+ {"id": "blueprint_foundry", "nameKey": "item.blueprint.foundry", "category": "Meta", "rarity": "Uncommon", "tags": ["Blueprint", "Workstation"], "workstationType": "Foundry"},
+ {"id": "blueprint_workbench", "nameKey": "item.blueprint.workbench", "category": "Meta", "rarity": "Uncommon", "tags": ["Blueprint", "Workstation"], "workstationType": "Workbench"},
+ {"id": "blueprint_furnace", "nameKey": "item.blueprint.furnace", "category": "Meta", "rarity": "Uncommon", "tags": ["Blueprint", "Workstation"], "workstationType": "Furnace"},
+ {"id": "blueprint_forge", "nameKey": "item.blueprint.forge", "category": "Meta", "rarity": "Rare", "tags": ["Blueprint", "Workstation"], "workstationType": "Forge"},
+ {"id": "blueprint_alchemy", "nameKey": "item.blueprint.alchemy", "category": "Meta", "rarity": "Rare", "tags": ["Blueprint", "Workstation"], "workstationType": "AlchemyTable"},
+ {"id": "blueprint_engineer", "nameKey": "item.blueprint.engineer", "category": "Meta", "rarity": "Rare", "tags": ["Blueprint", "Workstation"], "workstationType": "EngineerDesk"},
+ {"id": "blueprint_drawing", "nameKey": "item.blueprint.drawing", "category": "Meta", "rarity": "Uncommon", "tags": ["Blueprint", "Workstation"], "workstationType": "DrawingTable"},
+ {"id": "blueprint_engraving", "nameKey": "item.blueprint.engraving", "category": "Meta", "rarity": "Rare", "tags": ["Blueprint", "Workstation"], "workstationType": "EngravingBench"},
+ {"id": "blueprint_pentacle", "nameKey": "item.blueprint.pentacle", "category": "Meta", "rarity": "Epic", "tags": ["Blueprint", "Workstation"], "workstationType": "TransformationPentacle"},
+ {"id": "blueprint_printer", "nameKey": "item.blueprint.printer", "category": "Meta", "rarity": "Epic", "tags": ["Blueprint", "Workstation"], "workstationType": "Printer3D"},
+ {"id": "blueprint_synthesizer", "nameKey": "item.blueprint.synthesizer", "category": "Meta", "rarity": "Epic", "tags": ["Blueprint", "Workstation"], "workstationType": "MatterSynthesizer"},
+ {"id": "blueprint_genetic", "nameKey": "item.blueprint.genetic", "category": "Meta", "rarity": "Epic", "tags": ["Blueprint", "Workstation"], "workstationType": "GeneticModStation"},
+ {"id": "blueprint_stasis", "nameKey": "item.blueprint.stasis", "category": "Meta", "rarity": "Epic", "tags": ["Blueprint", "Workstation"], "workstationType": "StasisChamber"}
]
diff --git a/content/data/recipes.json b/content/data/recipes.json
index c508cf8..a833d81 100644
--- a/content/data/recipes.json
+++ b/content/data/recipes.json
@@ -53,25 +53,6 @@
],
"result": {"itemDefinitionId": "material_carbonfiber_sheet", "quantity": 1}
},
- {
- "id": "cut_diamond_gem",
- "nameKey": "recipe.cut_diamond_gem",
- "workstation": "Jewelry",
- "ingredients": [
- {"itemDefinitionId": "material_diamond_raw", "quantity": 2}
- ],
- "result": {"itemDefinitionId": "material_diamond_gem", "quantity": 1}
- },
- {
- "id": "forge_wood_nail",
- "nameKey": "recipe.forge_wood_nail",
- "workstation": "Anvil",
- "ingredients": [
- {"itemDefinitionId": "material_wood_refined", "quantity": 1},
- {"itemDefinitionId": "material_iron_ingot", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "material_wood_nail", "quantity": 3}
- },
{
"id": "brew_health_potion_medium",
@@ -82,16 +63,6 @@
],
"result": {"itemDefinitionId": "health_potion_medium", "quantity": 1}
},
- {
- "id": "brew_health_potion_large",
- "nameKey": "recipe.brew_health_potion_large",
- "workstation": "MagicCauldron",
- "ingredients": [
- {"itemDefinitionId": "health_potion_medium", "quantity": 2},
- {"itemDefinitionId": "blood_vial", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "health_potion_large", "quantity": 1}
- },
{
"id": "brew_mana_crystal_medium",
"nameKey": "recipe.brew_mana_crystal_medium",
@@ -101,36 +72,6 @@
],
"result": {"itemDefinitionId": "mana_crystal_medium", "quantity": 1}
},
- {
- "id": "brew_stamina_drink",
- "nameKey": "recipe.brew_stamina_drink",
- "workstation": "BrewingVat",
- "ingredients": [
- {"itemDefinitionId": "food_ration", "quantity": 2},
- {"itemDefinitionId": "mana_crystal_small", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "stamina_drink", "quantity": 2}
- },
- {
- "id": "distill_blood_vial",
- "nameKey": "recipe.distill_blood_vial",
- "workstation": "Distillery",
- "ingredients": [
- {"itemDefinitionId": "health_potion_small", "quantity": 2},
- {"itemDefinitionId": "darkfantasy_ring", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "blood_vial", "quantity": 2}
- },
- {
- "id": "brew_pirate_rum",
- "nameKey": "recipe.brew_pirate_rum",
- "workstation": "BrewingVat",
- "ingredients": [
- {"itemDefinitionId": "food_ration", "quantity": 3},
- {"itemDefinitionId": "stamina_drink", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "pirate_rum", "quantity": 1}
- },
{
"id": "synthesize_energy_cell",
"nameKey": "recipe.synthesize_energy_cell",
@@ -152,27 +93,6 @@
"result": {"itemDefinitionId": "oxygen_tank", "quantity": 2}
},
- {
- "id": "dye_hair_cyberpunk",
- "nameKey": "recipe.dye_hair_cyberpunk",
- "workstation": "DyeBasin",
- "ingredients": [
- {"itemDefinitionId": "cosmetic_hair_braided", "quantity": 1},
- {"itemDefinitionId": "tint_neon", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "cosmetic_hair_cyberpunk", "quantity": 1}
- },
- {
- "id": "tailor_body_suit",
- "nameKey": "recipe.tailor_body_suit",
- "workstation": "TailorTable",
- "ingredients": [
- {"itemDefinitionId": "cosmetic_body_tshirt", "quantity": 1},
- {"itemDefinitionId": "material_iron_ingot", "quantity": 2},
- {"itemDefinitionId": "tint_silver", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "cosmetic_body_suit", "quantity": 1}
- },
{
"id": "craft_pilot_glasses",
"nameKey": "recipe.craft_pilot_glasses",
@@ -189,23 +109,11 @@
"nameKey": "recipe.forge_armored_plate",
"workstation": "Forge",
"ingredients": [
- {"itemDefinitionId": "cosmetic_body_suit", "quantity": 1},
{"itemDefinitionId": "material_steel_ingot", "quantity": 3},
- {"itemDefinitionId": "material_carbonfiber_sheet", "quantity": 1}
+ {"itemDefinitionId": "material_carbonfiber_sheet", "quantity": 2}
],
"result": {"itemDefinitionId": "cosmetic_body_armored", "quantity": 1}
},
- {
- "id": "weld_mechanical_arms",
- "nameKey": "recipe.weld_mechanical_arms",
- "workstation": "WeldingStation",
- "ingredients": [
- {"itemDefinitionId": "cosmetic_arms_regular", "quantity": 1},
- {"itemDefinitionId": "material_titanium_ingot", "quantity": 2},
- {"itemDefinitionId": "energy_cell", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "cosmetic_arms_mechanical", "quantity": 1}
- },
{
"id": "engineer_rocket_boots",
"nameKey": "recipe.engineer_rocket_boots",
@@ -234,8 +142,7 @@
"workstation": "EngravingBench",
"ingredients": [
{"itemDefinitionId": "medieval_crest", "quantity": 1},
- {"itemDefinitionId": "medieval_scroll", "quantity": 1},
- {"itemDefinitionId": "material_bronze_ingot", "quantity": 1}
+ {"itemDefinitionId": "medieval_scroll", "quantity": 1}
],
"result": {"itemDefinitionId": "medieval_seal", "quantity": 1}
},
@@ -244,9 +151,7 @@
"nameKey": "recipe.enchant_dark_grimoire",
"workstation": "TransformationPentacle",
"ingredients": [
- {"itemDefinitionId": "darkfantasy_ring", "quantity": 2},
- {"itemDefinitionId": "blood_vial", "quantity": 2},
- {"itemDefinitionId": "mana_crystal_medium", "quantity": 1}
+ {"itemDefinitionId": "darkfantasy_ring", "quantity": 2}
],
"result": {"itemDefinitionId": "darkfantasy_grimoire", "quantity": 1}
},
@@ -255,8 +160,7 @@
"nameKey": "recipe.fuse_cosmic_crystal",
"workstation": "MatterSynthesizer",
"ingredients": [
- {"itemDefinitionId": "cosmic_shard", "quantity": 3},
- {"itemDefinitionId": "energy_cell", "quantity": 1}
+ {"itemDefinitionId": "cosmic_shard", "quantity": 2}
],
"result": {"itemDefinitionId": "cosmic_crystal", "quantity": 1}
},
@@ -265,7 +169,7 @@
"nameKey": "recipe.splice_glowing_dna",
"workstation": "GeneticModStation",
"ingredients": [
- {"itemDefinitionId": "microscopic_bacteria", "quantity": 2},
+ {"itemDefinitionId": "microscopic_bacteria", "quantity": 1},
{"itemDefinitionId": "microscopic_prion", "quantity": 1}
],
"result": {"itemDefinitionId": "microscopic_dna", "quantity": 1}
@@ -275,21 +179,11 @@
"nameKey": "recipe.preserve_amber",
"workstation": "StasisChamber",
"ingredients": [
- {"itemDefinitionId": "prehistoric_tooth", "quantity": 2},
- {"itemDefinitionId": "material_wood_refined", "quantity": 1}
+ {"itemDefinitionId": "prehistoric_tooth", "quantity": 2}
],
"result": {"itemDefinitionId": "prehistoric_amber", "quantity": 1}
},
- {
- "id": "craft_box_not_great",
- "nameKey": "recipe.craft_box_not_great",
- "workstation": "SawingPost",
- "ingredients": [
- {"itemDefinitionId": "material_wood_raw", "quantity": 3}
- ],
- "result": {"itemDefinitionId": "box_not_great", "quantity": 1}
- },
{
"id": "craft_box_ok_tier",
"nameKey": "recipe.craft_box_ok_tier",
@@ -320,17 +214,6 @@
],
"result": {"itemDefinitionId": "box_supply", "quantity": 1}
},
- {
- "id": "craft_box_style",
- "nameKey": "recipe.craft_box_style",
- "workstation": "PaintingSpace",
- "ingredients": [
- {"itemDefinitionId": "material_wood_refined", "quantity": 1},
- {"itemDefinitionId": "tint_purple", "quantity": 1},
- {"itemDefinitionId": "tint_warmpink", "quantity": 1}
- ],
- "result": {"itemDefinitionId": "box_style", "quantity": 1}
- },
{
"id": "craft_box_epic",
"nameKey": "recipe.craft_box_epic",
diff --git a/content/strings/en.json b/content/strings/en.json
index c9c4fc0..6df0463 100644
--- a/content/strings/en.json
+++ b/content/strings/en.json
@@ -382,21 +382,12 @@
"recipe.smelt_steel_ingot": "Smelt Steel Ingot",
"recipe.smelt_titanium_ingot": "Smelt Titanium Ingot",
"recipe.forge_carbonfiber_sheet": "Press Carbon Fiber Sheet",
- "recipe.cut_diamond_gem": "Cut Diamond Gem",
- "recipe.forge_wood_nail": "Forge Wood Nails",
"recipe.brew_health_potion_medium": "Brew Medium Health Potion",
- "recipe.brew_health_potion_large": "Brew Large Health Potion",
"recipe.brew_mana_crystal_medium": "Refine Medium Mana Crystal",
- "recipe.brew_stamina_drink": "Brew Stamina Drink",
- "recipe.distill_blood_vial": "Distill Blood Vial",
- "recipe.brew_pirate_rum": "Brew Bottle of Rum",
"recipe.synthesize_energy_cell": "Synthesize Energy Cell",
"recipe.pressurize_oxygen_tank": "Pressurize Oxygen Tank",
- "recipe.dye_hair_cyberpunk": "Dye Cyberpunk Neon Hair",
- "recipe.tailor_body_suit": "Tailor Business Suit",
"recipe.craft_pilot_glasses": "Craft Pilot Glasses",
"recipe.forge_armored_plate": "Forge Armored Plate",
- "recipe.weld_mechanical_arms": "Weld Mechanical Arms",
"recipe.engineer_rocket_boots": "Engineer Rocket Boots",
"recipe.chart_star_navigation": "Chart Star Navigation",
"recipe.engrave_royal_seal": "Engrave Royal Seal",
@@ -404,14 +395,35 @@
"recipe.fuse_cosmic_crystal": "Fuse Cosmic Crystal",
"recipe.splice_glowing_dna": "Splice Glowing DNA",
"recipe.preserve_amber": "Preserve Amber Stone",
- "recipe.craft_box_not_great": "Craft Meh Box",
"recipe.craft_box_ok_tier": "Craft Okay-ish Box",
"recipe.craft_box_cool": "Craft Cool Box",
"recipe.craft_box_supply": "Craft Supply Box",
- "recipe.craft_box_style": "Craft Style Box",
"recipe.craft_box_epic": "Craft Epic Box",
+ "action.collect_crafting": "Collect crafted items",
+ "craft.started": "Auto-crafting: {0} at {1}",
+ "craft.completed": "{0} finished crafting!",
+ "craft.done": "Done",
+ "craft.panel.title": "Workshops",
+ "craft.panel.empty": "No active workshops.",
+
+ "item.blueprint.foundry": "Foundry Blueprint",
+ "item.blueprint.workbench": "Workbench Blueprint",
+ "item.blueprint.furnace": "Furnace Blueprint",
+ "item.blueprint.forge": "Forge Blueprint",
+ "item.blueprint.alchemy": "Alchemy Table Blueprint",
+ "item.blueprint.engineer": "Engineer Desk Blueprint",
+ "item.blueprint.drawing": "Drawing Table Blueprint",
+ "item.blueprint.engraving": "Engraving Bench Blueprint",
+ "item.blueprint.pentacle": "Transformation Pentacle Blueprint",
+ "item.blueprint.printer": "3D Printer Blueprint",
+ "item.blueprint.synthesizer": "Matter Synthesizer Blueprint",
+ "item.blueprint.genetic": "Genetic Mod Station Blueprint",
+ "item.blueprint.stasis": "Stasis Chamber Blueprint",
+
"box.endgame": "The Final Box",
"box.endgame.desc": "You found all the resources. This is it. The last box. Are you ready?",
- "item.endgame_crown": "Crown of Completion"
+ "item.endgame_crown": "Crown of Completion",
+ "item.destiny_token": "Token of Destiny",
+ "adventure.secret_branch_found": "You feel a hidden path revealing itself..."
}
diff --git a/content/strings/fr.json b/content/strings/fr.json
index fa453cf..9aafe1c 100644
--- a/content/strings/fr.json
+++ b/content/strings/fr.json
@@ -382,21 +382,12 @@
"recipe.smelt_steel_ingot": "Fondre un lingot d'acier",
"recipe.smelt_titanium_ingot": "Fondre un lingot de titane",
"recipe.forge_carbonfiber_sheet": "Presser une feuille de fibre de carbone",
- "recipe.cut_diamond_gem": "Tailler une gemme de diamant",
- "recipe.forge_wood_nail": "Forger des clous en bois",
"recipe.brew_health_potion_medium": "Brasser une potion de sante moyenne",
- "recipe.brew_health_potion_large": "Brasser une grande potion de sante",
"recipe.brew_mana_crystal_medium": "Raffiner un cristal de mana moyen",
- "recipe.brew_stamina_drink": "Brasser une boisson d'endurance",
- "recipe.distill_blood_vial": "Distiller une fiole de sang",
- "recipe.brew_pirate_rum": "Brasser une bouteille de rhum",
"recipe.synthesize_energy_cell": "Synthetiser une cellule d'energie",
"recipe.pressurize_oxygen_tank": "Pressuriser un reservoir d'oxygene",
- "recipe.dye_hair_cyberpunk": "Teindre cheveux neon cyberpunk",
- "recipe.tailor_body_suit": "Coudre un costume",
"recipe.craft_pilot_glasses": "Fabriquer des lunettes d'aviateur",
"recipe.forge_armored_plate": "Forger une armure",
- "recipe.weld_mechanical_arms": "Souder des bras mecaniques",
"recipe.engineer_rocket_boots": "Concevoir des bottes a reaction",
"recipe.chart_star_navigation": "Cartographier la navigation stellaire",
"recipe.engrave_royal_seal": "Graver un sceau royal",
@@ -404,14 +395,35 @@
"recipe.fuse_cosmic_crystal": "Fusionner un cristal cosmique",
"recipe.splice_glowing_dna": "Episser de l'ADN luminescent",
"recipe.preserve_amber": "Conserver une pierre d'ambre",
- "recipe.craft_box_not_great": "Fabriquer une boite pas ouf",
"recipe.craft_box_ok_tier": "Fabriquer une boite ok tiers",
"recipe.craft_box_cool": "Fabriquer une boite coolos",
"recipe.craft_box_supply": "Fabriquer une boite de fourniture",
- "recipe.craft_box_style": "Fabriquer une boite stylee",
"recipe.craft_box_epic": "Fabriquer une boite epique",
+ "action.collect_crafting": "Recuperer les fabrications",
+ "craft.started": "Fabrication auto : {0} a l'atelier {1}",
+ "craft.completed": "{0} a termine la fabrication !",
+ "craft.done": "Termine",
+ "craft.panel.title": "Ateliers",
+ "craft.panel.empty": "Aucun atelier en activite.",
+
+ "item.blueprint.foundry": "Plan de Fonderie",
+ "item.blueprint.workbench": "Plan d'Etabli",
+ "item.blueprint.furnace": "Plan de Fourneau",
+ "item.blueprint.forge": "Plan de Forge",
+ "item.blueprint.alchemy": "Plan de Table d'Alchimie",
+ "item.blueprint.engineer": "Plan de Bureau d'Ingenieur",
+ "item.blueprint.drawing": "Plan de Table a Dessin",
+ "item.blueprint.engraving": "Plan de Banc de Gravure",
+ "item.blueprint.pentacle": "Plan de Pentacle de Transformation",
+ "item.blueprint.printer": "Plan d'Imprimante 3D",
+ "item.blueprint.synthesizer": "Plan de Synthetiseur de Matiere",
+ "item.blueprint.genetic": "Plan de Station de Modification Genetique",
+ "item.blueprint.stasis": "Plan de Chambre de Stase",
+
"box.endgame": "La Boite Finale",
"box.endgame.desc": "Tu as trouve toutes les ressources. C'est la derniere boite. Es-tu pret ?",
- "item.endgame_crown": "Couronne d'Accomplissement"
+ "item.endgame_crown": "Couronne d'Accomplissement",
+ "item.destiny_token": "Jeton du Destin",
+ "adventure.secret_branch_found": "Tu sens un chemin secret se reveler..."
}
diff --git a/specifications.md b/specifications.md
new file mode 100644
index 0000000..0d41cf8
--- /dev/null
+++ b/specifications.md
@@ -0,0 +1,114 @@
+# Open The Box — Content Specifications
+
+## Data Files (`content/data/`)
+
+### `boxes.json`
+Array of box definitions. Each box has:
+- `id` — Unique identifier (e.g., `box_common`, `box_adventure`, `box_endgame`)
+- `nameKey`, `descriptionKey` — Localization keys
+- `rarity` — Common, Uncommon, Rare, Epic, Legendary, Mythic
+- `isAutoOpen` — If true, box opens immediately when obtained
+- `lootTable`:
+ - `guaranteedRolls` — Array of item IDs always given
+ - `rollCount` — Number of random rolls
+ - `entries` — Weighted loot entries with optional `condition`:
+ - `condition.type`: `HasItem`, `HasNotItem`, `ResourceAbove`, `ResourceBelow`, `BoxesOpenedAbove`, `HasUIFeature`, `HasWorkstation`, `HasAdventure`, `HasCosmetic`, `AllResourcesVisible`
+
+### `items.json`
+Array of item definitions. Categories: Token, Consumable, Material, Cosmetic, Meta, Box.
+Special properties:
+- `adventureTheme` — Links token to an adventure theme
+- `cosmeticSlot` / `cosmeticValue` — Cosmetic equipment data
+- `statType` / `statValue` — Stat modification
+- `resourceType` / `resourceValue` — Resource modification
+- `metaUnlock` — UI feature to unlock
+- `workstationType` — Workstation blueprint
+
+### `crafting_recipes.json`
+Crafting recipes with inputs, outputs, duration, and workstation requirements.
+
+## Adventures (`content/adventures/`)
+
+### Folder Structure
+```
+content/adventures/
+├── space/ — Sci-fi theme (Key resource: Oxygen)
+├── medieval/ — Fantasy theme (Key resources: Mana, Stamina)
+├── pirate/ — Pirate theme (Key resources: Gold, Stamina)
+├── contemporary/ — Modern urban theme (Key resources: Energy, Gold)
+├── sentimental/ — Romance theme (Key resources: Health, Mana)
+├── prehistoric/ — Stone age theme (Key resources: Food, Stamina)
+├── cosmic/ — Cosmic/divine theme (Key resources: Mana, Energy)
+├── microscopic/ — Micro-world theme (Key resources: Energy, Oxygen)
+├── darkfantasy/ — Gothic horror theme (Key resources: Blood, Mana)
+└── destiny/ — Final adventure (acknowledges all other adventures)
+```
+
+Each folder contains:
+- `intro.lor` — Main adventure script (English)
+- `intro.fr.lor` — French translation
+- (Other locales as needed)
+
+### Secret Branches
+Each regular adventure (not Destiny) has ONE secret branch gated by stats, resources, or cosmetics:
+
+| Adventure | Condition | Branch ID |
+|-------------|------------------------------------|-----------------------------|
+| Space | `hasStat("Wisdom", 10)` | `space_box_whisperer` |
+| Medieval | `hasStat("Charisma", 10)` | `medieval_dragon_charmer` |
+| Pirate | `hasEquipped("legs", "PegLeg")` | `pirate_one_of_us` |
+| Contemporary| `hasResource("Gold", 30)` | `contemporary_vip` |
+| Sentimental | `hasStat("Wisdom", 10)` | `sentimental_true_sight` |
+| Prehistoric | `hasStat("Strength", 10)` | `prehistoric_champion` |
+| Cosmic | `hasStat("Intelligence", 10)` | `cosmic_enlightened` |
+| Microscopic | `hasStat("Dexterity", 10)` | `microscopic_surgeon` |
+| DarkFantasy | `hasResource("Blood", 20)` | `darkfantasy_blood_communion`|
+
+### Destiny Adventure (Final)
+- Triggered by `destiny_token` from `box_endgame`
+- Acknowledges completed adventures and found secret branches
+- Has 4 ending tiers based on secret branches found (0, 1-4, 5-8, all 9)
+- Ultimate ending grants `destiny_star` item
+
+## Game Systems Interaction Map
+
+```
+Boxes ──► Items ──► Inventory
+ │ │ │
+ │ ├──► Meta Unlocks (UI features, panels)
+ │ ├──► Adventure Tokens ──► Adventures
+ │ ├──► Cosmetics ──► Appearance ──┐
+ │ ├──► Stat Items ──► Stats ──────┤
+ │ └──► Consumables ──► Resources ─┤
+ │ │
+ │ ┌───────────────────────────────┘
+ │ ▼
+ │ Adventure Conditions (gate secret branches)
+ │ │
+ │ ▼
+ │ Secret Branches ──► Destiny Adventure (final acknowledgment)
+ │
+ └──► Crafting (materials consumed, items produced)
+```
+
+## Localization (`content/localization/`)
+- Key-value JSON files per locale
+- Locales: EN (default), FR
+- Adventure translations use Loreline's `#label` system with separate `.{locale}.lor` files
+
+## Enums Reference
+
+### AdventureTheme
+Space, Medieval, Pirate, Contemporary, Sentimental, Prehistoric, Cosmic, Microscopic, DarkFantasy, Destiny
+
+### StatType
+Strength, Intelligence, Luck, Charisma, Dexterity, Wisdom
+
+### ResourceType
+Health, Mana, Food, Stamina, Blood, Gold, Oxygen, Energy
+
+### CosmeticSlot
+Hair, Eyes, Body, Legs, Arms
+
+### Rarity
+Common, Uncommon, Rare, Epic, Legendary, Mythic
diff --git a/src/OpenTheBox/Adventures/AdventureEngine.cs b/src/OpenTheBox/Adventures/AdventureEngine.cs
index b92d49b..567815e 100644
--- a/src/OpenTheBox/Adventures/AdventureEngine.cs
+++ b/src/OpenTheBox/Adventures/AdventureEngine.cs
@@ -23,6 +23,30 @@ public enum GameEventKind
ResourceAdded
}
+///
+/// Separator used in choice option text to embed a hint for when the option is disabled.
+/// Format: "Option text|||Hint shown when unavailable"
+///
+///
+/// In .lor files: Open the secret path|||A keen sense of Luck might help here... #label [if hasStat("Luck", 30)]
+///
+internal static class HintSeparator
+{
+ public const string Delimiter = "|||";
+
+ ///
+ /// Splits a choice option text into the visible text and an optional hint.
+ ///
+ public static (string text, string? hint) Parse(string rawText)
+ {
+ int idx = rawText.IndexOf(Delimiter, StringComparison.Ordinal);
+ if (idx < 0)
+ return (rawText, null);
+
+ return (rawText[..idx].TrimEnd(), rawText[(idx + Delimiter.Length)..].TrimStart());
+ }
+}
+
///
/// Wraps the Loreline API for adventure playback. Loads .lor script files,
/// registers custom game functions, and bridges the callback-based Loreline engine
@@ -32,6 +56,12 @@ public sealed class AdventureEngine
{
private static readonly string AdventuresRoot = Path.Combine("content", "adventures");
+ ///
+ /// Total number of regular (non-Destiny) adventure themes that can have secret branches.
+ ///
+ public static readonly int TotalSecretBranchThemes = Enum.GetValues()
+ .Count(t => t != AdventureTheme.Destiny);
+
private readonly IRenderer _renderer;
private readonly LocalizationManager _loc;
@@ -141,6 +171,9 @@ public sealed class AdventureEngine
// Clear the save data for this adventure since it completed
state.AdventureSaveData.Remove(adventureId);
+ // Mark the adventure as completed
+ state.CompletedAdventures.Add(theme.ToString());
+
return events;
}
@@ -165,9 +198,22 @@ public sealed class AdventureEngine
private void HandleChoice(Loreline.Interpreter.Choice choice)
{
var options = new List();
+ var hints = new List();
+
foreach (var opt in choice.Options)
{
- options.Add(opt.Enabled ? opt.Text : $"(unavailable) {opt.Text}");
+ var (text, hint) = HintSeparator.Parse(opt.Text);
+
+ if (opt.Enabled)
+ {
+ options.Add(text);
+ hints.Add(null);
+ }
+ else
+ {
+ options.Add($"(unavailable) {text}");
+ hints.Add(hint);
+ }
}
int selectedIndex;
@@ -179,6 +225,12 @@ public sealed class AdventureEngine
if (choice.Options[selectedIndex].Enabled)
break;
+ // Show the hint if one exists for this disabled option
+ if (hints[selectedIndex] is { } hintText)
+ {
+ _renderer.ShowAdventureHint(hintText);
+ }
+
_renderer.ShowError("That option is not available.");
}
@@ -200,6 +252,8 @@ public sealed class AdventureEngine
{
return new Dictionary
{
+ // ── Inventory ────────────────────────────────────────────
+
["grantItem"] = (interpreter, args) =>
{
if (args.Length < 1) return null!;
@@ -252,6 +306,141 @@ public sealed class AdventureEngine
}
return null!;
+ },
+
+ // ── Stats ────────────────────────────────────────────────
+
+ ["hasStat"] = (interpreter, args) =>
+ {
+ if (args.Length < 2) return false;
+
+ string statName = args[0]?.ToString() ?? string.Empty;
+ int minValue = args[1] is double d ? (int)d : 0;
+
+ if (Enum.TryParse(statName, ignoreCase: true, out var statType))
+ {
+ return state.Stats.TryGetValue(statType, out int val) && val >= minValue;
+ }
+
+ return false;
+ },
+
+ ["getStatValue"] = (interpreter, args) =>
+ {
+ if (args.Length < 1) return 0.0;
+
+ string statName = args[0]?.ToString() ?? string.Empty;
+
+ if (Enum.TryParse(statName, ignoreCase: true, out var statType))
+ {
+ return state.Stats.TryGetValue(statType, out int val) ? (double)val : 0.0;
+ }
+
+ return 0.0;
+ },
+
+ // ── Resources ────────────────────────────────────────────
+
+ ["hasResource"] = (interpreter, args) =>
+ {
+ if (args.Length < 2) return false;
+
+ string resourceName = args[0]?.ToString() ?? string.Empty;
+ int minValue = args[1] is double d ? (int)d : 0;
+
+ if (Enum.TryParse(resourceName, ignoreCase: true, out var resType))
+ {
+ return state.GetResource(resType) >= minValue;
+ }
+
+ return false;
+ },
+
+ ["getResourceValue"] = (interpreter, args) =>
+ {
+ if (args.Length < 1) return 0.0;
+
+ string resourceName = args[0]?.ToString() ?? string.Empty;
+
+ if (Enum.TryParse(resourceName, ignoreCase: true, out var resType))
+ {
+ return (double)state.GetResource(resType);
+ }
+
+ return 0.0;
+ },
+
+ // ── Cosmetics & Appearance ───────────────────────────────
+
+ ["hasCosmetic"] = (interpreter, args) =>
+ {
+ if (args.Length < 1) return false;
+
+ string cosmeticId = args[0]?.ToString() ?? string.Empty;
+ return state.UnlockedCosmetics.Contains(cosmeticId);
+ },
+
+ ["hasEquipped"] = (interpreter, args) =>
+ {
+ if (args.Length < 2) return false;
+
+ string slot = args[0]?.ToString() ?? string.Empty;
+ string style = args[1]?.ToString() ?? string.Empty;
+
+ return slot.ToLowerInvariant() switch
+ {
+ "hair" => state.Appearance.HairStyle.ToString().Equals(style, StringComparison.OrdinalIgnoreCase),
+ "eyes" => state.Appearance.EyeStyle.ToString().Equals(style, StringComparison.OrdinalIgnoreCase),
+ "body" => state.Appearance.BodyStyle.ToString().Equals(style, StringComparison.OrdinalIgnoreCase),
+ "legs" => state.Appearance.LegStyle.ToString().Equals(style, StringComparison.OrdinalIgnoreCase),
+ "arms" => state.Appearance.ArmStyle.ToString().Equals(style, StringComparison.OrdinalIgnoreCase),
+ "hairtint" => state.Appearance.HairTint.ToString().Equals(style, StringComparison.OrdinalIgnoreCase),
+ "bodytint" => state.Appearance.BodyTint.ToString().Equals(style, StringComparison.OrdinalIgnoreCase),
+ _ => false
+ };
+ },
+
+ // ── Adventure progression ────────────────────────────────
+
+ ["hasCompletedAdventure"] = (interpreter, args) =>
+ {
+ if (args.Length < 1) return false;
+
+ string themeName = args[0]?.ToString() ?? string.Empty;
+ return state.CompletedAdventures.Contains(themeName);
+ },
+
+ ["markSecretBranch"] = (interpreter, args) =>
+ {
+ if (args.Length < 1) return null!;
+
+ string branchId = args[0]?.ToString() ?? string.Empty;
+ bool isNew = state.CompletedSecretBranches.Add(branchId);
+
+ if (isNew)
+ {
+ _renderer.ShowMessage(_loc.Get("adventure.secret_branch_found"));
+ }
+
+ return null!;
+ },
+
+ ["hasSecretBranch"] = (interpreter, args) =>
+ {
+ if (args.Length < 1) return false;
+
+ string branchId = args[0]?.ToString() ?? string.Empty;
+ return state.CompletedSecretBranches.Contains(branchId);
+ },
+
+ ["countSecretBranches"] = (interpreter, args) =>
+ {
+ return (double)state.CompletedSecretBranches.Count;
+ },
+
+ ["allSecretBranches"] = (interpreter, args) =>
+ {
+ return state.CompletedSecretBranches.Count >= TotalSecretBranchThemes;
}
};
}
diff --git a/src/OpenTheBox/Core/Crafting/CraftingJob.cs b/src/OpenTheBox/Core/Crafting/CraftingJob.cs
new file mode 100644
index 0000000..923dc84
--- /dev/null
+++ b/src/OpenTheBox/Core/Crafting/CraftingJob.cs
@@ -0,0 +1,30 @@
+using OpenTheBox.Core.Enums;
+
+namespace OpenTheBox.Core.Crafting;
+
+///
+/// Represents an active crafting process at a workstation.
+/// Tracks the recipe being crafted, timing, and completion status.
+///
+public sealed class CraftingJob
+{
+ public required Guid Id { get; init; }
+ public required string RecipeId { get; set; }
+ public required WorkstationType Workstation { get; set; }
+ public required DateTime StartedAt { get; set; }
+ public required TimeSpan Duration { get; set; }
+ public required CraftingJobStatus Status { get; set; }
+
+ ///
+ /// Returns the current progress as a percentage (0-100).
+ /// Completed jobs always return 100%.
+ ///
+ public double ProgressPercent => Status >= CraftingJobStatus.Completed
+ ? 100.0
+ : Math.Min(100.0, (DateTime.UtcNow - StartedAt).TotalSeconds / Duration.TotalSeconds * 100.0);
+
+ ///
+ /// Returns true if the crafting duration has elapsed.
+ ///
+ public bool IsComplete => Status >= CraftingJobStatus.Completed || DateTime.UtcNow >= StartedAt + Duration;
+}
diff --git a/src/OpenTheBox/Core/Crafting/CraftingJobStatus.cs b/src/OpenTheBox/Core/Crafting/CraftingJobStatus.cs
new file mode 100644
index 0000000..8297298
--- /dev/null
+++ b/src/OpenTheBox/Core/Crafting/CraftingJobStatus.cs
@@ -0,0 +1,16 @@
+namespace OpenTheBox.Core.Crafting;
+
+///
+/// The lifecycle status of a crafting job.
+///
+public enum CraftingJobStatus
+{
+ /// The workstation is actively processing the recipe.
+ InProgress,
+
+ /// Processing is complete; the result is ready to be collected.
+ Completed,
+
+ /// The crafted item has been collected by the player.
+ Collected
+}
diff --git a/src/OpenTheBox/Core/Crafting/Recipe.cs b/src/OpenTheBox/Core/Crafting/Recipe.cs
new file mode 100644
index 0000000..18c36ca
--- /dev/null
+++ b/src/OpenTheBox/Core/Crafting/Recipe.cs
@@ -0,0 +1,23 @@
+using OpenTheBox.Core.Enums;
+
+namespace OpenTheBox.Core.Crafting;
+
+///
+/// A crafting recipe that transforms ingredients into a result item at a specific workstation.
+///
+public sealed record Recipe(
+ string Id,
+ string NameKey,
+ WorkstationType Workstation,
+ List Ingredients,
+ RecipeResult Result);
+
+///
+/// A single ingredient requirement for a recipe.
+///
+public sealed record RecipeIngredient(string ItemDefinitionId, int Quantity);
+
+///
+/// The output of a completed recipe.
+///
+public sealed record RecipeResult(string ItemDefinitionId, int Quantity);
diff --git a/src/OpenTheBox/Core/Enums/AdventureTheme.cs b/src/OpenTheBox/Core/Enums/AdventureTheme.cs
index a699d75..7a8f1bc 100644
--- a/src/OpenTheBox/Core/Enums/AdventureTheme.cs
+++ b/src/OpenTheBox/Core/Enums/AdventureTheme.cs
@@ -32,5 +32,8 @@ public enum AdventureTheme
Microscopic,
/// Dark fantasy with gothic horror elements. Key resources: Blood, Mana. Unlocks Blood resource.
- DarkFantasy
+ DarkFantasy,
+
+ /// The final adventure. Acknowledges all previous adventures and secret branches. Unlocked by the endgame box.
+ Destiny
}
diff --git a/src/OpenTheBox/Core/GameState.cs b/src/OpenTheBox/Core/GameState.cs
index 46d6bd8..8c5bcaa 100644
--- a/src/OpenTheBox/Core/GameState.cs
+++ b/src/OpenTheBox/Core/GameState.cs
@@ -1,4 +1,5 @@
using OpenTheBox.Core.Characters;
+using OpenTheBox.Core.Crafting;
using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Items;
@@ -24,6 +25,7 @@ public sealed class GameState
public required HashSet UnlockedAdventures { get; set; }
public required HashSet UnlockedCosmetics { get; set; }
public required HashSet CompletedAdventures { get; set; }
+ public required HashSet CompletedSecretBranches { get; set; }
public required Dictionary AdventureSaveData { get; set; }
public required HashSet VisibleResources { get; set; }
public required HashSet VisibleStats { get; set; }
@@ -33,6 +35,7 @@ public sealed class GameState
public required TimeSpan TotalPlayTime { get; set; }
public required HashSet AvailableFonts { get; set; }
public required HashSet AvailableTextColors { get; set; }
+ public required List ActiveCraftingJobs { get; set; }
///
/// Returns the current value of a resource, or 0 if the resource is not tracked.
@@ -92,6 +95,7 @@ public sealed class GameState
UnlockedAdventures = [],
UnlockedCosmetics = [],
CompletedAdventures = [],
+ CompletedSecretBranches = [],
AdventureSaveData = [],
VisibleResources = [],
VisibleStats = [],
@@ -100,6 +104,7 @@ public sealed class GameState
CreatedAt = DateTime.UtcNow,
TotalPlayTime = TimeSpan.Zero,
AvailableFonts = [],
- AvailableTextColors = []
+ AvailableTextColors = [],
+ ActiveCraftingJobs = []
};
}
diff --git a/src/OpenTheBox/Data/ContentRegistry.cs b/src/OpenTheBox/Data/ContentRegistry.cs
index 3ff091f..4333218 100644
--- a/src/OpenTheBox/Data/ContentRegistry.cs
+++ b/src/OpenTheBox/Data/ContentRegistry.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using OpenTheBox.Core.Boxes;
+using OpenTheBox.Core.Crafting;
using OpenTheBox.Core.Interactions;
using OpenTheBox.Core.Items;
@@ -20,10 +21,12 @@ public class ContentRegistry
private readonly Dictionary _items = [];
private readonly Dictionary _boxes = [];
private readonly List _interactionRules = [];
+ private readonly Dictionary _recipes = [];
public void RegisterItem(ItemDefinition item) => _items[item.Id] = item;
public void RegisterBox(BoxDefinition box) => _boxes[box.Id] = box;
public void RegisterInteractionRule(InteractionRule rule) => _interactionRules.Add(rule);
+ public void RegisterRecipe(Recipe recipe) => _recipes[recipe.Id] = recipe;
public ItemDefinition? GetItem(string id) => _items.GetValueOrDefault(id);
public BoxDefinition? GetBox(string id) => _boxes.GetValueOrDefault(id);
@@ -36,12 +39,14 @@ public class ContentRegistry
public IReadOnlyDictionary Items => _items;
public IReadOnlyDictionary Boxes => _boxes;
public IReadOnlyList InteractionRules => _interactionRules;
+ public IReadOnlyDictionary Recipes => _recipes;
///
/// Loads content definitions from JSON files and returns a populated registry.
/// Files that do not exist are silently skipped.
///
- public static ContentRegistry LoadFromFiles(string itemsPath, string boxesPath, string interactionsPath)
+ public static ContentRegistry LoadFromFiles(
+ string itemsPath, string boxesPath, string interactionsPath, string? recipesPath = null)
{
var registry = new ContentRegistry();
@@ -78,6 +83,17 @@ public class ContentRegistry
}
}
+ if (recipesPath is not null && File.Exists(recipesPath))
+ {
+ var json = File.ReadAllText(recipesPath);
+ var recipes = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (recipes is not null)
+ {
+ foreach (var recipe in recipes)
+ registry.RegisterRecipe(recipe);
+ }
+ }
+
return registry;
}
}
diff --git a/src/OpenTheBox/Program.cs b/src/OpenTheBox/Program.cs
index 2d6a175..b33792a 100644
--- a/src/OpenTheBox/Program.cs
+++ b/src/OpenTheBox/Program.cs
@@ -1,4 +1,5 @@
using OpenTheBox.Core;
+using OpenTheBox.Core.Crafting;
using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Items;
using OpenTheBox.Data;
@@ -21,6 +22,7 @@ public static class Program
private static GameSimulation _simulation = null!;
private static RenderContext _renderContext = null!;
private static IRenderer _renderer = null!;
+ private static CraftingEngine _craftingEngine = null!;
private static bool _running = true;
private static readonly string LogFilePath = Path.Combine(
@@ -33,7 +35,7 @@ public static class Program
_saveManager = new SaveManager();
_loc = new LocalizationManager(Locale.EN);
_renderContext = new RenderContext();
- _renderer = RendererFactory.Create(_renderContext, _loc);
+ _renderer = RendererFactory.Create(_renderContext, _loc, _registry);
await MainMenuLoop();
}
@@ -152,11 +154,13 @@ public static class Program
_registry = ContentRegistry.LoadFromFiles(
"content/data/items.json",
"content/data/boxes.json",
- "content/data/interactions.json"
+ "content/data/interactions.json",
+ "content/data/recipes.json"
);
_simulation = new GameSimulation(_registry);
+ _craftingEngine = new CraftingEngine();
_renderContext = RenderContext.FromGameState(_state);
- _renderer = RendererFactory.Create(_renderContext, _loc);
+ _renderer = RendererFactory.Create(_renderContext, _loc, _registry);
}
private static void ChangeLanguage()
@@ -170,13 +174,16 @@ public static class Program
if (_state != null)
_state.CurrentLocale = newLocale;
- _renderer = RendererFactory.Create(_renderContext, _loc);
+ _renderer = RendererFactory.Create(_renderContext, _loc, _registry);
}
private static async Task GameLoop()
{
while (_running)
{
+ // Tick crafting jobs (InProgress → Completed)
+ TickCraftingJobs();
+
_renderer.Clear();
UpdateCompletionPercent();
_renderer.ShowGameState(_state, _renderContext);
@@ -214,6 +221,10 @@ public static class Program
if (_state.UnlockedCosmetics.Count > 0)
actions.Add((_loc.Get("action.appearance"), "appearance"));
+ var completedJobs = _state.ActiveCraftingJobs.Where(j => j.IsComplete).ToList();
+ if (completedJobs.Count > 0)
+ actions.Add((_loc.Get("action.collect_crafting") + $" ({completedJobs.Count})", "collect_crafting"));
+
actions.Add((_loc.Get("action.save"), "save"));
actions.Add((_loc.Get("action.quit"), "quit"));
@@ -228,6 +239,7 @@ public static class Program
case "inventory": ShowInventory(); break;
case "adventure": await StartAdventure(); break;
case "appearance": ChangeAppearance(); break;
+ case "collect_crafting": await CollectCrafting(); break;
case "save": SaveGame(); break;
case "quit": _running = false; break;
}
@@ -306,7 +318,7 @@ public static class Program
case UIFeatureUnlockedEvent uiEvt:
_renderContext.Unlock(uiEvt.Feature);
- _renderer = RendererFactory.Create(_renderContext, _loc);
+ _renderer = RendererFactory.Create(_renderContext, _loc, _registry);
_renderer.ShowUIFeatureUnlocked(
_loc.Get(GetUIFeatureLocKey(uiEvt.Feature)));
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
@@ -347,6 +359,21 @@ public static class Program
_renderer.ShowMessage(_loc.Get(cookieEvt.MessageKey));
_renderer.ShowMessage("----------------------");
break;
+
+ case CraftingStartedEvent craftEvt:
+ var recipeName = _registry.Recipes.TryGetValue(craftEvt.RecipeId, out var recDef)
+ ? _loc.Get(recDef.NameKey)
+ : craftEvt.RecipeId;
+ _renderer.ShowMessage(_loc.Get("craft.started", recipeName, craftEvt.Workstation.ToString()));
+ break;
+
+ case CraftingCompletedEvent craftDoneEvt:
+ _renderer.ShowMessage(_loc.Get("craft.completed", craftDoneEvt.Workstation.ToString()));
+ break;
+
+ case CraftingCollectedEvent:
+ // Item reveals are already handled by individual ItemReceivedEvents
+ break;
}
}
@@ -472,6 +499,28 @@ public static class Program
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
}
+ private static void TickCraftingJobs()
+ {
+ if (_craftingEngine is null) return;
+ var events = _craftingEngine.TickJobs(_state);
+ // Completed jobs will be shown in the CraftingPanel; no blocking render here.
+ }
+
+ private static async Task CollectCrafting()
+ {
+ var events = _craftingEngine.CollectCompleted(_state, _registry);
+
+ // Run meta pass on newly crafted items (some results may unlock features)
+ var newItems = events.OfType().Select(e => e.Item).ToList();
+ var metaEngine = new MetaEngine();
+ events.AddRange(metaEngine.ProcessNewItems(newItems, _state, _registry));
+
+ // Cascade: collected results may be ingredients for other recipes
+ events.AddRange(_craftingEngine.AutoCraftCheck(_state, _registry));
+
+ await RenderEvents(events);
+ }
+
private static void SaveGame()
{
_renderer.ShowMessage(_loc.Get("save.saving"));
diff --git a/src/OpenTheBox/Rendering/BasicRenderer.cs b/src/OpenTheBox/Rendering/BasicRenderer.cs
index 7df9113..68b012a 100644
--- a/src/OpenTheBox/Rendering/BasicRenderer.cs
+++ b/src/OpenTheBox/Rendering/BasicRenderer.cs
@@ -102,6 +102,11 @@ public sealed class BasicRenderer(LocalizationManager loc) : IRenderer
}
}
+ public void ShowAdventureHint(string hint)
+ {
+ Console.WriteLine($" ({hint})");
+ }
+
public void ShowUIFeatureUnlocked(string featureName)
{
Console.WriteLine("========================================");
diff --git a/src/OpenTheBox/Rendering/IRenderer.cs b/src/OpenTheBox/Rendering/IRenderer.cs
index dcec87b..bc7a422 100644
--- a/src/OpenTheBox/Rendering/IRenderer.cs
+++ b/src/OpenTheBox/Rendering/IRenderer.cs
@@ -53,6 +53,11 @@ public interface IRenderer
///
int ShowAdventureChoice(List options);
+ ///
+ /// Shows a subtle hint about why an adventure choice is unavailable, encouraging replay.
+ ///
+ void ShowAdventureHint(string hint);
+
///
/// Announces that a new UI feature has been unlocked.
///
diff --git a/src/OpenTheBox/Rendering/Panels/CraftingPanel.cs b/src/OpenTheBox/Rendering/Panels/CraftingPanel.cs
new file mode 100644
index 0000000..795e456
--- /dev/null
+++ b/src/OpenTheBox/Rendering/Panels/CraftingPanel.cs
@@ -0,0 +1,57 @@
+using OpenTheBox.Core;
+using OpenTheBox.Core.Crafting;
+using OpenTheBox.Data;
+using OpenTheBox.Localization;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+
+namespace OpenTheBox.Rendering.Panels;
+
+///
+/// Renders active crafting workstations showing progress bars and completion status.
+///
+public static class CraftingPanel
+{
+ ///
+ /// Builds a renderable panel showing all active crafting jobs with their progress.
+ ///
+ public static IRenderable Render(GameState state, ContentRegistry? registry = null, LocalizationManager? loc = null)
+ {
+ var rows = new List();
+
+ foreach (var job in state.ActiveCraftingJobs.OrderBy(j => j.StartedAt))
+ {
+ string name = job.RecipeId;
+ if (registry is not null && registry.Recipes.TryGetValue(job.RecipeId, out var recipe))
+ {
+ name = loc is not null ? loc.Get(recipe.NameKey) : recipe.NameKey;
+ }
+
+ string station = job.Workstation.ToString();
+
+ if (job.IsComplete)
+ {
+ // Completed: show checkmark
+ rows.Add(new Markup($" [bold green]✓[/] [yellow]{Markup.Escape(station)}[/]: {Markup.Escape(name)} — [bold green]{Markup.Escape(loc?.Get("craft.done") ?? "Done")}[/]"));
+ }
+ else
+ {
+ // In progress: show progress bar
+ int pct = (int)job.ProgressPercent;
+ int barWidth = 20;
+ int filled = barWidth * pct / 100;
+ string bar = new string('#', filled) + new string('-', barWidth - filled);
+ rows.Add(new Markup($" [yellow]{Markup.Escape(station)}[/]: {Markup.Escape(name)} [[{bar}]] {pct}%"));
+ }
+ }
+
+ if (rows.Count == 0)
+ {
+ rows.Add(new Markup($"[dim]{Markup.Escape(loc?.Get("craft.panel.empty") ?? "No active workshops.")}[/]"));
+ }
+
+ return new Panel(new Rows(rows))
+ .Header($"[bold orange1]{Markup.Escape(loc?.Get("craft.panel.title") ?? "Workshops")}[/]")
+ .Border(BoxBorder.Rounded);
+ }
+}
diff --git a/src/OpenTheBox/Rendering/RendererFactory.cs b/src/OpenTheBox/Rendering/RendererFactory.cs
index 8a3dfa1..73a90f5 100644
--- a/src/OpenTheBox/Rendering/RendererFactory.cs
+++ b/src/OpenTheBox/Rendering/RendererFactory.cs
@@ -1,3 +1,4 @@
+using OpenTheBox.Data;
using OpenTheBox.Localization;
namespace OpenTheBox.Rendering;
@@ -13,7 +14,7 @@ public static class RendererFactory
/// If the context has any Spectre-capable feature unlocked, a
/// is returned; otherwise the plain is used.
///
- public static IRenderer Create(RenderContext context, LocalizationManager loc)
+ public static IRenderer Create(RenderContext context, LocalizationManager loc, ContentRegistry? registry = null)
{
bool hasAnySpectreFeature =
context.HasColors ||
@@ -31,7 +32,7 @@ public static class RendererFactory
if (hasAnySpectreFeature)
{
- return new SpectreRenderer(context, loc);
+ return new SpectreRenderer(context, loc, registry);
}
return new BasicRenderer(loc);
diff --git a/src/OpenTheBox/Rendering/SpectreRenderer.cs b/src/OpenTheBox/Rendering/SpectreRenderer.cs
index 3055dc6..52a1326 100644
--- a/src/OpenTheBox/Rendering/SpectreRenderer.cs
+++ b/src/OpenTheBox/Rendering/SpectreRenderer.cs
@@ -1,6 +1,7 @@
using OpenTheBox.Core;
using OpenTheBox.Core.Characters;
using OpenTheBox.Core.Enums;
+using OpenTheBox.Data;
using OpenTheBox.Localization;
using OpenTheBox.Rendering.Panels;
using Spectre.Console;
@@ -234,6 +235,18 @@ public sealed class SpectreRenderer : IRenderer
return ShowSelection(_loc.Get("prompt.what_do"), options);
}
+ public void ShowAdventureHint(string hint)
+ {
+ if (_context.HasColors)
+ {
+ AnsiConsole.MarkupLine($" [dim italic]{Markup.Escape(hint)}[/]");
+ }
+ else
+ {
+ Console.WriteLine($" ({hint})");
+ }
+ }
+
// ── UI feature unlock announcement ──────────────────────────────────
public void ShowUIFeatureUnlocked(string featureName)
@@ -294,11 +307,13 @@ public sealed class SpectreRenderer : IRenderer
private RenderContext _context;
private readonly LocalizationManager _loc;
+ private ContentRegistry? _registry;
- public SpectreRenderer(RenderContext context, LocalizationManager loc)
+ public SpectreRenderer(RenderContext context, LocalizationManager loc, ContentRegistry? registry = null)
{
_context = context;
_loc = loc;
+ _registry = registry;
}
///
@@ -309,6 +324,14 @@ public sealed class SpectreRenderer : IRenderer
_context = context;
}
+ ///
+ /// Updates the content registry reference when it changes (e.g., after InitializeGame).
+ ///
+ public void UpdateRegistry(ContentRegistry registry)
+ {
+ _registry = registry;
+ }
+
// ── Private helpers ─────────────────────────────────────────────────
private static string RarityColor(string rarity) => rarity.ToLowerInvariant() switch
@@ -377,7 +400,7 @@ public sealed class SpectreRenderer : IRenderer
layout["Chat"].Update(new Panel("[dim]???[/]").Header("Chat"));
if (context.HasCraftingPanel)
- layout["Bottom"].Update(new Panel("[dim]Crafting area[/]").Header("Crafting"));
+ layout["Bottom"].Update(CraftingPanel.Render(state, _registry, _loc));
else
layout["Bottom"].Update(new Panel("[dim]???[/]").Header("???"));
@@ -419,6 +442,11 @@ public sealed class SpectreRenderer : IRenderer
AnsiConsole.Write(ChatPanel.Render([]));
}
+ if (context.HasCraftingPanel)
+ {
+ AnsiConsole.Write(CraftingPanel.Render(state, _registry, _loc));
+ }
+
if (context.HasCompletionTracker)
{
AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan"));
diff --git a/src/OpenTheBox/Simulation/CraftingEngine.cs b/src/OpenTheBox/Simulation/CraftingEngine.cs
new file mode 100644
index 0000000..08cf66d
--- /dev/null
+++ b/src/OpenTheBox/Simulation/CraftingEngine.cs
@@ -0,0 +1,216 @@
+using OpenTheBox.Core;
+using OpenTheBox.Core.Crafting;
+using OpenTheBox.Core.Enums;
+using OpenTheBox.Core.Items;
+using OpenTheBox.Data;
+using OpenTheBox.Simulation.Events;
+
+namespace OpenTheBox.Simulation;
+
+///
+/// Handles automatic background crafting. When the player has the CraftingPanel unlocked,
+/// an unlocked workstation, and sufficient materials, a recipe is started automatically.
+/// Jobs run in real time and can be collected once complete.
+///
+public class CraftingEngine
+{
+ ///
+ /// Returns the crafting duration based on the result item's rarity.
+ ///
+ public static TimeSpan GetCraftDuration(ItemRarity rarity) => rarity switch
+ {
+ ItemRarity.Common => TimeSpan.FromSeconds(15),
+ ItemRarity.Uncommon => TimeSpan.FromSeconds(30),
+ ItemRarity.Rare => TimeSpan.FromSeconds(60),
+ ItemRarity.Epic => TimeSpan.FromSeconds(90),
+ ItemRarity.Legendary => TimeSpan.FromSeconds(120),
+ ItemRarity.Mythic => TimeSpan.FromSeconds(180),
+ _ => TimeSpan.FromSeconds(30)
+ };
+
+ ///
+ /// Finds all recipes that can be started right now given unlocked workstations,
+ /// available materials, and free workstation slots.
+ /// Returns empty if CraftingPanel is not unlocked.
+ ///
+ public List FindCraftableRecipes(GameState state, ContentRegistry registry)
+ {
+ if (!state.HasUIFeature(UIFeature.CraftingPanel))
+ return [];
+
+ var busyStations = state.ActiveCraftingJobs
+ .Where(j => j.Status == CraftingJobStatus.InProgress)
+ .Select(j => j.Workstation)
+ .ToHashSet();
+
+ var craftable = new List();
+
+ foreach (var recipe in registry.Recipes.Values)
+ {
+ // Workstation must be unlocked
+ if (!state.UnlockedWorkstations.Contains(recipe.Workstation))
+ continue;
+
+ // Workstation must not be busy
+ if (busyStations.Contains(recipe.Workstation))
+ continue;
+
+ // All ingredients must be available in sufficient quantity
+ if (!HasIngredients(recipe, state))
+ continue;
+
+ craftable.Add(recipe);
+ }
+
+ return craftable;
+ }
+
+ ///
+ /// Starts a crafting job: consumes ingredients from inventory, creates the job.
+ ///
+ public List StartCraftingJob(Recipe recipe, GameState state, ContentRegistry registry)
+ {
+ var events = new List();
+
+ // Consume ingredients
+ foreach (var ingredient in recipe.Ingredients)
+ {
+ int remaining = ingredient.Quantity;
+ var matchingItems = state.Inventory
+ .Where(i => i.DefinitionId == ingredient.ItemDefinitionId)
+ .ToList();
+
+ foreach (var item in matchingItems)
+ {
+ if (remaining <= 0) break;
+
+ if (item.Quantity <= remaining)
+ {
+ remaining -= item.Quantity;
+ state.RemoveItem(item.Id);
+ events.Add(new ItemConsumedEvent(item.Id));
+ }
+ else
+ {
+ // Partially consume: reduce quantity
+ remaining = 0;
+ // Create a new item with reduced quantity to replace
+ state.RemoveItem(item.Id);
+ events.Add(new ItemConsumedEvent(item.Id));
+ var leftover = ItemInstance.Create(item.DefinitionId, item.Quantity - ingredient.Quantity);
+ state.AddItem(leftover);
+ }
+ }
+ }
+
+ // Determine duration from result item rarity
+ var resultDef = registry.GetItem(recipe.Result.ItemDefinitionId);
+ var duration = resultDef is not null
+ ? GetCraftDuration(resultDef.Rarity)
+ : TimeSpan.FromSeconds(30);
+
+ // Create the job
+ var job = new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = recipe.Id,
+ Workstation = recipe.Workstation,
+ StartedAt = DateTime.UtcNow,
+ Duration = duration,
+ Status = CraftingJobStatus.InProgress
+ };
+
+ state.ActiveCraftingJobs.Add(job);
+ events.Add(new CraftingStartedEvent(recipe.Id, recipe.Workstation, duration));
+
+ return events;
+ }
+
+ ///
+ /// Updates job statuses: marks InProgress jobs as Completed once their duration has elapsed.
+ ///
+ public List TickJobs(GameState state)
+ {
+ var events = new List();
+
+ foreach (var job in state.ActiveCraftingJobs)
+ {
+ if (job.Status == CraftingJobStatus.InProgress && job.IsComplete)
+ {
+ job.Status = CraftingJobStatus.Completed;
+ events.Add(new CraftingCompletedEvent(job.RecipeId, job.Workstation));
+ }
+ }
+
+ return events;
+ }
+
+ ///
+ /// Collects all completed jobs: creates result items, removes jobs from state.
+ ///
+ public List CollectCompleted(GameState state, ContentRegistry registry)
+ {
+ var events = new List();
+ var collectedItems = new List<(string ItemId, int Quantity)>();
+ var completedJobs = state.ActiveCraftingJobs
+ .Where(j => j.Status == CraftingJobStatus.Completed)
+ .ToList();
+
+ foreach (var job in completedJobs)
+ {
+ var recipe = registry.Recipes.GetValueOrDefault(job.RecipeId);
+ if (recipe is null) continue;
+
+ // Create result items
+ var resultItem = ItemInstance.Create(recipe.Result.ItemDefinitionId, recipe.Result.Quantity);
+ state.AddItem(resultItem);
+ events.Add(new ItemReceivedEvent(resultItem));
+ collectedItems.Add((recipe.Result.ItemDefinitionId, recipe.Result.Quantity));
+
+ // Remove the job
+ state.ActiveCraftingJobs.Remove(job);
+ }
+
+ if (collectedItems.Count > 0)
+ {
+ events.Add(new CraftingCollectedEvent(collectedItems));
+ }
+
+ return events;
+ }
+
+ ///
+ /// Auto-craft check: finds all craftable recipes and starts them.
+ /// Called after loot drops and after collecting crafted items (cascade).
+ ///
+ public List AutoCraftCheck(GameState state, ContentRegistry registry)
+ {
+ var events = new List();
+ var craftable = FindCraftableRecipes(state, registry);
+
+ foreach (var recipe in craftable)
+ {
+ // Re-check ingredients (previous StartCraftingJob may have consumed shared materials)
+ if (!HasIngredients(recipe, state))
+ continue;
+
+ events.AddRange(StartCraftingJob(recipe, state, registry));
+ }
+
+ return events;
+ }
+
+ ///
+ /// Checks whether the inventory contains all ingredients for a recipe.
+ ///
+ private static bool HasIngredients(Recipe recipe, GameState state)
+ {
+ foreach (var ingredient in recipe.Ingredients)
+ {
+ int available = state.CountItems(ingredient.ItemDefinitionId);
+ if (available < ingredient.Quantity)
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/OpenTheBox/Simulation/Events/GameEvent.cs b/src/OpenTheBox/Simulation/Events/GameEvent.cs
index cabf9ea..0d8d781 100644
--- a/src/OpenTheBox/Simulation/Events/GameEvent.cs
+++ b/src/OpenTheBox/Simulation/Events/GameEvent.cs
@@ -80,3 +80,18 @@ public sealed record MusicPlayedEvent() : GameEvent;
/// A fortune cookie message should be displayed.
///
public sealed record CookieFortuneEvent(string MessageKey) : GameEvent;
+
+///
+/// A crafting job has started at a workstation.
+///
+public sealed record CraftingStartedEvent(string RecipeId, WorkstationType Workstation, TimeSpan Duration) : GameEvent;
+
+///
+/// A crafting job has finished processing (ready for collection).
+///
+public sealed record CraftingCompletedEvent(string RecipeId, WorkstationType Workstation) : GameEvent;
+
+///
+/// Crafted items were collected from completed jobs.
+///
+public sealed record CraftingCollectedEvent(List<(string ItemId, int Quantity)> CollectedItems) : GameEvent;
diff --git a/src/OpenTheBox/Simulation/GameSimulation.cs b/src/OpenTheBox/Simulation/GameSimulation.cs
index 9fd5675..b48eb04 100644
--- a/src/OpenTheBox/Simulation/GameSimulation.cs
+++ b/src/OpenTheBox/Simulation/GameSimulation.cs
@@ -20,6 +20,7 @@ public class GameSimulation
private readonly InteractionEngine _interactionEngine;
private readonly MetaEngine _metaEngine;
private readonly ResourceEngine _resourceEngine;
+ private readonly CraftingEngine _craftingEngine;
public GameSimulation(ContentRegistry registry, Random? rng = null)
{
@@ -29,6 +30,7 @@ public class GameSimulation
_interactionEngine = new InteractionEngine(registry);
_metaEngine = new MetaEngine();
_resourceEngine = new ResourceEngine();
+ _craftingEngine = new CraftingEngine();
}
///
@@ -105,6 +107,9 @@ public class GameSimulation
// Run meta pass
events.AddRange(_metaEngine.ProcessNewItems(newItems, state, _registry));
+ // Run crafting auto-check (new materials may trigger recipes)
+ events.AddRange(_craftingEngine.AutoCraftCheck(state, _registry));
+
return events;
}
@@ -142,33 +147,8 @@ public class GameSimulation
private List HandleCraft(CraftAction action, GameState state)
{
- var events = new List();
-
- if (!state.UnlockedWorkstations.Contains(action.Station))
- {
- events.Add(new MessageEvent("error.workstation_locked"));
- return events;
- }
-
- // Consume all material items
- foreach (var materialId in action.MaterialIds)
- {
- var material = state.Inventory.FirstOrDefault(i => i.Id == materialId);
- if (material is null)
- {
- events.Add(new MessageEvent("error.material_not_found", [materialId.ToString()]));
- return events;
- }
-
- state.RemoveItem(materialId);
- events.Add(new ItemConsumedEvent(materialId));
- }
-
- // Crafting result is determined by interaction rules matching the materials
- // The interaction engine will handle rule matching and result production
- events.Add(new MessageEvent("craft.materials_consumed"));
-
- return events;
+ // Crafting is now automatic — delegate to the CraftingEngine
+ return _craftingEngine.AutoCraftCheck(state, _registry);
}
private List HandleStartAdventure(StartAdventureAction action, GameState state)
diff --git a/tests/OpenTheBox.Tests/CraftingTests.cs b/tests/OpenTheBox.Tests/CraftingTests.cs
new file mode 100644
index 0000000..7eaa0e6
--- /dev/null
+++ b/tests/OpenTheBox.Tests/CraftingTests.cs
@@ -0,0 +1,711 @@
+using OpenTheBox.Core;
+using OpenTheBox.Core.Crafting;
+using OpenTheBox.Core.Enums;
+using OpenTheBox.Core.Items;
+using OpenTheBox.Data;
+using OpenTheBox.Rendering.Panels;
+using OpenTheBox.Simulation;
+using OpenTheBox.Simulation.Events;
+
+namespace OpenTheBox.Tests;
+
+// ══════════════════════════════════════════════════════════════════════════
+// CraftingEngine Tests
+// ══════════════════════════════════════════════════════════════════════════
+
+public class CraftingEngineTests
+{
+ private static ContentRegistry CreateRegistryWithRecipe(
+ string recipeId = "refine_wood",
+ WorkstationType station = WorkstationType.Foundry,
+ string ingredientId = "material_wood_raw",
+ int ingredientQty = 2,
+ string resultId = "material_wood_refined",
+ int resultQty = 1)
+ {
+ var registry = new ContentRegistry();
+
+ registry.RegisterItem(new ItemDefinition(
+ ingredientId, $"item.{ingredientId}", ItemCategory.Material, ItemRarity.Common,
+ ["Material"], MaterialType: MaterialType.Wood, MaterialForm: MaterialForm.Raw));
+
+ registry.RegisterItem(new ItemDefinition(
+ resultId, $"item.{resultId}", ItemCategory.Material, ItemRarity.Common,
+ ["Material"], MaterialType: MaterialType.Wood, MaterialForm: MaterialForm.Refined));
+
+ registry.RegisterRecipe(new Recipe(
+ recipeId, $"recipe.{recipeId}", station,
+ [new RecipeIngredient(ingredientId, ingredientQty)],
+ new RecipeResult(resultId, resultQty)));
+
+ return registry;
+ }
+
+ private static GameState CreateState(bool craftingPanel = true, bool unlockFoundry = true)
+ {
+ var state = GameState.Create("Test", Locale.EN);
+ if (craftingPanel)
+ state.UnlockedUIFeatures.Add(UIFeature.CraftingPanel);
+ if (unlockFoundry)
+ state.UnlockedWorkstations.Add(WorkstationType.Foundry);
+ return state;
+ }
+
+ // ── FindCraftableRecipes ──────────────────────────────────────────
+
+ [Fact]
+ public void FindCraftable_NoCraftingPanel_ReturnsEmpty()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState(craftingPanel: false);
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ var result = engine.FindCraftableRecipes(state, registry);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void FindCraftable_NoWorkstation_ReturnsEmpty()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState(unlockFoundry: false);
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ var result = engine.FindCraftableRecipes(state, registry);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void FindCraftable_InsufficientIngredients_ReturnsEmpty()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.AddItem(ItemInstance.Create("material_wood_raw", 1)); // Need 2
+
+ var result = engine.FindCraftableRecipes(state, registry);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void FindCraftable_AllConditionsMet_ReturnsRecipe()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ var result = engine.FindCraftableRecipes(state, registry);
+
+ Assert.Single(result);
+ Assert.Equal("refine_wood", result[0].Id);
+ }
+
+ [Fact]
+ public void FindCraftable_WorkstationBusy_ReturnsEmpty()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ // Add an in-progress job using Foundry
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "some_other",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow,
+ Duration = TimeSpan.FromHours(1),
+ Status = CraftingJobStatus.InProgress
+ });
+
+ var result = engine.FindCraftableRecipes(state, registry);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void FindCraftable_CompletedJobOnStation_AllowsNew()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ // A completed job doesn't block the station
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "some_other",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow.AddHours(-2),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.Completed
+ });
+
+ var result = engine.FindCraftableRecipes(state, registry);
+
+ Assert.Single(result);
+ }
+
+ // ── StartCraftingJob ──────────────────────────────────────────────
+
+ [Fact]
+ public void StartJob_ConsumesIngredients_CreatesJob()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ var recipe = registry.Recipes["refine_wood"];
+ var events = engine.StartCraftingJob(recipe, state, registry);
+
+ // Ingredients consumed
+ Assert.Equal(0, state.CountItems("material_wood_raw"));
+ // Job created
+ Assert.Single(state.ActiveCraftingJobs);
+ Assert.Equal("refine_wood", state.ActiveCraftingJobs[0].RecipeId);
+ Assert.Equal(CraftingJobStatus.InProgress, state.ActiveCraftingJobs[0].Status);
+ // Events emitted
+ Assert.Contains(events, e => e is CraftingStartedEvent);
+ Assert.Contains(events, e => e is ItemConsumedEvent);
+ }
+
+ [Fact]
+ public void StartJob_DurationBasedOnResultRarity()
+ {
+ var engine = new CraftingEngine();
+ var registry = new ContentRegistry();
+ registry.RegisterItem(new ItemDefinition(
+ "input", "item.input", ItemCategory.Material, ItemRarity.Common, ["Material"]));
+ registry.RegisterItem(new ItemDefinition(
+ "output", "item.output", ItemCategory.Material, ItemRarity.Rare, ["Material"]));
+ registry.RegisterRecipe(new Recipe(
+ "test", "recipe.test", WorkstationType.Foundry,
+ [new RecipeIngredient("input", 1)],
+ new RecipeResult("output", 1)));
+
+ var state = CreateState();
+ state.AddItem(ItemInstance.Create("input", 1));
+
+ engine.StartCraftingJob(registry.Recipes["test"], state, registry);
+
+ Assert.Equal(TimeSpan.FromSeconds(60), state.ActiveCraftingJobs[0].Duration);
+ }
+
+ // ── TickJobs ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void TickJobs_InProgressNotElapsed_NoEvent()
+ {
+ var engine = new CraftingEngine();
+ var state = CreateState();
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "test",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow,
+ Duration = TimeSpan.FromHours(1),
+ Status = CraftingJobStatus.InProgress
+ });
+
+ var events = engine.TickJobs(state);
+
+ Assert.Empty(events);
+ Assert.Equal(CraftingJobStatus.InProgress, state.ActiveCraftingJobs[0].Status);
+ }
+
+ [Fact]
+ public void TickJobs_InProgressElapsed_MarksCompleted()
+ {
+ var engine = new CraftingEngine();
+ var state = CreateState();
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "test",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow.AddSeconds(-10),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.InProgress
+ });
+
+ var events = engine.TickJobs(state);
+
+ Assert.Single(events);
+ Assert.IsType(events[0]);
+ Assert.Equal(CraftingJobStatus.Completed, state.ActiveCraftingJobs[0].Status);
+ }
+
+ [Fact]
+ public void TickJobs_AlreadyCompleted_NoDoubleEvent()
+ {
+ var engine = new CraftingEngine();
+ var state = CreateState();
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "test",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow.AddSeconds(-10),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.Completed
+ });
+
+ var events = engine.TickJobs(state);
+
+ Assert.Empty(events);
+ }
+
+ // ── CollectCompleted ──────────────────────────────────────────────
+
+ [Fact]
+ public void Collect_CompletedJob_CreatesResultItem()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "refine_wood",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow.AddSeconds(-10),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.Completed
+ });
+
+ var events = engine.CollectCompleted(state, registry);
+
+ // Result item added
+ Assert.True(state.HasItem("material_wood_refined"));
+ // Job removed
+ Assert.Empty(state.ActiveCraftingJobs);
+ // Events: ItemReceivedEvent + CraftingCollectedEvent
+ Assert.Contains(events, e => e is ItemReceivedEvent);
+ Assert.Contains(events, e => e is CraftingCollectedEvent);
+ }
+
+ [Fact]
+ public void Collect_NoCompletedJobs_NoEvents()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+
+ var events = engine.CollectCompleted(state, registry);
+
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void Collect_InProgressJob_NotCollected()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "refine_wood",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow,
+ Duration = TimeSpan.FromHours(1),
+ Status = CraftingJobStatus.InProgress
+ });
+
+ var events = engine.CollectCompleted(state, registry);
+
+ Assert.Empty(events);
+ Assert.Single(state.ActiveCraftingJobs);
+ }
+
+ // ── AutoCraftCheck ────────────────────────────────────────────────
+
+ [Fact]
+ public void AutoCraft_MatchingRecipeExists_StartsJob()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ var events = engine.AutoCraftCheck(state, registry);
+
+ Assert.Contains(events, e => e is CraftingStartedEvent);
+ Assert.Single(state.ActiveCraftingJobs);
+ Assert.Equal(0, state.CountItems("material_wood_raw"));
+ }
+
+ [Fact]
+ public void AutoCraft_NoMatchingRecipe_NoAction()
+ {
+ var engine = new CraftingEngine();
+ var registry = CreateRegistryWithRecipe();
+ var state = CreateState();
+ // No materials
+
+ var events = engine.AutoCraftCheck(state, registry);
+
+ Assert.Empty(events);
+ Assert.Empty(state.ActiveCraftingJobs);
+ }
+
+ // ── CraftingJob properties ────────────────────────────────────────
+
+ [Fact]
+ public void CraftingJob_IsComplete_TrueWhenTimeElapsed()
+ {
+ var job = new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "test",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow.AddSeconds(-10),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.InProgress
+ };
+
+ Assert.True(job.IsComplete);
+ }
+
+ [Fact]
+ public void CraftingJob_IsComplete_FalseWhenNotElapsed()
+ {
+ var job = new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "test",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow,
+ Duration = TimeSpan.FromHours(1),
+ Status = CraftingJobStatus.InProgress
+ };
+
+ Assert.False(job.IsComplete);
+ }
+
+ [Fact]
+ public void CraftingJob_ProgressPercent_CompletedIs100()
+ {
+ var job = new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "test",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow.AddMinutes(-5),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.Completed
+ };
+
+ Assert.Equal(100.0, job.ProgressPercent);
+ }
+
+ // ── GetCraftDuration ──────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(ItemRarity.Common, 15)]
+ [InlineData(ItemRarity.Uncommon, 30)]
+ [InlineData(ItemRarity.Rare, 60)]
+ [InlineData(ItemRarity.Epic, 90)]
+ [InlineData(ItemRarity.Legendary, 120)]
+ [InlineData(ItemRarity.Mythic, 180)]
+ public void GetCraftDuration_CorrectPerRarity(ItemRarity rarity, int expectedSeconds)
+ {
+ Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), CraftingEngine.GetCraftDuration(rarity));
+ }
+}
+
+// ══════════════════════════════════════════════════════════════════════════
+// CraftingPanel Tests
+// ══════════════════════════════════════════════════════════════════════════
+
+public class CraftingPanelTests
+{
+ [Fact]
+ public void Render_EmptyJobs_ShowsEmptyMessage()
+ {
+ var state = GameState.Create("Test", Locale.EN);
+ var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
+
+ Assert.NotEmpty(output);
+ // Should contain the empty workshop message
+ Assert.Contains("active", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Render_InProgressJob_ShowsProgressBar()
+ {
+ var state = GameState.Create("Test", Locale.EN);
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "refine_wood",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow,
+ Duration = TimeSpan.FromHours(1),
+ Status = CraftingJobStatus.InProgress
+ });
+
+ var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
+
+ Assert.NotEmpty(output);
+ Assert.Contains("Foundry", output);
+ Assert.Contains("%", output);
+ }
+
+ [Fact]
+ public void Render_CompletedJob_ShowsDone()
+ {
+ var state = GameState.Create("Test", Locale.EN);
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "refine_wood",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow.AddSeconds(-10),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.Completed
+ });
+
+ var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
+
+ Assert.NotEmpty(output);
+ Assert.Contains("Foundry", output);
+ }
+
+ [Fact]
+ public void Render_WithRegistry_ResolvesRecipeName()
+ {
+ var registry = new ContentRegistry();
+ registry.RegisterRecipe(new Recipe(
+ "refine_wood", "recipe.refine_wood", WorkstationType.Foundry,
+ [new RecipeIngredient("material_wood_raw", 2)],
+ new RecipeResult("material_wood_refined", 1)));
+
+ var state = GameState.Create("Test", Locale.EN);
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "refine_wood",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow,
+ Duration = TimeSpan.FromHours(1),
+ Status = CraftingJobStatus.InProgress
+ });
+
+ var output = RenderHelper.RenderToString(CraftingPanel.Render(state, registry));
+
+ Assert.NotEmpty(output);
+ // Should show recipe name key (or localized version)
+ Assert.Contains("recipe.refine_wood", output);
+ }
+
+ [Fact]
+ public void Render_MixedJobs_DoesNotThrow()
+ {
+ var state = GameState.Create("Test", Locale.EN);
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "job1",
+ Workstation = WorkstationType.Foundry,
+ StartedAt = DateTime.UtcNow,
+ Duration = TimeSpan.FromHours(1),
+ Status = CraftingJobStatus.InProgress
+ });
+ state.ActiveCraftingJobs.Add(new CraftingJob
+ {
+ Id = Guid.NewGuid(),
+ RecipeId = "job2",
+ Workstation = WorkstationType.Furnace,
+ StartedAt = DateTime.UtcNow.AddSeconds(-10),
+ Duration = TimeSpan.FromSeconds(1),
+ Status = CraftingJobStatus.Completed
+ });
+
+ var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
+
+ Assert.NotEmpty(output);
+ Assert.Contains("Foundry", output);
+ Assert.Contains("Furnace", output);
+ }
+}
+
+// ══════════════════════════════════════════════════════════════════════════
+// Recipe Loading Tests
+// ══════════════════════════════════════════════════════════════════════════
+
+public class RecipeLoadingTests
+{
+ private static string ContentPath(string file) =>
+ Path.Combine("content", "data", file);
+
+ [Fact]
+ public void LoadRecipes_AllRecipesLoaded()
+ {
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"),
+ ContentPath("recipes.json"));
+
+ Assert.NotEmpty(registry.Recipes);
+ // recipes.json has 23 recipes (13 workstations)
+ Assert.True(registry.Recipes.Count >= 20, $"Expected >= 20 recipes, got {registry.Recipes.Count}");
+ }
+
+ [Fact]
+ public void LoadRecipes_AllRecipesHaveIngredients()
+ {
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"),
+ ContentPath("recipes.json"));
+
+ foreach (var recipe in registry.Recipes.Values)
+ {
+ Assert.NotEmpty(recipe.Ingredients);
+ foreach (var ingredient in recipe.Ingredients)
+ {
+ Assert.NotNull(ingredient.ItemDefinitionId);
+ Assert.True(ingredient.Quantity > 0, $"Recipe {recipe.Id} has ingredient with qty <= 0");
+ }
+ }
+ }
+
+ [Fact]
+ public void LoadRecipes_AllRecipesHaveResult()
+ {
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"),
+ ContentPath("recipes.json"));
+
+ foreach (var recipe in registry.Recipes.Values)
+ {
+ Assert.NotNull(recipe.Result);
+ Assert.NotNull(recipe.Result.ItemDefinitionId);
+ Assert.True(recipe.Result.Quantity > 0, $"Recipe {recipe.Id} has result with qty <= 0");
+ }
+ }
+
+ [Fact]
+ public void LoadRecipes_AllRecipesHaveValidWorkstation()
+ {
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"),
+ ContentPath("recipes.json"));
+
+ var validStations = Enum.GetValues().ToHashSet();
+
+ foreach (var recipe in registry.Recipes.Values)
+ {
+ Assert.Contains(recipe.Workstation, validStations);
+ }
+ }
+
+ [Fact]
+ public void LoadRecipes_NullPath_Succeeds()
+ {
+ // Without recipes path, registry should still work
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"));
+
+ Assert.Empty(registry.Recipes);
+ }
+
+ [Fact]
+ public void LoadRecipes_AllBlueprintItemsExist()
+ {
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"),
+ ContentPath("recipes.json"));
+
+ // Every blueprint item should have a WorkstationType set
+ var blueprints = registry.Items.Values
+ .Where(i => i.Tags.Contains("Blueprint"))
+ .ToList();
+
+ Assert.NotEmpty(blueprints);
+ foreach (var bp in blueprints)
+ {
+ Assert.True(bp.WorkstationType.HasValue, $"Blueprint {bp.Id} missing WorkstationType");
+ }
+ }
+
+ [Fact]
+ public void LoadRecipes_EachUsedWorkstation_HasBlueprint()
+ {
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"),
+ ContentPath("recipes.json"));
+
+ var usedStations = registry.Recipes.Values
+ .Select(r => r.Workstation)
+ .Distinct()
+ .ToHashSet();
+
+ var blueprintStations = registry.Items.Values
+ .Where(i => i.WorkstationType.HasValue)
+ .Select(i => i.WorkstationType!.Value)
+ .ToHashSet();
+
+ foreach (var station in usedStations)
+ {
+ Assert.Contains(station, blueprintStations);
+ }
+ }
+}
+
+// ══════════════════════════════════════════════════════════════════════════
+// GameSimulation Crafting Integration Tests
+// ══════════════════════════════════════════════════════════════════════════
+
+public class SimulationCraftingTests
+{
+ private static string ContentPath(string file) =>
+ Path.Combine("content", "data", file);
+
+ [Fact]
+ public void OpenBox_WithMaterials_TriggerAutoCraft()
+ {
+ var registry = ContentRegistry.LoadFromFiles(
+ ContentPath("items.json"),
+ ContentPath("boxes.json"),
+ ContentPath("interactions.json"),
+ ContentPath("recipes.json"));
+
+ var state = GameState.Create("Test", Locale.EN);
+ state.UnlockedUIFeatures.Add(UIFeature.CraftingPanel);
+ state.UnlockedWorkstations.Add(WorkstationType.Foundry);
+
+ // Pre-add materials so that after any box opening, auto-craft can check
+ state.AddItem(ItemInstance.Create("material_wood_raw", 2));
+
+ var engine = new CraftingEngine();
+ var events = engine.AutoCraftCheck(state, registry);
+
+ // Should start a crafting job for refine_wood
+ Assert.Contains(events, e => e is CraftingStartedEvent cs && cs.RecipeId == "refine_wood");
+ Assert.Single(state.ActiveCraftingJobs);
+ }
+}
diff --git a/tests/OpenTheBox.Tests/UnitTest1.cs b/tests/OpenTheBox.Tests/UnitTest1.cs
index ad9e192..8ecbafb 100644
--- a/tests/OpenTheBox.Tests/UnitTest1.cs
+++ b/tests/OpenTheBox.Tests/UnitTest1.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using OpenTheBox.Core;
using OpenTheBox.Core.Boxes;
+using OpenTheBox.Core.Crafting;
using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Interactions;
using OpenTheBox.Core.Items;
@@ -474,63 +475,74 @@ public class ContentValidationTests
// ── Full run integration tests ─────────────────────────────────────
- [Fact]
- public void FullRun_AllReachableContentIsObtained()
+ [Theory]
+ [InlineData(42)]
+ [InlineData(123)]
+ [InlineData(777)]
+ public void FullRun_AllReachableContentIsObtained(int seed)
{
// Simulates an entire game playthrough by repeatedly opening boxes
- // until all content reachable via box openings is unlocked.
+ // until all content reachable via box openings + crafting is unlocked.
// Uses only the simulation (zero I/O) to prove game completability.
- var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
- var simulation = new GameSimulation(registry, new Random(42));
+ var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
+ var simulation = new GameSimulation(registry, new Random(seed));
+ var craftingEngine = new CraftingEngine();
var state = GameState.Create("CompletionTest", Locale.EN);
// ── Compute the "reachable set" dynamically from item definitions ──
var allItems = registry.Items.Values.ToList();
- // All MetaUnlock values that exist on items → expected UI features
var expectedUIFeatures = allItems
.Where(i => i.MetaUnlock.HasValue)
.Select(i => i.MetaUnlock!.Value)
.ToHashSet();
- // All cosmetic definition IDs → expected cosmetics
var expectedCosmetics = allItems
.Where(i => i.CosmeticSlot.HasValue)
.Select(i => i.Id)
.ToHashSet();
- // All AdventureTheme values that exist on items → expected adventures
var expectedAdventures = allItems
.Where(i => i.AdventureTheme.HasValue)
.Select(i => i.AdventureTheme!.Value)
.ToHashSet();
- // All ResourceType values that exist on items → expected visible resources
var expectedResources = allItems
.Where(i => i.ResourceType.HasValue)
.Select(i => i.ResourceType!.Value)
.ToHashSet();
- // All lore fragment definition IDs
var expectedLore = allItems
.Where(i => i.Category == ItemCategory.LoreFragment)
.Select(i => i.Id)
.ToHashSet();
- // All StatType values that exist on items → expected visible stats
var expectedStats = allItems
.Where(i => i.StatType.HasValue)
.Select(i => i.StatType!.Value)
.ToHashSet();
- // All FontStyle values that exist on items → expected fonts
var expectedFonts = allItems
.Where(i => i.FontStyle.HasValue)
.Select(i => i.FontStyle!.Value)
.ToHashSet();
+ // Adventure token recipes: their outputs are also direct drops from adventure boxes,
+ // so they serve as bonus insurance, not mandatory crafting requirements.
+ var adventureRecipeIds = new HashSet
+ {
+ "chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire",
+ "fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber"
+ };
+
+ // Core recipe output IDs → required crafted items (only obtainable via crafting)
+ var expectedCraftedItems = registry.Recipes.Values
+ .Where(r => !adventureRecipeIds.Contains(r.Id))
+ .Select(r => r.Result.ItemDefinitionId)
+ .ToHashSet();
+
// Track all unique item definition IDs ever received
var seenDefinitionIds = new HashSet();
@@ -549,20 +561,32 @@ public class ContentValidationTests
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
if (box is null)
- break; // Game loop broke — will be caught by asserts
+ break;
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
var events = simulation.ProcessAction(action, state);
- // Track all received items
foreach (var evt in events.OfType())
- {
seenDefinitionIds.Add(evt.Item.DefinitionId);
- }
+
+ // ── Fast-forward crafting: complete all jobs through full cascade ──
+ bool keepCrafting;
+ do
+ {
+ foreach (var job in state.ActiveCraftingJobs
+ .Where(j => j.Status == CraftingJobStatus.InProgress))
+ job.StartedAt = DateTime.UtcNow.AddHours(-1);
+ craftingEngine.TickJobs(state);
+ var coll = craftingEngine.CollectCompleted(state, registry);
+ foreach (var evt in coll.OfType())
+ seenDefinitionIds.Add(evt.Item.DefinitionId);
+ var cascade = craftingEngine.AutoCraftCheck(state, registry);
+ keepCrafting = cascade.OfType().Any();
+ } while (keepCrafting);
totalBoxesOpened++;
- // Check if we've covered everything
+ // Check if we've covered everything (including crafted items)
bool allUIFeatures = expectedUIFeatures.IsSubsetOf(state.UnlockedUIFeatures);
bool allCosmetics = expectedCosmetics.IsSubsetOf(state.UnlockedCosmetics);
bool allAdventures = expectedAdventures.IsSubsetOf(state.UnlockedAdventures);
@@ -570,8 +594,10 @@ public class ContentValidationTests
bool allLore = expectedLore.IsSubsetOf(seenDefinitionIds);
bool allStats = expectedStats.IsSubsetOf(state.VisibleStats);
bool allFonts = expectedFonts.IsSubsetOf(state.AvailableFonts);
+ bool allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds);
- if (allUIFeatures && allCosmetics && allAdventures && allResources && allLore && allStats && allFonts)
+ if (allUIFeatures && allCosmetics && allAdventures && allResources
+ && allLore && allStats && allFonts && allCrafted)
break; // 100% completion reached
}
@@ -604,6 +630,10 @@ public class ContentValidationTests
var missingFonts = expectedFonts.Except(state.AvailableFonts).ToList();
Assert.True(missingFonts.Count == 0,
$"Missing fonts after {totalBoxesOpened} boxes: {string.Join(", ", missingFonts)}");
+
+ var missingCrafted = expectedCraftedItems.Except(seenDefinitionIds).ToList();
+ Assert.True(missingCrafted.Count == 0,
+ $"Missing crafted items after {totalBoxesOpened} boxes: {string.Join(", ", missingCrafted)}");
}
[Fact]
@@ -613,7 +643,7 @@ public class ContentValidationTests
// in inventory. This validates the box_of_boxes guaranteed roll
// sustains the game loop indefinitely.
- var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
+ var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
var simulation = new GameSimulation(registry, new Random(123));
var state = GameState.Create("LoopTest", Locale.EN);
@@ -638,15 +668,18 @@ public class ContentValidationTests
"No boxes remaining after 500 openings. Game loop is unsustainable.");
}
- [Fact]
- public void FullRun_PacingReport()
+ [Theory]
+ [InlineData(42)]
+ [InlineData(123)]
+ [InlineData(777)]
+ public void FullRun_PacingReport(int seed)
{
// Diagnostic test: outputs a pacing report showing when each piece
- // of content is first unlocked. Not a pass/fail test — it always
- // passes but prints progression milestones to the test output.
+ // of content is first unlocked, including crafting progression.
- var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
- var simulation = new GameSimulation(registry, new Random(42));
+ var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
+ var simulation = new GameSimulation(registry, new Random(seed));
+ var craftingEngine = new CraftingEngine();
var state = GameState.Create("PacingTest", Locale.EN);
var allItems = registry.Items.Values.ToList();
@@ -672,13 +705,32 @@ public class ContentValidationTests
.Select(i => i.Id)
.ToHashSet();
+ // Expected workstation blueprints (items with WorkstationType set)
+ var expectedBlueprints = allItems
+ .Where(i => i.WorkstationType.HasValue)
+ .Select(i => i.Id)
+ .ToHashSet();
+
+ // Adventure token recipes (bonus, not required for completion)
+ var adventureRecipeIds = new HashSet
+ {
+ "chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire",
+ "fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber"
+ };
+
+ // Expected crafted outputs (core recipes only — adventure outputs drop directly from boxes)
+ var expectedCraftedItems = registry.Recipes.Values
+ .Where(r => !adventureRecipeIds.Contains(r.Id))
+ .Select(r => r.Result.ItemDefinitionId)
+ .ToHashSet();
+
var seenDefinitionIds = new HashSet();
// Track unlock milestones: (box#, description)
var milestones = new List<(int boxNum, string description)>();
// Track previous counts to detect new unlocks
- int prevUI = 0, prevCos = 0, prevAdv = 0, prevRes = 0, prevLore = 0;
+ int prevUI = 0, prevCos = 0, prevAdv = 0, prevRes = 0, prevLore = 0, prevBP = 0, prevCraft = 0;
var starterBox = ItemInstance.Create("box_starter");
state.AddItem(starterBox);
@@ -699,6 +751,21 @@ public class ContentValidationTests
foreach (var evt in events.OfType())
seenDefinitionIds.Add(evt.Item.DefinitionId);
+ // ── Fast-forward crafting through full cascade ──
+ bool keepCrafting;
+ do
+ {
+ foreach (var job in state.ActiveCraftingJobs
+ .Where(j => j.Status == CraftingJobStatus.InProgress))
+ job.StartedAt = DateTime.UtcNow.AddHours(-1);
+ craftingEngine.TickJobs(state);
+ var coll = craftingEngine.CollectCompleted(state, registry);
+ foreach (var evt in coll.OfType())
+ seenDefinitionIds.Add(evt.Item.DefinitionId);
+ var cascade = craftingEngine.AutoCraftCheck(state, registry);
+ keepCrafting = cascade.OfType().Any();
+ } while (keepCrafting);
+
totalBoxesOpened++;
// Detect new unlocks
@@ -707,9 +774,21 @@ public class ContentValidationTests
int curAdv = state.UnlockedAdventures.Count(a => expectedAdventures.Contains(a));
int curRes = state.VisibleResources.Count(r => expectedResources.Contains(r));
int curLore = seenDefinitionIds.Count(id => expectedLore.Contains(id));
+ int curBP = state.UnlockedWorkstations.Count;
+ int curCraft = seenDefinitionIds.Count(id => expectedCraftedItems.Contains(id));
if (curUI > prevUI)
- milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: +{string.Join(", ", state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).Except(milestones.Where(m => m.description.StartsWith("UI")).SelectMany(_ => Array.Empty())))}"));
+ {
+ var newFeatures = state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).ToList();
+ milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: {newFeatures.Last()}"));
+ }
+ if (curBP > prevBP)
+ {
+ var newStation = state.UnlockedWorkstations.Last();
+ milestones.Add((totalBoxesOpened, $"Workshop {curBP}/{expectedBlueprints.Count}: {newStation}"));
+ }
+ if (curCraft > prevCraft)
+ milestones.Add((totalBoxesOpened, $"Crafted: {curCraft}/{expectedCraftedItems.Count}"));
if (curCos > prevCos)
milestones.Add((totalBoxesOpened, $"Cosmetics: {curCos}/{expectedCosmetics.Count}"));
if (curAdv > prevAdv)
@@ -719,31 +798,33 @@ public class ContentValidationTests
if (curLore > prevLore)
milestones.Add((totalBoxesOpened, $"Lore: {curLore}/{expectedLore.Count}"));
- prevUI = curUI; prevCos = curCos; prevAdv = curAdv; prevRes = curRes; prevLore = curLore;
+ prevUI = curUI; prevCos = curCos; prevAdv = curAdv;
+ prevRes = curRes; prevLore = curLore; prevBP = curBP; prevCraft = curCraft;
complete = curUI == expectedUIFeatures.Count
&& curCos == expectedCosmetics.Count
&& curAdv == expectedAdventures.Count
&& curRes == expectedResources.Count
- && curLore == expectedLore.Count;
+ && curLore == expectedLore.Count
+ && curCraft == expectedCraftedItems.Count;
}
// Build the pacing report
var report = new System.Text.StringBuilder();
report.AppendLine();
- report.AppendLine("╔══════════════════════════════════════════════════════╗");
- report.AppendLine("║ PACING REPORT (seed=42) ║");
- report.AppendLine("╠══════════════════════════════════════════════════════╣");
- report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-32}║");
- report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-36}║");
- report.AppendLine("╠══════════════════════════════════════════════════════╣");
- report.AppendLine("║ Box# │ Milestone ║");
- report.AppendLine("╟────────┼────────────────────────────────────────────╢");
+ report.AppendLine("╔════════════════════════════════════════════════════════════╗");
+ report.AppendLine($"║ PACING REPORT (seed={seed,-6}) ║");
+ report.AppendLine("╠════════════════════════════════════════════════════════════╣");
+ report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║");
+ report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-42}║");
+ report.AppendLine("╠════════════════════════════════════════════════════════════╣");
+ report.AppendLine("║ Box# │ Milestone ║");
+ report.AppendLine("╟────────┼──────────────────────────────────────────────────╢");
foreach (var (boxNum, desc) in milestones)
{
- report.AppendLine($"║ {boxNum,5} │ {desc,-42} ║");
+ report.AppendLine($"║ {boxNum,5} │ {desc,-48} ║");
}
- report.AppendLine("╠══════════════════════════════════════════════════════╣");
+ report.AppendLine("╠════════════════════════════════════════════════════════════╣");
// Summary by category with completion box#
int uiDoneAt = milestones.LastOrDefault(m => m.description.Contains($"UI Feature {expectedUIFeatures.Count}/")).boxNum;
@@ -751,18 +832,150 @@ public class ContentValidationTests
int advDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Adventures: {expectedAdventures.Count}/")).boxNum;
int resDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Resources: {expectedResources.Count}/")).boxNum;
int loreDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Lore: {expectedLore.Count}/")).boxNum;
+ int bpDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Workshop {expectedBlueprints.Count}/")).boxNum;
+ int craftDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Crafted: {expectedCraftedItems.Count}/")).boxNum;
- report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-17}║");
- report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-17}║");
- report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-17}║");
- report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-17}║");
- report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-17}║");
- report.AppendLine("╚══════════════════════════════════════════════════════╝");
+ report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-23}║");
+ report.AppendLine($"║ Workshops ({expectedBlueprints.Count,2}) complete at box #{bpDoneAt,-23}║");
+ report.AppendLine($"║ Crafted ({expectedCraftedItems.Count,2}) complete at box #{craftDoneAt,-23}║");
+ report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-23}║");
+ report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-23}║");
+ report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-23}║");
+ report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-23}║");
+ report.AppendLine("╚════════════════════════════════════════════════════════════╝");
- // Output via ITestOutputHelper would be ideal, but Assert message works too
Assert.True(true, report.ToString());
+ Console.WriteLine(report.ToString());
+ }
+
+ // ── Ultimate Ending Test ────────────────────────────────────────────
+
+ ///
+ /// Simulates a full playthrough where the player completes all adventures,
+ /// discovers all 9 secret branches, obtains the destiny token from box_endgame,
+ /// and verifies the ultimate ending is achievable.
+ ///
+ [Theory]
+ [InlineData(42)]
+ [InlineData(123)]
+ [InlineData(777)]
+ public void FullRun_UltimateEnding_Achievable(int seed)
+ {
+ var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath);
+ var simulation = new GameSimulation(registry, new Random(seed));
+ var craftingEngine = new CraftingEngine();
+ var state = GameState.Create("UltimateTest", Locale.EN);
+
+ // All 9 secret branch IDs matching the adventure .lor scripts
+ var allSecretBranches = new[]
+ {
+ "space_box_whisperer",
+ "medieval_dragon_charmer",
+ "pirate_one_of_us",
+ "contemporary_vip",
+ "sentimental_true_sight",
+ "prehistoric_champion",
+ "cosmic_enlightened",
+ "microscopic_surgeon",
+ "darkfantasy_blood_communion"
+ };
+
+ // All 9 regular adventure themes (excluding Destiny)
+ var regularThemes = Enum.GetValues()
+ .Where(t => t != AdventureTheme.Destiny)
+ .ToList();
+
+ // Phase 1: Open boxes until all resources are visible (triggers box_endgame availability)
+ var starterBox = ItemInstance.Create("box_starter");
+ state.AddItem(starterBox);
+
+ const int maxBoxOpenings = 10_000;
+ bool gotDestinyToken = false;
+
+ for (int i = 0; i < maxBoxOpenings; i++)
+ {
+ var box = state.Inventory
+ .FirstOrDefault(item => registry.IsBox(item.DefinitionId));
+ if (box is null) break;
+
+ var events = simulation.ProcessAction(
+ new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }, state);
+
+ // Check if we received the destiny token
+ foreach (var evt in events.OfType())
+ {
+ if (evt.Item.DefinitionId == "destiny_token")
+ gotDestinyToken = true;
+ }
+
+ // Fast-forward crafting
+ bool keepCrafting;
+ do
+ {
+ foreach (var job in state.ActiveCraftingJobs
+ .Where(j => j.Status == CraftingJobStatus.InProgress))
+ job.StartedAt = DateTime.UtcNow.AddHours(-1);
+ craftingEngine.TickJobs(state);
+ craftingEngine.CollectCompleted(state, registry);
+ var cascade = craftingEngine.AutoCraftCheck(state, registry);
+ keepCrafting = cascade.OfType().Any();
+ } while (keepCrafting);
+ }
+
+ // Phase 2: Verify destiny token was obtained
+ Assert.True(gotDestinyToken,
+ "destiny_token should be obtainable from box_endgame after all resources are visible");
+
+ // Verify Destiny adventure is unlocked
+ Assert.Contains(AdventureTheme.Destiny, state.UnlockedAdventures);
+
+ // Phase 3: Simulate completing all regular adventures with secret branches
+ foreach (var theme in regularThemes)
+ {
+ state.CompletedAdventures.Add(theme.ToString());
+ }
+
+ foreach (var branchId in allSecretBranches)
+ {
+ state.CompletedSecretBranches.Add(branchId);
+ }
+
+ // Phase 4: Verify ultimate ending conditions
+ Assert.Equal(9, state.CompletedSecretBranches.Count);
+ Assert.Equal(regularThemes.Count, state.CompletedAdventures.Count);
+ Assert.True(
+ state.CompletedSecretBranches.Count >= Adventures.AdventureEngine.TotalSecretBranchThemes,
+ $"All {Adventures.AdventureEngine.TotalSecretBranchThemes} secret branches should be found " +
+ $"(got {state.CompletedSecretBranches.Count})");
+
+ // Phase 5: Verify the destiny adventure script exists
+ string destinyScript = Path.Combine("content", "adventures", "destiny", "intro.lor");
+ Assert.True(File.Exists(destinyScript),
+ $"Destiny adventure script should exist at {destinyScript}");
+
+ // Phase 6: Verify all secret branch IDs match the expected count
+ Assert.Equal(Adventures.AdventureEngine.TotalSecretBranchThemes, allSecretBranches.Length);
+
+ // Build report
+ var report = new System.Text.StringBuilder();
+ report.AppendLine();
+ report.AppendLine("╔════════════════════════════════════════════════════════════╗");
+ report.AppendLine($"║ ULTIMATE ENDING REPORT (seed={seed,-6}) ║");
+ report.AppendLine("╠════════════════════════════════════════════════════════════╣");
+ report.AppendLine($"║ Destiny token obtained: {(gotDestinyToken ? "YES" : "NO"),-34}║");
+ report.AppendLine($"║ Destiny adventure unlocked: {(state.UnlockedAdventures.Contains(AdventureTheme.Destiny) ? "YES" : "NO"),-30}║");
+ report.AppendLine($"║ Adventures completed: {state.CompletedAdventures.Count,2}/{regularThemes.Count,-33}║");
+ report.AppendLine($"║ Secret branches found: {state.CompletedSecretBranches.Count,2}/{Adventures.AdventureEngine.TotalSecretBranchThemes,-32}║");
+ report.AppendLine($"║ Ultimate ending achievable: YES{' ',-27}║");
+ report.AppendLine("╠════════════════════════════════════════════════════════════╣");
+ report.AppendLine("║ Secret Branches: ║");
+ foreach (var branch in allSecretBranches)
+ {
+ bool found = state.CompletedSecretBranches.Contains(branch);
+ report.AppendLine($"║ {(found ? "[x]" : "[ ]")} {branch,-52}║");
+ }
+ report.AppendLine("╚════════════════════════════════════════════════════════════╝");
- // Also write to console for visibility in test runners
Console.WriteLine(report.ToString());
}