Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

The Alchemy Engine

Story

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.


Part 1 — Basic Reactions (15 points)

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 end

When two potions are poured together, the Guild's scrolls say what comes out. Implement:

mix(a::Potion, b::Potion) -> Potion

mix is commutative — mixing A into B gives the same result as mixing B into A — so the table below lists each pair only once.

Elemental Reaction Table

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 end

Example

mix(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)

Part 2 — Brewing (15 points)

A cauldron doesn't just take two potions — apprentices pour in a whole shelf of ingredients, one after another. Implement:

brew(potions::Vector{<:Potion}) -> Potion

The brewing algorithm is a left-to-right reduction:

  1. The cauldron starts holding potions[1].
  2. For each remaining potion in order, pour it in: the cauldron's contents become mix(cauldron, potions[i]).
  3. 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).

Example

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 produces

Same three ingredients, different pour order, potentially a different potion. Keep this in mind once more reactions exist.


Part 3 — Potion Properties (10 points)

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)

Property Table (Parts 1–3 potions)

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

Example

power(Lava())      # => 9
rarity(Steam())     # => :uncommon
stability(NothingPotion())  # => 0.0

Part 4 — The Reaction Ledger (35 points)

Word 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

What to build

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}) -> Nothing

Calling 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):

New Reaction Table

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.

New Property Table

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.

The contract

Two things the judge holds you to here, beyond the table above:

  1. 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 calls register_reaction!(SomePotion, SomeOtherPotion, AThirdPotion) and then mix(SomePotion(), SomeOtherPotion()), your engine needs to answer AThirdPotion() 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.
  2. 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 does brew, 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.

Example

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)

Scoring

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.


Part 5 — Catalysts (Bonus, 15 points)

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 end

Implement the three-argument form:

mix(a::Potion, b::Potion, c::Catalyst) -> Potion

Catalyst Rules

  1. VoidCatalyst suppresses every reaction, no matter the ingredients: mix(a, b, VoidCatalyst()) is always NothingPotion().

  2. GoldCatalyst unlocks two specific transmutations that don't happen otherwise:

    Combine Result
    Fire + Water + GoldCatalyst Elixir
    Fire + Earth + GoldCatalyst Gold
  3. For every other combination, GoldCatalyst changes nothing — mix(a, b, GoldCatalyst()) gives the same result as the plain two-argument mix(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

Example

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages