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:
parent
4c4d528187
commit
c9f8a9566a
35 changed files with 2210 additions and 241 deletions
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
176
content/adventures/destiny/intro.lor
Normal file
176
content/adventures/destiny/intro.lor
Normal 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
|
||||
-> .
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
114
specifications.md
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
30
src/OpenTheBox/Core/Crafting/CraftingJob.cs
Normal file
30
src/OpenTheBox/Core/Crafting/CraftingJob.cs
Normal 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;
|
||||
}
|
||||
16
src/OpenTheBox/Core/Crafting/CraftingJobStatus.cs
Normal file
16
src/OpenTheBox/Core/Crafting/CraftingJobStatus.cs
Normal 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
|
||||
}
|
||||
23
src/OpenTheBox/Core/Crafting/Recipe.cs
Normal file
23
src/OpenTheBox/Core/Crafting/Recipe.cs
Normal 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);
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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("========================================");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
57
src/OpenTheBox/Rendering/Panels/CraftingPanel.cs
Normal file
57
src/OpenTheBox/Rendering/Panels/CraftingPanel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
216
src/OpenTheBox/Simulation/CraftingEngine.cs
Normal file
216
src/OpenTheBox/Simulation/CraftingEngine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
711
tests/OpenTheBox.Tests/CraftingTests.cs
Normal file
711
tests/OpenTheBox.Tests/CraftingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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("╔════════════════════════════════════════════════════════════╗");
|
||||
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("╟────────┼────────────────────────────────────────────╢");
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue