For three hundred years, the Royal Alchemy Guild of Aurelia has trained apprentices the old way: memorize the scrolls, mix the ingredients, hope the tower doesn't catch fire.
It hasn't scaled. Last winter a journeyman mixed Fire and Earth expecting Clay and got Lava on his boots. The Guildmaster has had enough of scrolls that contradict each other and apprentices who guess. She wants a reaction engine — a program that knows, precisely and consistently, what happens when any two potions meet in a cauldron.
You've been hired as the Guild's first Apprentice Reaction-Engineer. Your job, across the rest of this problem, is to build that engine: first the basic reactions, then a way to brew a whole shelf of potions down to one, then a way to judge how good the result is, and finally to keep the whole thing working as the Guild's potion catalogue keeps growing. It always keeps growing.
The scrolls are precise about what reacts with what. Nothing here is left for you to guess — your job is the engineering, not the guesswork.
Every potion in the Guild's catalogue is a value of a subtype of the abstract type Potion. Four elemental potions come straight from nature and need no recipe:
abstract type Potion end
struct Fire <: Potion end
struct Water <: Potion end
struct Earth <: Potion end
struct Air <: Potion endWhen two potions are poured together, the Guild's scrolls say what comes out. Implement:
mix(a::Potion, b::Potion) -> Potionmix is commutative — mixing A into B gives the same result as mixing B into A — so the table below lists each pair only once.
| Combine | Result |
|---|---|
| Fire + Water | Steam |
| Fire + Earth | Lava |
| Fire + Air | Smoke |
| Water + Earth | Mud |
| Water + Air | Mist |
| Earth + Air | Dust |
Steam, Lava, Smoke, Mud, Mist, and Dust are new potion types you need to define yourself (each one is just an empty struct ... <: Potion end, same as Fire).
Every combination not listed above — including a potion mixed with itself, e.g. Fire + Fire — produces NothingPotion, a potion representing "nothing happened":
struct NothingPotion <: Potion endmix(Fire(), Water()) # => Steam()
mix(Water(), Fire()) # => Steam() (order doesn't matter)
mix(Fire(), Fire()) # => NothingPotion()
mix(Steam(), Earth()) # => NothingPotion() (no rule for this pair — yet)A cauldron doesn't just take two potions — apprentices pour in a whole shelf of ingredients, one after another. Implement:
brew(potions::Vector{<:Potion}) -> PotionThe brewing algorithm is a left-to-right reduction:
- The cauldron starts holding
potions[1]. - For each remaining potion in order, pour it in: the cauldron's contents become
mix(cauldron, potions[i]). - After the last potion is poured, whatever the cauldron holds is the result.
You're guaranteed potions has at least one element. With exactly one element, brew just returns that potion untouched (no mixing happens).
brew([Fire(), Water(), Earth()])
# cauldron = Fire()
# cauldron = mix(Fire(), Water()) = Steam()
# cauldron = mix(Steam(), Earth()) = NothingPotion() (no rule for Steam + Earth)
# => NothingPotion()Pouring order matters, even though each individual mix is commutative — because the intermediate potion changes what reacts next:
brew([Fire(), Earth(), Air()])
# Fire -> mix(Fire,Earth)=Lava -> mix(Lava,Air)=NothingPotion() (no rule for Lava + Air)
# => NothingPotion()
brew([Earth(), Air(), Fire()])
# Earth -> mix(Earth,Air)=Dust -> mix(Dust,Fire)=???
# => whatever your Part 1 table (plus, later, Part 4) says Dust + Fire producesSame three ingredients, different pour order, potentially a different potion. Keep this in mind once more reactions exist.
The Guild also grades every potion on three properties. Implement:
power(p::Potion) -> Int # combat/brewing strength
rarity(p::Potion) -> Symbol # :common, :uncommon, :rare, :legendary, or :none
stability(p::Potion) -> Float64 # 0.0 (falls apart instantly) to 1.0 (inert forever)| Potion | power | rarity | stability |
|---|---|---|---|
| Fire | 5 | :common |
0.90 |
| Water | 4 | :common |
0.95 |
| Earth | 3 | :common |
1.00 |
| Air | 2 | :common |
0.85 |
| Steam | 6 | :uncommon |
0.60 |
| Lava | 9 | :uncommon |
0.70 |
| Smoke | 5 | :uncommon |
0.50 |
| Mud | 3 | :uncommon |
0.80 |
| Mist | 2 | :uncommon |
0.75 |
| Dust | 2 | :uncommon |
0.65 |
| NothingPotion | 0 | :none |
0.00 |
power(Lava()) # => 9
rarity(Steam()) # => :uncommon
stability(NothingPotion()) # => 0.0Word of the engine has spread past the Guild tower. Field alchemists across the kingdom keep writing back with new recipes — Metal dissolving in Water into something the scribes are calling Rust, Shadow curdling Crystal into Obsidian — faster than anyone wants to keep sending you back into the source to add one more rule for each. The Guildmaster's instruction is blunt: she doesn't want to reopen your code every time a courier arrives with a new scroll. She wants a ledger — something her own scribes can add a line to, that the engine consults on its own, without the reaction logic itself ever needing to change again.
Four new raw reagents arrived with this batch of scrolls (again, plain struct ... <: Potion end):
struct Metal <: Potion end
struct Crystal <: Potion end
struct Shadow <: Potion end
struct Light <: Potion end...along with twelve more potions their reactions produce (also plain structs):
Slag, Rust, Chime, Geode, Prism, Obsidian, Ash, Wraith, Spirit, Mirror, Poison, Glass
Instead of adding one rule at a time, build the ledger the Guildmaster asked for. Implement:
register_reaction!(a::Type{<:Potion}, b::Type{<:Potion}, result::Type{<:Potion}) -> NothingCalling register_reaction!(Fire, Metal, Slag) teaches the engine that mixing a Fire with a Metal produces a Slag. From that point on — without touching anything else — mix(Fire(), Metal()) (and, since mix stays commutative, mix(Metal(), Fire())) should return Slag(). Registering the same pair again overwrites whatever was there before.
This changes what "implement mix" means for the rest of the problem. mix(a::Potion, b::Potion) now needs to consult whatever has been taught to the ledger so far and answer accordingly, rather than having every reaction spelled out ahead of time as its own rule. A pair that's never been registered, in either order, still produces NothingPotion() — exactly as before. That default belongs to mix itself; it isn't something anyone registers.
Teach the engine the table below the same way you'd teach it anything else — one register_reaction! call per row (the order of the first two arguments doesn't matter):
| Combine | Result |
|---|---|
| Fire + Metal | Slag |
| Water + Metal | Rust |
| Steam + Metal | Rust |
| Air + Crystal | Chime |
| Earth + Crystal | Geode |
| Light + Crystal | Prism |
| Shadow + Crystal | Obsidian |
| Lava + Water | Obsidian |
| Fire + Shadow | Ash |
| Smoke + Water | Ash |
| Air + Shadow | Wraith |
| Light + Shadow | Spirit |
| Light + Metal | Mirror |
| Mud + Air | Poison |
| Mud + Shadow | Poison |
| Dust + Fire | Glass |
Everything else — every pair not in this table or the Part 1 table, across all now-16 potion types — still produces NothingPotion, exactly as before.
Note two pairs above converge on the same result from different starting points (Shadow + Crystal and Lava + Water both give Obsidian; Fire + Shadow and Smoke + Water both give Ash). That's intentional — real alchemy (and real chemistry) has more than one path to the same substance. Register both lines; they're two separate entries that happen to name the same result.
| Potion | power | rarity | stability |
|---|---|---|---|
| Metal | 4 | :rare |
0.95 |
| Crystal | 3 | :rare |
1.00 |
| Shadow | 6 | :rare |
0.55 |
| Light | 6 | :rare |
0.90 |
| Slag | 7 | :rare |
0.55 |
| Rust | 3 | :rare |
0.40 |
| Chime | 4 | :rare |
0.80 |
| Geode | 8 | :rare |
0.90 |
| Prism | 9 | :legendary |
0.85 |
| Obsidian | 10 | :legendary |
0.75 |
| Ash | 1 | :uncommon |
0.60 |
| Wraith | 8 | :legendary |
0.35 |
| Spirit | 10 | :legendary |
0.50 |
| Mirror | 5 | :rare |
0.85 |
| Poison | 7 | :rare |
0.45 |
| Glass | 4 | :uncommon |
0.90 |
mix, brew, power, rarity, and stability all need to keep working — for every old potion, every new one, and every combination of the two — after this part.
Two things the judge holds you to here, beyond the table above:
- It has to actually be a ledger, not a table you memorized.
register_reaction!must work for potion types your file has never seen, including ones that only exist inside the judge's own tests, registered after your file has already loaded. If something callsregister_reaction!(SomePotion, SomeOtherPotion, AThirdPotion)and thenmix(SomePotion(), SomeOtherPotion()), your engine needs to answerAThirdPotion()on the strength of that one call — nothing about those three types can be special-cased anywhere in your file, because your file was written before they existed. - Nothing from before breaks. Every Part 1 example —
mix(Fire(), Water()) == Steam(),mix(Fire(), Fire()) == NothingPotion(), and so on — still needs to hold after this rebuild, and so doesbrew, and so do the Part 3 properties. How you get there is your call, but two reaction mechanisms quietly disagreeing with each other is exactly the kind of bug this part exists to catch.
register_reaction!(Water, Metal, Rust)
mix(Water(), Metal()) # => Rust()
mix(Metal(), Water()) # => Rust() (still commutative)
mix(Metal(), Metal()) # => NothingPotion() (nobody ever registered this)
register_reaction!(Water, Metal, Poison) # re-registering overwrites
mix(Water(), Metal()) # => Poison()
mix(Shadow(), Crystal()) # => Obsidian(), once you've registered that row
mix(Lava(), Water()) # => Obsidian(), same result, different recipe
brew([Fire(), Shadow(), Crystal()])
# Fire -> mix(Fire,Shadow)=Ash -> mix(Ash,Crystal)=NothingPotion() (no rule for Ash + Crystal)| Category | Points |
|---|---|
| Part 1 – Basic Reactions | 20 |
| Part 2 – Brewing | 15 |
| Part 3 – Potion Properties | 15 |
| Part 4 – Reaction Ledger | 30 |
| First/Second/Third Submission | 15/10/5 |
| Code quality: clean, readable, sensibly organized | 5 |
| Total | 100 |
Hidden Tests
Your submission will be evaluated against additional hidden tests beyond the provided samples. These may include:
- Undefined reaction combinations
- Commutativity of reactions
- Brewing with longer potion sequences
- Dynamic registration of previously unseen potion types
- Overwriting registered reactions
- Property correctness
- Backwards compatibility after implementing the Reaction Ledger
Passing the sample tests does not guarantee full marks.
A catalyst changes what a reaction produces without being consumed itself. The Guild has isolated two:
abstract type Catalyst end
struct GoldCatalyst <: Catalyst end
struct VoidCatalyst <: Catalyst endImplement the three-argument form:
mix(a::Potion, b::Potion, c::Catalyst) -> Potion-
VoidCatalystsuppresses every reaction, no matter the ingredients:mix(a, b, VoidCatalyst())is alwaysNothingPotion(). -
GoldCatalystunlocks two specific transmutations that don't happen otherwise:Combine Result Fire + Water + GoldCatalyst Elixir Fire + Earth + GoldCatalyst Gold -
For every other combination,
GoldCatalystchanges nothing —mix(a, b, GoldCatalyst())gives the same result as the plain two-argumentmix(a, b).
Elixir and Gold are new potion types you'll need to add, with properties:
| Potion | power | rarity | stability |
|---|---|---|---|
| Elixir | 20 | :legendary |
1.00 |
| Gold | 15 | :legendary |
1.00 |
mix(Fire(), Water(), GoldCatalyst()) # => Elixir()
mix(Fire(), Earth(), GoldCatalyst()) # => Gold()
mix(Water(), Air(), GoldCatalyst()) # => Mist() (no special rule — falls back to normal)
mix(Fire(), Water(), VoidCatalyst()) # => NothingPotion() (Void always wins)Good luck, Apprentice. Try not to lose any eyebrows.