Add adventure secret branches, Destiny finale, crafting system, and project docs

Integrate stats, resources, and cosmetics into adventures via conditional
branches gated by game state checks. Each of the 9 adventures now has a
secret branch that rewards exploration and encourages replay with subtle
hints on locked choices. The endgame box now triggers a Destiny adventure
that acknowledges all completed adventures and secret branches, with four
ending tiers culminating in an ultimate ending when all 9 secrets are found.

Also adds the crafting engine, CLAUDE.md and specifications.md for faster
onboarding.
This commit is contained in:
Samuel Bouchet 2026-03-11 17:50:37 +01:00
parent 4c4d528187
commit c9f8a9566a
35 changed files with 2210 additions and 241 deletions

52
CLAUDE.md Normal file
View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
-> .

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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": []
}

View file

@ -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"}
]

View file

@ -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",

View file

@ -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..."
}

View file

@ -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..."
}

114
specifications.md Normal file
View file

@ -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

View file

@ -23,6 +23,30 @@ public enum GameEventKind
ResourceAdded
}
/// <summary>
/// Separator used in choice option text to embed a hint for when the option is disabled.
/// Format: "Option text|||Hint shown when unavailable"
/// </summary>
/// <example>
/// In .lor files: Open the secret path|||A keen sense of Luck might help here... #label [if hasStat("Luck", 30)]
/// </example>
internal static class HintSeparator
{
public const string Delimiter = "|||";
/// <summary>
/// Splits a choice option text into the visible text and an optional hint.
/// </summary>
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());
}
}
/// <summary>
/// Wraps the Loreline API for adventure playback. Loads <c>.lor</c> 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");
/// <summary>
/// Total number of regular (non-Destiny) adventure themes that can have secret branches.
/// </summary>
public static readonly int TotalSecretBranchThemes = Enum.GetValues<AdventureTheme>()
.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<string>();
var hints = new List<string?>();
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<string, Loreline.Interpreter.Function>
{
// ── 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<StatType>(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<StatType>(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<ResourceType>(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<ResourceType>(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;
}
};
}

View file

@ -0,0 +1,30 @@
using OpenTheBox.Core.Enums;
namespace OpenTheBox.Core.Crafting;
/// <summary>
/// Represents an active crafting process at a workstation.
/// Tracks the recipe being crafted, timing, and completion status.
/// </summary>
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; }
/// <summary>
/// Returns the current progress as a percentage (0-100).
/// Completed jobs always return 100%.
/// </summary>
public double ProgressPercent => Status >= CraftingJobStatus.Completed
? 100.0
: Math.Min(100.0, (DateTime.UtcNow - StartedAt).TotalSeconds / Duration.TotalSeconds * 100.0);
/// <summary>
/// Returns true if the crafting duration has elapsed.
/// </summary>
public bool IsComplete => Status >= CraftingJobStatus.Completed || DateTime.UtcNow >= StartedAt + Duration;
}

View file

@ -0,0 +1,16 @@
namespace OpenTheBox.Core.Crafting;
/// <summary>
/// The lifecycle status of a crafting job.
/// </summary>
public enum CraftingJobStatus
{
/// <summary>The workstation is actively processing the recipe.</summary>
InProgress,
/// <summary>Processing is complete; the result is ready to be collected.</summary>
Completed,
/// <summary>The crafted item has been collected by the player.</summary>
Collected
}

View file

@ -0,0 +1,23 @@
using OpenTheBox.Core.Enums;
namespace OpenTheBox.Core.Crafting;
/// <summary>
/// A crafting recipe that transforms ingredients into a result item at a specific workstation.
/// </summary>
public sealed record Recipe(
string Id,
string NameKey,
WorkstationType Workstation,
List<RecipeIngredient> Ingredients,
RecipeResult Result);
/// <summary>
/// A single ingredient requirement for a recipe.
/// </summary>
public sealed record RecipeIngredient(string ItemDefinitionId, int Quantity);
/// <summary>
/// The output of a completed recipe.
/// </summary>
public sealed record RecipeResult(string ItemDefinitionId, int Quantity);

View file

@ -32,5 +32,8 @@ public enum AdventureTheme
Microscopic,
/// <summary>Dark fantasy with gothic horror elements. Key resources: Blood, Mana. Unlocks Blood resource.</summary>
DarkFantasy
DarkFantasy,
/// <summary>The final adventure. Acknowledges all previous adventures and secret branches. Unlocked by the endgame box.</summary>
Destiny
}

View file

@ -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<AdventureTheme> UnlockedAdventures { get; set; }
public required HashSet<string> UnlockedCosmetics { get; set; }
public required HashSet<string> CompletedAdventures { get; set; }
public required HashSet<string> CompletedSecretBranches { get; set; }
public required Dictionary<string, string> AdventureSaveData { get; set; }
public required HashSet<ResourceType> VisibleResources { get; set; }
public required HashSet<StatType> VisibleStats { get; set; }
@ -33,6 +35,7 @@ public sealed class GameState
public required TimeSpan TotalPlayTime { get; set; }
public required HashSet<FontStyle> AvailableFonts { get; set; }
public required HashSet<TextColor> AvailableTextColors { get; set; }
public required List<CraftingJob> ActiveCraftingJobs { get; set; }
/// <summary>
/// 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 = []
};
}

View file

@ -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<string, ItemDefinition> _items = [];
private readonly Dictionary<string, BoxDefinition> _boxes = [];
private readonly List<InteractionRule> _interactionRules = [];
private readonly Dictionary<string, Recipe> _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<string, ItemDefinition> Items => _items;
public IReadOnlyDictionary<string, BoxDefinition> Boxes => _boxes;
public IReadOnlyList<InteractionRule> InteractionRules => _interactionRules;
public IReadOnlyDictionary<string, Recipe> Recipes => _recipes;
/// <summary>
/// Loads content definitions from JSON files and returns a populated registry.
/// Files that do not exist are silently skipped.
/// </summary>
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<List<Recipe>>(json, JsonOptions);
if (recipes is not null)
{
foreach (var recipe in recipes)
registry.RegisterRecipe(recipe);
}
}
return registry;
}
}

View file

@ -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<ItemReceivedEvent>().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"));

View file

@ -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("========================================");

View file

@ -53,6 +53,11 @@ public interface IRenderer
/// </summary>
int ShowAdventureChoice(List<string> options);
/// <summary>
/// Shows a subtle hint about why an adventure choice is unavailable, encouraging replay.
/// </summary>
void ShowAdventureHint(string hint);
/// <summary>
/// Announces that a new UI feature has been unlocked.
/// </summary>

View file

@ -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;
/// <summary>
/// Renders active crafting workstations showing progress bars and completion status.
/// </summary>
public static class CraftingPanel
{
/// <summary>
/// Builds a renderable panel showing all active crafting jobs with their progress.
/// </summary>
public static IRenderable Render(GameState state, ContentRegistry? registry = null, LocalizationManager? loc = null)
{
var rows = new List<IRenderable>();
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);
}
}

View file

@ -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 <see cref="SpectreRenderer"/>
/// is returned; otherwise the plain <see cref="BasicRenderer"/> is used.
/// </summary>
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);

View file

@ -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;
}
/// <summary>
@ -309,6 +324,14 @@ public sealed class SpectreRenderer : IRenderer
_context = context;
}
/// <summary>
/// Updates the content registry reference when it changes (e.g., after InitializeGame).
/// </summary>
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"));

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public class CraftingEngine
{
/// <summary>
/// Returns the crafting duration based on the result item's rarity.
/// </summary>
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)
};
/// <summary>
/// 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.
/// </summary>
public List<Recipe> 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<Recipe>();
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;
}
/// <summary>
/// Starts a crafting job: consumes ingredients from inventory, creates the job.
/// </summary>
public List<GameEvent> StartCraftingJob(Recipe recipe, GameState state, ContentRegistry registry)
{
var events = new List<GameEvent>();
// 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;
}
/// <summary>
/// Updates job statuses: marks InProgress jobs as Completed once their duration has elapsed.
/// </summary>
public List<GameEvent> TickJobs(GameState state)
{
var events = new List<GameEvent>();
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;
}
/// <summary>
/// Collects all completed jobs: creates result items, removes jobs from state.
/// </summary>
public List<GameEvent> CollectCompleted(GameState state, ContentRegistry registry)
{
var events = new List<GameEvent>();
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;
}
/// <summary>
/// Auto-craft check: finds all craftable recipes and starts them.
/// Called after loot drops and after collecting crafted items (cascade).
/// </summary>
public List<GameEvent> AutoCraftCheck(GameState state, ContentRegistry registry)
{
var events = new List<GameEvent>();
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;
}
/// <summary>
/// Checks whether the inventory contains all ingredients for a recipe.
/// </summary>
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;
}
}

View file

@ -80,3 +80,18 @@ public sealed record MusicPlayedEvent() : GameEvent;
/// A fortune cookie message should be displayed.
/// </summary>
public sealed record CookieFortuneEvent(string MessageKey) : GameEvent;
/// <summary>
/// A crafting job has started at a workstation.
/// </summary>
public sealed record CraftingStartedEvent(string RecipeId, WorkstationType Workstation, TimeSpan Duration) : GameEvent;
/// <summary>
/// A crafting job has finished processing (ready for collection).
/// </summary>
public sealed record CraftingCompletedEvent(string RecipeId, WorkstationType Workstation) : GameEvent;
/// <summary>
/// Crafted items were collected from completed jobs.
/// </summary>
public sealed record CraftingCollectedEvent(List<(string ItemId, int Quantity)> CollectedItems) : GameEvent;

View file

@ -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();
}
/// <summary>
@ -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<GameEvent> HandleCraft(CraftAction action, GameState state)
{
var events = new List<GameEvent>();
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<GameEvent> HandleStartAdventure(StartAdventureAction action, GameState state)

View file

@ -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<CraftingCompletedEvent>(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<WorkstationType>().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);
}
}

View file

@ -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<string>
{
"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<string>();
@ -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<ItemReceivedEvent>())
{
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<ItemReceivedEvent>())
seenDefinitionIds.Add(evt.Item.DefinitionId);
var cascade = craftingEngine.AutoCraftCheck(state, registry);
keepCrafting = cascade.OfType<CraftingStartedEvent>().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<string>
{
"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<string>();
// 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<ItemReceivedEvent>())
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<ItemReceivedEvent>())
seenDefinitionIds.Add(evt.Item.DefinitionId);
var cascade = craftingEngine.AutoCraftCheck(state, registry);
keepCrafting = cascade.OfType<CraftingStartedEvent>().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<UIFeature>())))}"));
{
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 ────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<AdventureTheme>()
.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<ItemReceivedEvent>())
{
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<CraftingStartedEvent>().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());
}