Happy Feast of Winter Veil, Champion!

It has been two months since the last Fireplace dev update. Since then, we have reached over 2000 commits!

The past two months, we have implemented 178 new cards and added 77 new tests. Not even accounting for all the bugs fixed and code cleanups.

  • 178 new cards
  • 77 new tests
  • All cards from Classic, Naxxramas, GVG, Blackrock and TGT implemented!

Features

The bootstrapping process has been greatly improved. The main bootstrap script is now a polyglot script that will run on Windows, Linux and OSX alike. It automatically runs scripts/bootstrap.sh or scripts/bootstrap.ps1 depending on the available shell.

The bootstrapped data is now pulled directly from the fireplace.cards module. This means it’s now much cleaner to implement buffs, Choose Ones and such as they are no longer in two different places. CardDB is now a class that implements the necessary merging functionality.

Custom cards are now implemented with the @custom_card decorator, registering the class as a new card to bootstrap. The tags attribute will be pulled in to the defaults.

This cleanup has allowed us to automatically gather all card scripts and set appropriate defaults for missing attributes, greatly improving runtime performance. The removal of the data module also let us enumerate Fireplace cardsets and run through all of them.

Tests have finally been split into multiple files. The decision was to create one file for each card set, one for secrets and some extra files for related tests. Alongside the custom_card decorator, this was used to re-implement the pre-nerf Warsong Commander, which is used for the most reliable test cases on summon event timing.

Engine changes

Damage handling has been fixed and is nearly final. Reorganizing the order of events, broadcasts and damage reduction allows damage events to trigger even through armor, such as for Floating Watcher. The addition of Predamage allowed for Bolf Ramshield and Ice Block to be implemented, the latter which also needed the new Lethal() evaluator.

Lord Jaraxxus is fully implemented at last, and faithful to secrets interactions in Hearthstone. Ben Brode previously hinted that Jaraxxus has special code for interacting with the on-minion-play secrets. I found this to be unnecessary as it works as expected when using Morph(). The only necessary change was to allow Repentance, Sacred Trial and Snipe to trigger when a Hero is played. The one exception is Mirror Entity, which needs to summon the original minion when the hero is “played”.

In this model, playing a card that morphs itself during its play action (Battlecry) will trigger events with the result of the morph. It’s consistent with the way Faceless Manipulator triggers the appropriate on-summon events.

So here is the new Jaraxxus:

# Lord Jaraxxus
class EX1_323:
	play = (
		Summon(CONTROLLER, "EX1_323h").then(Morph(SELF, Summon.CARDS)),
		Summon(CONTROLLER, "EX1_323w")
	)

And the new Mirror Entity:

# Mirror Entity
class EX1_294:
	secret = [
		Play(OPPONENT, MINION).after(
			Reveal(SELF), Summon(CONTROLLER, ExactCopy(Play.CARD))
		),
		Play(OPPONENT, ID("EX1_323h")).after(
			Reveal(SELF), Summon(CONTROLLER, "EX1_323")
		)
	]

Discover() has been implemented. It is based on GenericChoice(), taking a picker argument. The pickers are not yet powerful enough to do weights; this will have to be taken care of at some point.

Other minor changes:

  • Windfury is no longer a boolean value, it is treated as “additional attacks” instead, which is how Hearthstone treats it to implement Mega-Windfury.
  • Attacks now trigger Attack.after() and durability hits have been moved there for Gorehowl’s sake. This is used for Bear Trap.
  • Player.cant_overload is checked before overloading, to implement an old version of Lava Shock.
  • Hero Powers now track their activations per turn, no longer exhausting after one use.
  • Double Damage/Healing (Prophet Velen) should now work exactly as intended.

DSL Changes

Subactions and Jousts

The biggest change in the DSL is the implementation of Action.then(). This implements sub-actions without having to pass actions as arguments to other actions (which is now deprecated). Doing it this way means we get arbitrary access to the parent action’s arguments. Here it is with Far Sight

# Far Sight
class CS2_053:
	play = Draw(CONTROLLER).then(Buff(Draw.CARD, "CS2_053e"))

This is even used for the new Joust! Joust is no longer a mere evaluator, it is both an action and an evaluator. To be more specific, it is an action (the “reveal”), which has a sub-action with an evaluator on the action’s arguments (the jousters):

Joust(RANDOM(FRIENDLY_DECK + MINION), RANDOM(ENEMY_DECK + MINION)).then(
	JoustEvaluator(Joust.CHALLENGER, Joust.DEFENDER) & (...)
)

Doing it this way lets us cleanly implement King’s Elekk:

# King's Elekk
class AT_058:
	play = JOUST & Draw(CONTROLLER, Joust.CHALLENGER)

Lazy Values, Selectors

Action arguments are no longer enum-based. Instead, they are subclasses of ActionArg, which inherits from LazyValue. LazyNum is also a subclass, so wherever those were usable, Action arguments are as well. New Controller() and Opponent() methods are also based on LazyValue.

The new Lorewalker Cho, for example, uses Opponent() to be fully declarative:

events = Play(ALL_PLAYERS, SPELL).on(Give(Opponent(Play.PLAYER), Copy(Play.CARD)))

Selectors have been slightly cleaned up. FuncSelector is now a base to implement ID, TARGET and LazySelector which is used in Selector arithmetics with LazyValues. Selectors are also now indexable. To get the first three entities from a selector, you would do <selector>[:3]. This is used by both Poison Cloud and Tracking:

# Tracking
class DS1_184:
	play = GenericChoice(CONTROLLER, FRIENDLY_DECK[:3])

# Poison Cloud
class NAX11_02:
	activate = Hit(ALL_MINIONS, 1).then(
		Dead(Hit.TARGETS) & Summon(CONTROLLER, "NAX11_03")
	)

Calling an Attr() with a selector will return an AttrSelector(). With some helpers, this is how it can be used (Summoning Stone example):

events = Play(CONTROLLER, SPELL).on(Summon(CONTROLLER, RandomMinion(cost=COST(Play.CARD)))

Or Molten Giant:

# Molten Giant
class EX1_620:
	cost_mod = -DAMAGE(FRIENDLY_HERO)

Other DSL changes

Secrets now use the secret script. That script behaves like events, but will never actively listen during the controller’s turn. events is still available and is used for Competitive Spirit which does trigger during the Controller’s turn.

The new RandomID() picker allows picking from a list of IDs. Most cards use entourage instead but some, such as Tinkmaster Overspark, don’t.

The SetTag() syntax has changed to default values to 1 (or 0 for UnsetTag()) and helpers have been added for GiveCharge, GiveDivineShield and GiveWindfury.

Reno Jackson required the implementation of the FindDuplicates() evaluator. It could even be used to implement a powered_up script for it.

Steal() now optionally takes a “new controller” argument, for the sake of Shadow Madness. It might be made mandatory at some point, as the interaction with Djinni of Zephyrs is important.

Attacking() is a new evaluator which is used in Gorehowl and Massive Runeblade. This is Gorehowl:

# Gorehowl
class EX1_411:
	update = Attacking(FRIENDLY_HERO, MINION) & Refresh(SELF, buff="EX1_411e")
	events = Attack(FRIENDLY_HERO, MINION).after(Buff(SELF, "EX1_411e2"))

EX1_411e = buff(immune=True)
EX1_411e2 = buff(atk=-1)

Fatigue now happens in the Fatigue() action. This should not matter for simulation purposes, but it is needed as a game action for Stove.

New aliases: FULL_HAND, FULL_BOARD, EMPTY_HAND and EMPTY_BOARD . They are mostly used in secrets, most of which should now only trigger in the same situations as Hearthstone. Lots of other new aliases: REMOVED_IN_PLAY, ENEMY_CLASS, CLASS_CARD(), HIGHEST_ATK(), LOWEST_ATK(), CONTROLLED_BY(), RandomMech, CURRENT_PLAYER and CURRENT_HEALTH().


Well, I’m forgetting a lot. I’ll leave it here though, this is more than enough for now.

117 files changed, 10755 insertions(+), 8232 deletions(-)

Jerome