Created
November 8, 2022 13:15
-
-
Save CyberShadow/54ca7a0fa594c2272cabf8677ff6e42f to your computer and use it in GitHub Desktop.
Inscryption solver
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/problem | |
/solution.txt |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import core.bitop : popcnt; | |
import std.algorithm.comparison : min, among; | |
import std.algorithm.iteration; | |
import std.algorithm.mutation : swap; | |
import std.algorithm.searching; | |
import std.array; | |
import std.conv; | |
import std.math.algebraic : abs; | |
import std.random; | |
import std.range; | |
import std.string; | |
import std.traits : EnumMembers; | |
import ae.utils.meta; | |
import ae.utils.text : camelCaseJoin; | |
enum AbilityIndex | |
{ | |
rabbitHole, | |
beesWithin, | |
sprinter, | |
touchOfDeath, | |
fledgling, | |
damBuilder, | |
hoarder, | |
burrower, | |
fecundity, | |
looseTail, | |
corpseEater, | |
boneKing, | |
waterborne, // the one with the tail | |
unkillable, | |
sharpQuills, | |
hefty, | |
antSpawner, | |
guardian, | |
airborne, | |
manyLives, | |
repulsive, | |
worthySacrifice, | |
mightyLeap, | |
bifurcatedStrike, | |
trifurcatedStrike, | |
frozenAway, | |
trinketBearer, | |
steelTrap, | |
amorphous, | |
tidalLock, | |
omniStrike, | |
leader, | |
brittle, | |
bellist, | |
annoying, | |
stinky, | |
waterborne2, // the one with the tentacle | |
madeOfStone, | |
} | |
mixin({ | |
string s; | |
s ~= `enum Abilities : ulong {`; | |
s ~= `none = 0,`; | |
s ~= `unknown = cast(ulong)-1L,`; | |
foreach (index; EnumMembers!AbilityIndex) | |
{ | |
enum name = __traits(identifier, EnumMembers!AbilityIndex[index]); | |
s ~= name ~ " = 1UL << AbilityIndex." ~ name ~ ","; | |
} | |
s ~= "}"; | |
return s; | |
}()); | |
enum ClanIndex | |
{ | |
squirrel, | |
canine, | |
deer, | |
bug, | |
reptile, | |
bird, | |
} | |
mixin({ | |
string s; | |
s ~= `enum Clans : ubyte {`; | |
s ~= `none = 0,`; | |
s ~= `unknown = cast(ubyte)-1,`; | |
foreach (index; EnumMembers!ClanIndex) | |
{ | |
enum name = __traits(identifier, EnumMembers!ClanIndex[index]); | |
s ~= name ~ " = 1UL << ClanIndex." ~ name ~ ","; | |
} | |
s ~= "}"; | |
return s; | |
}()); | |
// Special values | |
enum : byte | |
{ | |
unknown = -100, | |
ants, | |
oneHalfBones, | |
mirrorSomething, // ? | |
bellRinger, // ? | |
cardCounter, // ? | |
spilledBlood, | |
} | |
private struct CostDef | |
{ | |
enum Type : ubyte | |
{ | |
blood, | |
bones, | |
} | |
Type type; | |
byte amount; | |
byte totalBlood() const { return type == Type.blood ? amount : 0; } | |
byte totalBones() const { return type == Type.bones ? amount : 0; } | |
} | |
enum CardFlags : ubyte | |
{ | |
none = 0, | |
// Definitions | |
noSacrifice = 1 << 0, // cannot attack; can't be sacrificed | |
rare = 1 << 1, | |
// Transient state | |
sprintLeft = 1 << 2, | |
} | |
CostDef blood(byte amount) { return CostDef(CostDef.Type.blood, amount); } | |
CostDef bones(byte amount) { return CostDef(CostDef.Type.bones, amount); } | |
enum CostDef noCost = 0.blood; | |
enum CostDef unknownCost = (-1).blood; | |
private struct CardDef | |
{ | |
string name; | |
CostDef cost; | |
byte attack = unknown; | |
byte health = unknown; | |
CardFlags flags = CardFlags.none; | |
Clans clans = Clans.unknown; | |
Abilities abilities = Abilities.unknown; | |
} | |
private immutable CardDef[] cardDefs = { | |
alias F = CardFlags; | |
alias C = Clans; | |
alias A = Abilities; | |
immutable CardDef[] result = [ | |
{"None"}, | |
// Cost Attack HP Flags Clan Ability | |
{"Squirrel" , noCost, 0, 1, F.none, C.squirrel, A.none}, // note: clan is implicit; totem head exists | |
{"Stoat" , 1.blood, 1, 3, F.none, C.none, A.none}, | |
{"Wolf" , 2.blood, 3, 2, F.none, C.canine, A.none}, | |
{"Wolf Cub" , 1.blood, 1, 1, F.none, C.canine, A.fledgling}, | |
{"River Snapper" , 2.blood, 1, 6, F.none, C.reptile, A.none}, | |
{"Grizzly" , 3.blood, 4, 6, F.none, C.none, A.none}, | |
{"Sparrow" , 1.blood, 1, 2, F.none, C.bird, A.airborne}, | |
{"Cat" , 1.blood, 0, 1, F.none, C.unknown, A.manyLives}, | |
{"Porcupine" , unknownCost, 1, 2, F.none, C.unknown, A.sharpQuills}, | |
{"Elk" , 2.blood, 2, 4, F.none, C.deer, A.sprinter}, | |
{"Elk Fawn" , 1.blood, 1, 1, F.none, C.deer, A.sprinter | A.fledgling}, | |
{"Stinkbug" , 2.bones, 1, 2, F.none, C.bug, A.stinky}, | |
{"Bullfrog" , 1.blood, 1, 4, F.none, C.reptile, A.mightyLeap}, | |
// {"Corpse Maggots" , 5.bones, 1, 2, F.none, C.unknown, A.corpseEater}, | |
// {"Strange Larva" , 1.blood, 0, 3, F.rare, C.unknown, A.fledgling}, // TODO evolution | |
{"Worker Ant" , 1.blood, ants, 2, F.none, C.bug, A.unknown}, | |
{"Ant Queen" , 2.blood, ants, 3, F.none, C.unknown, A.antSpawner}, | |
{"Caged Wolf" , 2.blood, 0, 6, F.noSacrifice, C.canine, A.none}, | |
{"Kingfisher" , 1.blood, 1, 1, F.none, C.bird, A.airborne | A.waterborne}, | |
{"Ring Worm" , 1.blood, 0, 1}, | |
{"Pack Rat" , 2.blood, 2, 2, F.none, C.none, A.trinketBearer}, | |
{"Child 13" , 1.blood, 0, 1, F.rare, C.deer, A.manyLives}, | |
{"Rattler" , 6.bones, 3, 1, F.none, C.reptile, A.none}, | |
{"Adder" , 2.blood, 1, 1, F.none, C.reptile, A.touchOfDeath}, | |
{"Magpie" , 2.blood, 1, 1, F.none, C.bird, A.airborne | A.hoarder}, | |
{"Coyote" , 4.bones, 2, 1, F.none, C.canine, A.none}, | |
{"Cockroach" , 4.bones, 1, 1, F.none, C.bug, A.unkillable}, | |
{"Bee" , /*?*/noCost, 1, 1, F.none, C.bug, A.airborne}, | |
{"Opossum" , 2.bones, 1, 1, F.none, C.none, A.none}, | |
{"Rat King" , 2.blood, 2, 1, F.none, C.none, A.boneKing}, | |
{"Urayuli" , 4.blood, 7, 7, F.rare, C.none, A.none}, | |
{"Turkey Vulture" , 8.bones, 3, 3, F.none, C.bird, A.airborne}, | |
{"Skunk" , 1.blood, 0, 3, F.none, C.none, A.stinky}, | |
{"Bat" , unknownCost, 2, 1, F.none, C.none, A.airborne}, | |
{"River Otter" , unknownCost, 1, 1, F.none, C.none, A.waterborne}, | |
{"Raven Egg" , 1.blood, 0, 2, F.none, C.bird, A.fledgling}, | |
{"Raven" , 2.blood, 2, 3, F.none, C.bird, A.airborne}, | |
{"Great White" , 3.blood, 4, 2, F.none, C.none, A.waterborne}, | |
{"Ouroboros" , 2.blood, 1, 1, F.rare, C.reptile, A.unkillable}, | |
{"Moose Buck" , 3.blood, 3, 7, F.none, C.deer, A.hefty}, | |
{"Mole" , 1.blood, 0, 4, F.none, C.none, A.burrower}, | |
{"Mole Man" , unknownCost, 0, 6, F.none, C.none, A.burrower | A.mightyLeap}, | |
{"Black Goat" , 1.blood, 0, 1, F.none, C.deer, A.worthySacrifice}, | |
{"Frozen Opossum" , noCost, 0, 5, F.noSacrifice, C.none, A.frozenAway}, | |
{"Pronghorn" , 2.blood, 1, 3, F.none, C.deer, A.sprinter | A.bifurcatedStrike}, | |
{"Skink" , 1.blood, 1, 2, F.none, C.reptile, A.looseTail}, | |
{"Wriggling Tail" , noCost, 0, 2, F.none, C.reptile, A.none}, | |
{"Strange Frog" , unknownCost, 1, 2, F.noSacrifice, C.reptile, A.mightyLeap}, | |
{"Leaping Trap" , unknownCost, 0, 1, F.noSacrifice, C.none, A.steelTrap | A.mightyLeap}, | |
{"Mantis God" , 1.blood, 1, 1, F.rare, C.bug, A.trifurcatedStrike}, | |
{"The Daus" , 2.blood, 2, 2, F.rare, C.deer, A.bellist}, | |
{"Chime" /*?*/ , noCost, 0, 1, F.noSacrifice, C.none, A.none}, // ? | |
{"Bloodhound" , 2.blood, 2, 3, F.none, C.canine, A.guardian}, | |
{"Amalgam" , 2.blood, 3, 3, F.rare, cast(C)(C.bird | C.canine | C.deer | C.bug | C.reptile), A.none}, | |
{"Geck" , noCost, 1, 1, F.rare, C.reptile, A.none}, | |
{"Hand Tentacle" , 1.blood, cardCounter, 1, F.none, C.none, A.none}, | |
{"Greater Smoke" , noCost, 1, 3, F.none, C.none, A.boneKing}, | |
{"Moon" , unknownCost, 1,40, F.none, C.none, A.mightyLeap | A.omniStrike | A.tidalLock}, | |
{"Moon proxy" , unknownCost, 0, 0, F.none, C.none, A.none}, | |
{"Rabbit Pelt" , noCost, 0, 1, F.noSacrifice, C.none, A.none}, | |
{"Wolf Pelt" , noCost, 0, 2, F.noSacrifice, C.none, A.none}, | |
{"Golden Pelt" , noCost, 0, 3, F.noSacrifice, C.none, A.none}, | |
{"Stump" , noCost, 0, 3, F.noSacrifice, C.none, A.none}, | |
{"Boulder" , noCost, 0, 5, F.noSacrifice, C.none, A.none}, | |
{"Grand Fir" , unknownCost, 0, 3, F.noSacrifice, C.unknown, A.mightyLeap}, | |
{"Snowy Fir" , unknownCost, 0, 4, F.noSacrifice, C.none, A.mightyLeap}, | |
{"Gold Nugget" , unknownCost, 0, 2, F.noSacrifice, C.none, A.none}, | |
{"Bait Bucket" , unknownCost, 0, 1, F.noSacrifice, C.none, A.none}, | |
{"Reginald" , unknownCost, 1, 3, F.none, C.none, A.touchOfDeath}, | |
{"Kaminski" , unknownCost, 0, 1, F.none, C.none, A.guardian | A.sharpQuills}, | |
{"Second" , 2.bones, 4, 2, F.none, C.none, A.mightyLeap}, | |
]; | |
return result; | |
}(); | |
mixin({ | |
string s; | |
s ~= `enum CardIndex : ubyte {`; | |
foreach (cardDef; cardDefs) | |
s ~= cardDef.name.toLower.split.camelCaseJoin ~ ","; | |
s ~= "}"; | |
return s; | |
}()); | |
struct Card | |
{ | |
CardIndex index = CardIndex.none; | |
byte bloodCost = unknown; | |
byte bonesCost = unknown; | |
byte attack = unknown; | |
byte health = unknown; | |
byte damage = 0; // damage sustained | |
CardFlags flags; | |
Clans clans; | |
Abilities abilities; | |
T opCast(T)() const if (is(T == bool)) { return index != CardIndex.none; } | |
byte sprintDir() const @nogc nothrow { return (flags & CardFlags.sprintLeft) ? -1 : +1; } | |
} | |
enum Card noCard = Card.init; | |
immutable Card[enumLength!CardIndex] cards = { | |
Card[enumLength!CardIndex] cards; | |
foreach (index, cardDef; cardDefs) | |
{ | |
Card card = { | |
index: cast(CardIndex)index, | |
bloodCost: cardDef.cost.totalBlood, | |
bonesCost: cardDef.cost.totalBones, | |
attack: cardDef.attack, | |
health: cardDef.health, | |
flags: cardDef.flags, | |
abilities: cardDef.abilities, | |
clans: cardDef.clans, | |
}; | |
cards[index] = card; | |
} | |
return cards; | |
}(); | |
immutable string[enumLength!CardIndex] cardNames = cardDefs.map!(def => def.name).array; | |
enum maxHandSize = 16; | |
enum maxDeckSize = 32; | |
enum boardWidth = 4; | |
enum Player { me, ai } | |
static immutable string[enumLength!E] enumNames(E) = { | |
string[enumLength!E] names; | |
static foreach (i, index; EnumMembers!E) | |
names[i] = __traits(identifier, EnumMembers!E[i]); | |
return names; | |
}(); | |
struct Totem | |
{ | |
Clans clan = Clans.none; | |
Abilities ability = Abilities.none; | |
} | |
enum maxItems = 3; | |
enum Item : ubyte | |
{ | |
none, | |
placeholder, // Random item granted by trinketBearer | |
harpiesBirdlegFan, | |
magickalBleach, | |
fishHook, | |
frozenOpossumBottle, | |
blackGoatBottle, | |
// failure, | |
hourglass, | |
magpiesGlass, | |
hoggyBank, | |
pliers, | |
wiseclock, | |
scissors, | |
specialDagger, | |
squirrelInABottle, | |
boulderInABottle, | |
skinningKnife, | |
} | |
enum SpecialState : ubyte | |
{ | |
none, | |
anglerPassive, | |
anglerActive, | |
} | |
enum Row : ubyte | |
{ | |
my, | |
ai, | |
aiNext, | |
} | |
enum Boons : ubyte | |
{ | |
none = 0, | |
ambidextrous = 1 << 0, // "You may draw twice at the beginning of your turn." | |
magpiesEye = 1 << 1, // "When you draw from your deck, you may choose any card in your deck to draw." | |
} | |
struct Game | |
{ | |
Card[maxHandSize] hand = noCard; | |
Card[maxDeckSize] deck = noCard; | |
union | |
{ | |
Card[boardWidth][enumLength!Row] board; | |
Card[boardWidth * enumLength!Row] boardCards; | |
} | |
byte draws = 0; // If positive, the next move must be a draw | |
byte deckPicks = 0; // For the next draw, the card may be picked from the deck | |
int[enumLength!Player] playerDamage; | |
byte bones; | |
bool cagedWolfBonus; | |
ubyte ouroborosBonus; | |
Totem[enumLength!Player] totems; | |
Item[maxItems] items; | |
Boons boons; | |
SpecialState specialState; | |
ubyte lastPlacement; | |
bool harpiesBirdlegFanActive; | |
} | |
struct Action | |
{ | |
enum Type : ubyte | |
{ | |
none, | |
drawSquirrel, | |
drawFromDeck, | |
pickFromDeck, | |
playCard, | |
useItem, | |
endTurn, | |
} | |
Type type; | |
byte deckIndex; // for drawFromDeck, pickFromDeck | |
ubyte sacrificeOrder; // for playCard | |
byte handIndex; // for playCard | |
byte boardColumn; // for playCard | |
byte itemIndex; // for useItem | |
} | |
Player other(Player player) @nogc nothrow { return player == Player.me ? Player.ai : Player.me; } | |
bool isOver(ref const Game game) @nogc nothrow { return abs(game.playerDamage[Player.me] - game.playerDamage[Player.ai]) >= 5; } | |
int bonusScore(ref const Game game) @nogc nothrow | |
{ | |
int score = 0; | |
score += game.items[].count!(item => item != Item.none) * 500; | |
score += game.cagedWolfBonus * 5000; | |
score += game.ouroborosBonus * 1000; | |
return score; | |
} | |
byte resolveSigil(ref const Game game, byte value, Player player) @nogc nothrow | |
{ | |
if (value >= 0) | |
return value; | |
switch (value) | |
{ | |
case ants: | |
value = 0; | |
foreach (ref card; game.board[player]) | |
if (card.index.among( | |
CardIndex.workerAnt, | |
CardIndex.antQueen, | |
)) | |
value++; | |
return value; | |
case cardCounter: | |
if (player != Player.me) | |
return 0; | |
return cast(byte)game.hand[].count!(card => !!card); | |
default: | |
string error = "Unknown special value"; | |
debug error ~= ": " ~ text(value); | |
assert(false, error); | |
} | |
} | |
static immutable ubyte[][] sacrificeOrders = [ | |
[], | |
[0], | |
[1], | |
[2], | |
[3], | |
[0, 1], | |
[0, 2], | |
[0, 3], | |
[1, 0], | |
[1, 2], | |
[1, 3], | |
[2, 0], | |
[2, 1], | |
[2, 3], | |
[3, 0], | |
[3, 1], | |
[3, 2], | |
[0, 1, 2], | |
[0, 1, 3], | |
[0, 2, 1], | |
[0, 2, 3], | |
[0, 3, 1], | |
[0, 3, 2], | |
[1, 0, 2], | |
[1, 0, 3], | |
[1, 2, 0], | |
[1, 2, 3], | |
[1, 3, 0], | |
[1, 3, 2], | |
[2, 0, 1], | |
[2, 0, 3], | |
[2, 1, 0], | |
[2, 1, 3], | |
[2, 3, 0], | |
[2, 3, 1], | |
[3, 0, 1], | |
[3, 0, 2], | |
[3, 1, 0], | |
[3, 1, 2], | |
[3, 2, 0], | |
[3, 2, 1], | |
[0, 1, 2, 3], | |
[0, 1, 3, 2], | |
[0, 2, 1, 3], | |
[0, 2, 3, 1], | |
[0, 3, 1, 2], | |
[0, 3, 2, 1], | |
[1, 0, 2, 3], | |
[1, 0, 3, 2], | |
[1, 2, 0, 3], | |
[1, 2, 3, 0], | |
[1, 3, 0, 2], | |
[1, 3, 2, 0], | |
[2, 0, 1, 3], | |
[2, 0, 3, 1], | |
[2, 1, 0, 3], | |
[2, 1, 3, 0], | |
[2, 3, 0, 1], | |
[2, 3, 1, 0], | |
[3, 0, 1, 2], | |
[3, 0, 2, 1], | |
[3, 1, 0, 2], | |
[3, 1, 2, 0], | |
[3, 2, 0, 1], | |
[3, 2, 1, 0], | |
]; | |
private void checkCard(ref const Card card) @nogc nothrow | |
{ | |
if (!card) | |
return; | |
debug assert(card.abilities != Abilities.unknown, "Card abilities unknown: " ~ cardNames[card.index]); | |
debug assert(card.clans != Clans.unknown, "Card clans unknown: " ~ cardNames[card.index]); | |
} | |
private void checkCards(ref const Game game) @nogc nothrow | |
{ | |
foreach (ref card; game.hand) | |
card.checkCard(); | |
foreach (ref card; game.deck) | |
card.checkCard(); | |
foreach (row; Row.init .. enumLength!Row) | |
foreach (ref card; game.board[row]) | |
card.checkCard(); | |
} | |
private void applyTotem(ref Card card, ref const Totem totem) @nogc nothrow | |
{ | |
if (card && (card.clans & totem.clan)) | |
card.abilities |= totem.ability; | |
} | |
private void applyTotems(ref Game game) | |
{ | |
foreach (ref card; game.hand) | |
card.applyTotem(game.totems[Player.me]); | |
foreach (ref card; game.deck) | |
card.applyTotem(game.totems[Player.me]); | |
foreach (player; Player.init .. enumLength!Player) | |
foreach (ref card; game.board[player]) | |
card.applyTotem(game.totems[player]); | |
foreach (ref card; game.board[Row.aiNext]) | |
card.applyTotem(game.totems[Player.ai]); | |
} | |
void initialize(ref Game game) | |
{ | |
game.checkCards(); | |
game.applyTotems(); | |
} | |
private Card makeCard(ref const Game game, ref const Card sample, Player player) @nogc nothrow | |
{ | |
Card card = sample; | |
debug checkCard(card); | |
card.applyTotem(game.totems[player]); | |
return card; | |
} | |
struct BoardPosition | |
{ | |
@nogc nothrow: | |
ubyte index = ubyte.max; // Game.boardCards[boardIndex] | |
this(Row row, ubyte boardColumn) { index = cast(ubyte)(row * boardWidth + boardColumn); } | |
this(Player player, ubyte boardColumn) { this(cast(Row)player, boardColumn); } | |
@property Row row() const { return cast(Row)(index / boardWidth); } | |
@property ubyte column() const { return index % boardWidth; } | |
T opCast(T)() if (is(T == bool)) { return this !is typeof(this).init; } | |
} | |
void applyAction(ref Game game, Action action) @nogc nothrow | |
{ | |
void addToHand(Card card) | |
{ | |
auto handIndex = game.hand[].countUntil(noCard); | |
assert(handIndex >= 0, "No more space in hand"); | |
game.hand[handIndex] = card; | |
} | |
ref Card cardAt(BoardPosition cardPosition) | |
{ | |
auto card = &game.boardCards[cardPosition.index]; | |
if (card.index == CardIndex.moonProxy) | |
{ | |
card = &game.board[Row.ai][0]; | |
assert(card.index == CardIndex.moon); | |
} | |
return *card; | |
} | |
enum DamageSource : ubyte | |
{ | |
sacrifice, | |
attack, | |
overkill, | |
retribution, | |
} | |
bool isByCombat(DamageSource damageSource) { return !!damageSource.among(DamageSource.attack, DamageSource.overkill, DamageSource.retribution); } | |
void killCard(BoardPosition cardPosition, DamageSource damageSource) | |
{ | |
auto card = &cardAt(cardPosition); | |
auto owner = cardPosition.row == Row.my ? Player.me : Player.ai; | |
byte bones = (card.abilities & Abilities.boneKing) ? 4 : 1; | |
auto cardIndex = card.index; | |
if ((card.abilities & Abilities.unkillable) && owner == Player.me) | |
{ | |
auto card2 = *card; | |
card2.damage = 0; | |
if (cardIndex == CardIndex.ouroboros) | |
{ | |
card2.attack++; | |
card2.health++; | |
game.ouroborosBonus++; | |
} | |
addToHand(card2); | |
} | |
if (owner == Player.me && card.index == CardIndex.cagedWolf) | |
game.cagedWolfBonus = true; | |
bool frozenAway = !!(card.abilities & Abilities.frozenAway); | |
bool steelTrap = (card.abilities & Abilities.steelTrap) && cardPosition.row != Row.aiNext; | |
*card = noCard; | |
if (owner == Player.me) | |
game.bones += bones; | |
// "If a creature that you own perishes by combat, a card bearing this sigil in your hand is automatically played in its place." | |
if (owner == Player.me && isByCombat(damageSource)) | |
foreach (ref handCard; game.hand) | |
if (handCard.abilities & Abilities.corpseEater) | |
{ | |
swap(handCard, *card); | |
break; | |
} | |
if (cardIndex == CardIndex.baitBucket) | |
*card = game.makeCard(cards[CardIndex.greatWhite], owner); | |
if (cardIndex == CardIndex.strangeFrog) | |
*card = game.makeCard(cards[CardIndex.leapingTrap], owner); | |
if (cardIndex == CardIndex.moon) | |
foreach (ref boardCard; game.boardCards) | |
if (boardCard.index == CardIndex.moonProxy) | |
boardCard = noCard; | |
// "When a card bearing this sigil perishes, the creature inside is released in its place." | |
if (frozenAway) | |
{ | |
switch (cardIndex) | |
{ | |
case CardIndex.frozenOpossum: | |
*card = game.makeCard(cards[CardIndex.opossum], owner); | |
break; | |
default: | |
assert(false, "Don't know how to unfreeze"); | |
} | |
} | |
// "When a card bearing this sigil perishes, the creature opposing it perishes as well. A pelt is created in your hand." | |
if (steelTrap) | |
{ | |
auto oppositePosition = BoardPosition(owner.other, cardPosition.column); | |
auto oppositeCard = &cardAt(oppositePosition); | |
if (*oppositeCard) | |
{ | |
killCard(oppositePosition, DamageSource.retribution); | |
addToHand(game.makeCard(cards[CardIndex.wolfPelt], Player.me)); | |
} | |
} | |
} | |
void damageCard(BoardPosition cardPosition, ref byte attackPower, DamageSource damageSource, BoardPosition attackingCardPosition) | |
{ | |
auto card = &cardAt(cardPosition); | |
auto owner = cardPosition.row == Row.my ? Player.me : Player.ai; | |
auto attackingCard = attackingCardPosition ? &cardAt(attackingCardPosition) : null; | |
assert(*card); | |
assert(card.damage < card.health); | |
if (damageSource == DamageSource.attack) | |
assert(attackingCard && *attackingCard); | |
// "When a card bearing this sigil would be struck, a tail is created in its place and a card bearing this sigil moves to the right." | |
if ((card.abilities & Abilities.looseTail) && | |
damageSource == DamageSource.attack && | |
cardPosition.column + 1 < boardWidth && | |
!game.board[cardPosition.row][cardPosition.column + 1]) | |
{ | |
card.abilities &= ~Abilities.looseTail; | |
game.board[cardPosition.row][cardPosition.column + 1] = *card; | |
*card = game.makeCard(cards[CardIndex.wrigglingTail], owner); | |
} | |
// "Once a card bearing this sigil is struck, the striker is then dealt a single damage point." | |
bool sharpQuills = damageSource == DamageSource.attack && (card.abilities & Abilities.sharpQuills); | |
// "Once a card bearing this sigil is struck, a bee is created in your hand." | |
bool beesWithin = damageSource == DamageSource.attack && (card.abilities & Abilities.beesWithin) && owner == Player.me; | |
// "When a card bearing this sigil damages another creature, that creature perishes." | |
bool touchOfDeath = attackingCard && *attackingCard && (attackingCard.abilities & Abilities.touchOfDeath); | |
auto healthRemaining = card.health - card.damage; | |
auto usedAttackPower = min(attackPower, healthRemaining); | |
card.damage += usedAttackPower; | |
attackPower -= usedAttackPower; | |
if (card.damage >= card.health || touchOfDeath) | |
killCard(cardPosition, damageSource); | |
if (sharpQuills) | |
{ | |
byte sharpQuillsAttackPower = 1; | |
damageCard(attackingCardPosition, sharpQuillsAttackPower, DamageSource.retribution, cardPosition); | |
} | |
if (beesWithin) | |
addToHand(game.makeCard(cards[CardIndex.bee], Player.me)); | |
} | |
final switch (action.type) | |
{ | |
case Action.Type.none: | |
assert(false, "Null action"); | |
case Action.Type.drawSquirrel: | |
assert(game.draws, "Can't draw right now"); | |
game.draws--; | |
addToHand(game.makeCard(cards[CardIndex.squirrel], Player.me)); | |
break; | |
case Action.Type.drawFromDeck: | |
assert(game.draws, "Can't draw right now"); | |
game.draws--; | |
assert(game.deck[action.deckIndex], "No such card in deck"); | |
addToHand(game.deck[action.deckIndex]); | |
game.deck[action.deckIndex] = noCard; | |
break; | |
case Action.Type.pickFromDeck: | |
assert(game.draws, "Can't draw right now"); | |
game.draws--; | |
if (game.deckPicks) | |
game.deckPicks--; | |
else if (game.boons & Boons.magpiesEye) | |
{} | |
else | |
assert(false, "Can't pick right now"); | |
assert(game.deck[action.deckIndex], "No such card in deck"); | |
addToHand(game.deck[action.deckIndex]); | |
game.deck[action.deckIndex] = noCard; | |
break; | |
case Action.Type.playCard: | |
auto card = game.hand[action.handIndex]; | |
assert(card, "No such card in hand"); | |
assert(card.bloodCost >= 0, { | |
string s = "Unknown card cost"; | |
debug s ~= ": " ~ cardNames[card.index]; | |
return s; | |
}()); | |
byte blood = 0; | |
foreach (ubyte boardColumn; sacrificeOrders[action.sacrificeOrder]) | |
{ | |
assert(blood < card.bloodCost, "Too much sacrifice blood"); | |
auto sacrificeCardPosition = BoardPosition(Row.my, boardColumn); | |
auto sacrificedCard = &cardAt(sacrificeCardPosition); | |
assert(*sacrificedCard, "No card to sacrifice"); | |
assert(!(sacrificedCard.flags & CardFlags.noSacrifice), "Can't sacrifice this card"); | |
blood += (sacrificedCard.abilities & Abilities.worthySacrifice) ? 3 : 1; | |
if (sacrificedCard.abilities & Abilities.manyLives) | |
{ | |
if (sacrificedCard.index == CardIndex.child13) | |
{ | |
if (sacrificedCard.abilities & Abilities.airborne) | |
{ | |
// Awakened -> Passive | |
// TODO: don't delete patched ability - need to store it separately | |
sacrificedCard.abilities &= ~Abilities.airborne; | |
sacrificedCard.attack -= 2; | |
} | |
else | |
{ | |
// Passive -> Awakened | |
sacrificedCard.abilities |= Abilities.airborne; | |
sacrificedCard.attack += 2; | |
} | |
} | |
} | |
else | |
killCard(sacrificeCardPosition, DamageSource.sacrifice); | |
} | |
assert(blood >= card.bloodCost, "Not enough sacrifice blood"); | |
assert(card.bonesCost <= game.bones, "Not enough bones"); | |
game.bones -= card.bonesCost; | |
assert(!game.board[Row.my][action.boardColumn], "Space is occupied"); | |
game.board[Row.my][action.boardColumn] = card; | |
game.hand[action.handIndex] = noCard; | |
if (card.abilities & Abilities.hoarder) | |
if (game.deck[].canFind!((ref card) => card)) | |
{ | |
game.deckPicks++; | |
game.draws++; | |
} | |
// "When a card bearing this sigil is played, an ant is created in your hand." | |
if (card.abilities & Abilities.antSpawner) | |
addToHand(game.makeCard(cards[CardIndex.workerAnt], Player.me)); // TODO: accurate? What is an "ant"? | |
// "When a card bearing this sigil is played, you will receive a random item as long as your pack is not full." | |
if (card.abilities & Abilities.trinketBearer) | |
foreach (ref item; game.items) | |
if (!item) | |
{ | |
item = Item.placeholder; | |
break; | |
} | |
// "When a card bearing this sigil is played, a chime is created on each empty adjacent space. A chime is defined as: 0 power, 1 health." | |
if (card.abilities & Abilities.bellist) | |
foreach (chimeColumn; only(action.boardColumn - 1, action.boardColumn + 1)) | |
if (chimeColumn >= 0 && chimeColumn < boardWidth && !game.board[Row.my][chimeColumn]) | |
game.board[Row.my][chimeColumn] = game.makeCard(cards[CardIndex.chime], Player.me); | |
// Guardian: "When an opposing creature is placed opposite to an empty space, a card bearing this sigil will move to that empty space." | |
{ | |
auto opposingSpace = &game.board[Row.ai][action.boardColumn]; | |
if (!*opposingSpace) | |
foreach (ref guardianCard; game.board[Row.ai]) | |
if (guardianCard && (guardianCard.abilities & Abilities.guardian)) | |
{ | |
swap(*opposingSpace, guardianCard); | |
break; | |
} | |
} | |
game.lastPlacement = action.boardColumn; | |
break; | |
case Action.Type.useItem: | |
auto item = game.items[action.itemIndex]; | |
assert(item, "No such item"); | |
game.items[action.itemIndex] = Item.none; | |
switch (item) | |
{ | |
case Item.pliers: | |
assert(action.boardColumn == -1); | |
game.playerDamage[Player.ai]++; | |
break; | |
case Item.hoggyBank: | |
assert(action.boardColumn == -1); | |
game.bones += 4; | |
break; | |
case Item.harpiesBirdlegFan: | |
assert(action.boardColumn == -1); | |
game.harpiesBirdlegFanActive = true; | |
break; | |
case Item.squirrelInABottle: | |
assert(action.boardColumn == -1); | |
addToHand(game.makeCard(cards[CardIndex.squirrel], Player.me)); | |
break; | |
case Item.boulderInABottle: | |
assert(action.boardColumn == -1); | |
addToHand(game.makeCard(cards[CardIndex.boulder], Player.me)); | |
break; | |
case Item.frozenOpossumBottle: | |
assert(action.boardColumn == -1); | |
addToHand(game.makeCard(cards[CardIndex.frozenOpossum], Player.me)); | |
break; | |
case Item.blackGoatBottle: | |
assert(action.boardColumn == -1); | |
addToHand(game.makeCard(cards[CardIndex.blackGoat], Player.me)); | |
break; | |
case Item.fishHook: | |
assert(action.boardColumn >= 0); | |
assert(game.board[Row.ai][action.boardColumn]); | |
assert(!game.board[Row.my][action.boardColumn]); | |
swap(game.board[Row.ai][action.boardColumn], game.board[Row.my][action.boardColumn]); | |
break; | |
case Item.scissors: | |
assert(action.boardColumn >= 0); | |
assert(game.board[Row.ai][action.boardColumn]); | |
game.board[Row.ai][action.boardColumn] = noCard; | |
break; | |
default: | |
assert(false, { | |
string s = "Don't know how to use item"; | |
debug s ~= ": " ~ enumNames!Item[item]; | |
return s; | |
}()); | |
} | |
break; | |
case Action.Type.endTurn: | |
void runCardsTurnStart(Player player) @nogc nothrow | |
{ | |
foreach (boardColumn; 0 .. boardWidth) | |
{ | |
auto myCard = &game.board[player][boardColumn]; | |
if (myCard.abilities & Abilities.fledgling) | |
{ | |
void evolveTo(CardIndex targetCard) | |
{ | |
// TODO evolution logic? | |
// Does damage carry over? Do abilities carry over? | |
*myCard = game.makeCard(cards[targetCard], Player.me); | |
} | |
switch (myCard.index) | |
{ | |
case CardIndex.elkFawn: | |
evolveTo(CardIndex.elk); | |
break; | |
case CardIndex.wolfCub: | |
evolveTo(CardIndex.wolf); | |
break; | |
case CardIndex.ravenEgg: | |
evolveTo(CardIndex.raven); | |
break; | |
default: | |
// Elder | |
// TODO stats! | |
myCard.health++; | |
if (myCard.attack > 0) | |
myCard.attack++; | |
break; | |
} | |
} | |
if (myCard.abilities & Abilities.tidalLock) | |
{ | |
foreach (ref card; game.boardCards) | |
if (card && card.index.among( | |
CardIndex.squirrel, | |
// ??? | |
)) | |
card = noCard; | |
} | |
} | |
} | |
void runCardsTurnEnd(Player player) @nogc nothrow | |
{ | |
foreach (ubyte boardColumn; 0 .. boardWidth) | |
{ | |
auto myCardPosition = BoardPosition(player, boardColumn); | |
auto myCard = &cardAt(myCardPosition); | |
if (*myCard) | |
{ | |
if (myCard.index == CardIndex.moon && myCardPosition != BoardPosition(Row.ai, 0)) | |
continue; // proxies don't attack | |
auto oppositeCardPosition = BoardPosition(player.other, boardColumn); | |
auto oppositeCard = &cardAt(oppositeCardPosition); | |
debug checkCard(*myCard); | |
foreach (index; AbilityIndex.init .. enumLength!AbilityIndex) | |
{ | |
auto mask = 1UL << index; | |
if (myCard.abilities & mask) | |
{ | |
switch (index) | |
{ | |
case AbilityIndex.manyLives: | |
case AbilityIndex.airborne: | |
case AbilityIndex.waterborne: | |
case AbilityIndex.waterborne2: | |
case AbilityIndex.mightyLeap: | |
case AbilityIndex.stinky: | |
case AbilityIndex.sharpQuills: | |
case AbilityIndex.sprinter: | |
case AbilityIndex.beesWithin: | |
case AbilityIndex.unkillable: | |
case AbilityIndex.hoarder: | |
case AbilityIndex.boneKing: | |
case AbilityIndex.corpseEater: | |
case AbilityIndex.fledgling: | |
case AbilityIndex.worthySacrifice: | |
case AbilityIndex.touchOfDeath: | |
case AbilityIndex.burrower: | |
case AbilityIndex.antSpawner: | |
case AbilityIndex.trinketBearer: | |
case AbilityIndex.hefty: | |
case AbilityIndex.frozenAway: | |
case AbilityIndex.looseTail: | |
case AbilityIndex.bifurcatedStrike: | |
case AbilityIndex.trifurcatedStrike: | |
case AbilityIndex.omniStrike: | |
case AbilityIndex.steelTrap: | |
case AbilityIndex.bellist: | |
case AbilityIndex.guardian: | |
case AbilityIndex.tidalLock: | |
break; // implemented | |
default: | |
static immutable names = { | |
string[enumLength!AbilityIndex] names; | |
static foreach (i, index; EnumMembers!AbilityIndex) | |
names[i] = __traits(identifier, EnumMembers!AbilityIndex[i]); | |
return names; | |
}(); | |
string error = "Ability not implemented"; | |
debug error ~= ": " ~ enumNames!AbilityIndex[index]; | |
assert(false, error); | |
} | |
} | |
} | |
auto initialAttackPower = resolveSigil(game, myCard.attack, player); | |
// Stinky: "The creature opposing a card bearing this sigil loses 1 power." | |
if (initialAttackPower && *oppositeCard && (oppositeCard.abilities & Abilities.stinky)) | |
initialAttackPower--; | |
void attackTheColumn(ubyte attackColumn) | |
{ | |
auto attackPower = initialAttackPower; | |
if (attackPower <= 0) | |
return; | |
auto otherCardPosition = BoardPosition(player.other, attackColumn); | |
auto otherCard = &cardAt(otherCardPosition); | |
// Burrower: "When an empty space would be struck, a card bearing this sigil will move to that space to receive the strike instead." | |
if (!*otherCard) | |
{ | |
foreach (ref burrowCard; game.board[player.other]) | |
if (burrowCard && (burrowCard.abilities & Abilities.burrower)) | |
{ | |
swap(*otherCard, burrowCard); | |
break; | |
} | |
} | |
bool canAttackCard = { | |
if (!*otherCard) | |
return false; // No card to attack | |
bool airborne = (myCard.abilities & Abilities.airborne) || (player == Player.me && game.harpiesBirdlegFanActive); | |
if (airborne && !(otherCard.abilities & Abilities.mightyLeap)) | |
return false; // Airborne cards attack the user, unless countered by Mighty Leap | |
if ((otherCard.abilities & Abilities.waterborne) || (otherCard.abilities & Abilities.waterborne2)) | |
return false; // Cannot attack waterborne cards | |
return true; | |
}(); | |
if (canAttackCard) | |
{ | |
damageCard(otherCardPosition, attackPower, DamageSource.attack, myCardPosition); | |
// Overkill: attack next row | |
if (*myCard && attackPower > 0 && player == Player.me) | |
{ | |
auto nextCardPosition = BoardPosition(Row.aiNext, attackColumn); | |
auto nextCard = &cardAt(nextCardPosition); | |
if (*nextCard) | |
damageCard(nextCardPosition, attackPower, DamageSource.overkill, myCardPosition); // TODO: check if damageSource has same semantics as attack (sharpQuills?) | |
} | |
} | |
else | |
game.playerDamage[player.other] += attackPower; | |
} | |
foreach (byte attackColumn; 0 .. boardWidth) | |
{ | |
bool canAttackColumn = { | |
if (myCard.abilities & Abilities.trifurcatedStrike) | |
return abs(boardColumn - attackColumn) <= 1; | |
if (myCard.abilities & Abilities.bifurcatedStrike) | |
return abs(boardColumn - attackColumn) == 1; | |
// "A card bearing this sigil will strike each opposing space that is occupied by a creature. It will strike directly if no creatures oppose it." | |
if (myCard.abilities & Abilities.omniStrike) | |
return game.board[player.other][attackColumn] || ( | |
attackColumn == 0 && game.board[player.other][].all!((ref card) => !card) | |
); | |
return boardColumn == attackColumn; | |
}(); | |
if (canAttackColumn) | |
{ | |
attackTheColumn(attackColumn); | |
if (!*myCard) | |
break; // was killed in retribution | |
} | |
} | |
} | |
} | |
// Sprinter: "At the end of the owner's turn, a card bearing this sigil will move in the direction inscribed in the sigil." | |
// Hefty: "At the end of the owner's turn, a card bearing this sigil will move in the direction inscribed in this sigil. Creatures in the way will be pushed in the same direction." | |
for (byte boardColumn = 0; boardColumn < boardWidth; boardColumn++) | |
{ | |
auto myCard = &game.board[player][boardColumn]; | |
if (myCard.abilities & (Abilities.sprinter | Abilities.hefty)) | |
{ | |
bool vacate(byte targetIndex) | |
{ | |
if (targetIndex < 0 || targetIndex >= boardWidth) | |
return false; // out of bounds | |
if (!game.board[player][targetIndex]) | |
return true; // already free | |
if (!(myCard.abilities & Abilities.hefty)) | |
return false; // can't push | |
auto nextIndex = targetIndex; | |
nextIndex += myCard.sprintDir; | |
if (!vacate(nextIndex)) | |
return false; // couldn't push | |
assert(!game.board[player][nextIndex]); | |
swap(game.board[player][nextIndex], game.board[player][targetIndex]); | |
return true; // push successful | |
} | |
byte targetIndex = boardColumn; | |
targetIndex += myCard.sprintDir; | |
if (!vacate(targetIndex)) | |
{ | |
myCard.flags ^= CardFlags.sprintLeft; // Can't go, turn around | |
targetIndex = boardColumn; | |
targetIndex += myCard.sprintDir; | |
} | |
if (!vacate(targetIndex)) | |
{} // Can't move even after turning around | |
else | |
swap(*myCard, game.board[player][targetIndex]); | |
if (targetIndex > boardColumn) | |
boardColumn = targetIndex; // Don't process the same sprinter twice! | |
} | |
} | |
} | |
runCardsTurnEnd(Player.me); | |
game.harpiesBirdlegFanActive = false; | |
if (game.isOver) | |
return; | |
runCardsTurnStart(Player.ai); | |
// Angler boss | |
if (game.specialState == SpecialState.anglerPassive) | |
game.specialState = SpecialState.anglerActive; | |
else | |
if (game.specialState == SpecialState.anglerActive) | |
{ | |
if (game.board[Row.my][game.lastPlacement] | |
&& | |
( | |
! game.board[Row.ai ][game.lastPlacement] | |
|| | |
! game.board[Row.aiNext][game.lastPlacement] | |
)) | |
{ | |
if (game.board[Row.ai][game.lastPlacement]) | |
swap( | |
game.board[Row.ai ][game.lastPlacement], | |
game.board[Row.aiNext][game.lastPlacement], | |
); | |
swap( | |
game.board[Row.my][game.lastPlacement], | |
game.board[Row.ai][game.lastPlacement], | |
); | |
assert(!game.board[Row.my][game.lastPlacement]); | |
} | |
game.specialState = SpecialState.anglerPassive; | |
} | |
foreach (boardColumn; 0 .. boardWidth) | |
if (game.board[Row.aiNext][boardColumn] && !game.board[Row.ai][boardColumn]) | |
{ | |
game.board[Row.ai][boardColumn] = game.board[Row.aiNext][boardColumn]; | |
game.board[Row.aiNext][boardColumn] = noCard; | |
// Guardian: "When an opposing creature is placed opposite to an empty space, a card bearing this sigil will move to that empty space." | |
{ | |
auto opposingSpace = &game.board[Row.my][action.boardColumn]; | |
if (!*opposingSpace) | |
foreach (ref guardianCard; game.board[Row.my]) | |
if (guardianCard && (guardianCard.abilities & Abilities.guardian)) | |
{ | |
swap(*opposingSpace, guardianCard); | |
break; | |
} | |
} | |
} | |
runCardsTurnEnd(Player.ai); | |
runCardsTurnStart(Player.me); // TODO: is this right? | |
game.draws = 1; | |
if (game.boons & Boons.ambidextrous) | |
game.draws++; | |
break; | |
} | |
} | |
string describeAction(ref const Game game, ref const Action action) | |
{ | |
static immutable boardPositions = [ | |
"left-most", | |
"center-left", | |
"center-right", | |
"right-most", | |
]; | |
final switch (action.type) | |
{ | |
case Action.Type.none: | |
return "(no action)"; | |
case Action.Type.drawSquirrel: | |
return "Draw a squirrel"; | |
case Action.Type.drawFromDeck: | |
return "Draw a card from your deck"; | |
case Action.Type.pickFromDeck: | |
return "Pick the " ~ cardNames[game.deck[action.deckIndex].index] ~ " card from your deck"; | |
case Action.Type.playCard: | |
auto card = game.hand[action.handIndex]; | |
string s; | |
s ~= "Play the " ~ cardNames[card.index]; | |
s ~= " on the " ~ boardPositions[action.boardColumn] ~ " tile"; | |
if (action.sacrificeOrder) | |
s ~= ", sacrificing " ~ ( | |
sacrificeOrders[action.sacrificeOrder] | |
.map!(boardColumn => "the " ~ cardNames[game.board[Row.my][boardColumn].index] ~ " on the " ~ boardPositions[boardColumn] ~ " tile") | |
.join(" and ") | |
); | |
return s; | |
case Action.Type.useItem: | |
string s; | |
s ~= "Use the " ~ enumNames!Item[game.items[action.itemIndex]]; | |
if (action.boardColumn >= 0) | |
s ~= " on the " ~ boardPositions[action.boardColumn] ~ " tile"; | |
return s; | |
case Action.Type.endTurn: | |
return "End your turn"; | |
} | |
} | |
string bitsToString(Index, Bits, string name)(Bits cardBits, Bits referenceBits) | |
{ | |
string s; | |
if (referenceBits & ~cardBits) | |
{ | |
s ~= ".clear" ~ name ~ "()"; | |
referenceBits = Bits.none; | |
} | |
foreach (index; EnumMembers!Index) | |
if ((cardBits & (1UL << index)) && !(referenceBits & (1UL << index))) | |
s ~= format(".patch(%s.%s)", name, enumNames!Index[index]); | |
return s; | |
} | |
string toString(Card card) | |
{ | |
if (!card) | |
return "noCard"; | |
string s = "cards[CardIndex." ~ enumNames!CardIndex[card.index] ~ "]"; | |
assert(card.bloodCost == cards[card.index].bloodCost); | |
assert(card.bonesCost == cards[card.index].bonesCost); | |
if (card.attack != cards[card.index].attack) s ~= format(".addAttack(%d)", card.attack - cards[card.index].attack); | |
if (card.health != cards[card.index].health) s ~= format(".addHealth(%d)", card.health - cards[card.index].health); | |
if (card.damage != cards[card.index].damage) s ~= format(".addDamage(%d)", card.damage - cards[card.index].damage); | |
if (card.sprintDir != +1) s ~= ".sprintLeft()"; | |
s ~= bitsToString!(ClanIndex, Clans, "Clans")(card.clans, cards[card.index].clans); | |
s ~= bitsToString!(AbilityIndex, Abilities, "Abilities")(card.abilities, cards[card.index].abilities); | |
return s; | |
} | |
string toString(Totem totem) | |
{ | |
if (totem == Totem.init) | |
return "{}"; | |
return "{ " ~ | |
size_t(enumLength!ClanIndex).iota.filter!(index => (1UL << index) & totem.clan).map!(index => "Clans." ~ enumNames!ClanIndex[index]).join(" | ") ~ | |
", " ~ | |
size_t(enumLength!AbilityIndex).iota.filter!(index => (1UL << index) & totem.ability).map!(index => "Abilities." ~ enumNames!AbilityIndex[index]).join(" | ") ~ | |
" }"; | |
} | |
string toString(Game game) | |
{ | |
string[] lines; | |
lines ~= " Game game = {"; | |
lines ~= " hand: ["; | |
foreach (card; game.hand) | |
if (card) | |
lines ~= " " ~ card.toString() ~ ","; | |
lines ~= " ],"; | |
lines ~= " deck: ["; | |
foreach (card; game.deck) | |
if (card) | |
lines ~= " " ~ card.toString() ~ ","; | |
lines ~= " ],"; | |
lines ~= " board: [["; | |
foreach (card; game.board[Row.my]) | |
lines ~= " " ~ card.toString() ~ ","; | |
lines ~= " ], ["; | |
foreach (card; game.board[Row.ai]) | |
lines ~= " " ~ card.toString() ~ ","; | |
lines ~= " ], ["; | |
foreach (card; game.board[Row.aiNext]) | |
lines ~= " " ~ card.toString() ~ ","; | |
lines ~= " ]],"; | |
lines ~= " draws: " ~ game.draws.text ~ ","; | |
if (game.deckPicks) | |
lines ~= " deckPicks: " ~ game.deckPicks.text ~ ","; | |
lines ~= " playerDamage: " ~ game.playerDamage.text ~ ","; | |
lines ~= " bones: " ~ game.bones.text ~ ","; | |
if (game.cagedWolfBonus) | |
lines ~= " cagedWolfBonus: " ~ game.cagedWolfBonus.text ~ ","; | |
if (game.ouroborosBonus) | |
lines ~= " ouroborosBonus: " ~ game.ouroborosBonus.text ~ ","; | |
lines ~= " totems: ["; | |
lines ~= " " ~ game.totems[Player.me].toString() ~ ","; | |
lines ~= " " ~ game.totems[Player.ai].toString() ~ ","; | |
lines ~= " ],"; | |
lines ~= " items: ["; | |
foreach (item; game.items) | |
if (item) | |
lines ~= " Item." ~ item.text ~ ","; | |
lines ~= " ],"; | |
if (game.boons) | |
lines ~= " boons: cast(Boons)(" ~ [EnumMembers!Boons].filter!(boon => boon & game.boons).map!(boon => "Boons." ~ boon.text).join(" | ") ~ "),"; | |
if (game.specialState) | |
lines ~= " specialState: SpecialState." ~ game.specialState.text ~ ","; | |
if (game.specialState.among(SpecialState.anglerActive, SpecialState.anglerPassive)) | |
lines ~= " lastPlacement: " ~ game.lastPlacement.text ~ ","; | |
if (game.harpiesBirdlegFanActive) | |
lines ~= " harpiesBirdlegFanActive: " ~ game.harpiesBirdlegFanActive.text ~ ","; | |
lines ~= " };"; | |
return lines.join("\n") ~ "\n"; | |
} | |
alias RNG = Xorshift; | |
// Create a random situation with the given Game's persistent aspects (deck, totems...) | |
Game randomGame(ref const Game base, ref RNG rng) pure | |
{ | |
Game game = base; | |
void randomizeCard(ref Card card) | |
{ | |
card.damage = uniform(byte(0), card.health, rng); | |
} | |
void placeAICard(ref Card card) | |
{ | |
if (uniform(0, 3, rng) == 0) | |
{ | |
card = cards[uniform(1, $, rng)]; | |
if (card.clans == Clans.unknown) card.clans = Clans.none; | |
if (card.abilities == Abilities.unknown) card.abilities = Abilities.none; | |
randomizeCard(card); | |
} | |
} | |
game.deck[].randomShuffle(rng); | |
size_t deckIndex; | |
Card draw() | |
{ | |
while (deckIndex < maxDeckSize) | |
{ | |
auto card = &game.deck[deckIndex++]; | |
if (*card) | |
{ | |
auto result = *card; | |
*card = noCard; | |
return result; | |
} | |
} | |
return noCard; | |
} | |
foreach (ref card; game.hand[0 .. uniform(0, 5, rng)]) | |
card = draw(); | |
foreach (ref card; game.board[Row.my]) | |
if (uniform(0, 3, rng) == 0) | |
{ | |
card = draw(); | |
if (card) | |
randomizeCard(card); | |
} | |
foreach (ref card; game.board[Row.ai]) | |
placeAICard(card); | |
foreach (ref card; game.board[Row.aiNext]) | |
placeAICard(card); | |
game.draws = uniform(ubyte(0), ubyte(2), rng); | |
game.bones = uniform(byte(0), byte(3), rng); | |
return game; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import core.stdc.stdlib; | |
enum arenaSize = 48UL * 1024 * 1024 * 1024; | |
// TODO MT | |
struct Arena | |
{ | |
this(string name, size_t size) | |
{ | |
this.name = name; | |
start = malloc(size); | |
if (!start) assert(false, "malloc failed"); | |
end = start + size; | |
next = start; | |
} | |
~this() | |
{ | |
if (start) | |
{ | |
free(start); | |
start = end = next = null; | |
} | |
} | |
@disable this(this); | |
string name; | |
void* start, end; | |
void* next; | |
void[] allocBytes(size_t n) | |
{ | |
auto p = next; | |
next += n; | |
if (next > end) | |
{ | |
import core.stdc.stdio : fprintf, stderr; | |
fprintf(stderr, "Arena %.*s out of memory\n", | |
cast(int)name.length, name.ptr, | |
); | |
throw new Exception("Out of arena memory"); | |
} | |
return p[0 .. n]; | |
} | |
T[] alloc(T, bool initialize=true)(size_t n) | |
{ | |
auto bytes = allocBytes(T.sizeof * n); | |
auto arr = cast(T[])bytes; | |
if (initialize) | |
arr[] = T.init; | |
else | |
debug { (cast(ubyte[])bytes)[] = 0xfc; } | |
return arr; | |
} | |
T* alloc(T)() | |
{ | |
auto bytes = allocBytes(T.sizeof); | |
auto p = cast(T*)bytes.ptr; | |
*p = T.init; | |
return p; | |
} | |
void[] data() { return start[0 .. next - start]; } | |
size_t capacity() { return end - start; } | |
size_t usedSize() { return next - start; } | |
size_t freeSize() { return end - next; } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import core.bitop : popcnt; | |
import std.algorithm.comparison; | |
import std.algorithm.iteration; | |
import std.algorithm.searching; | |
import std.algorithm.setops : cartesianProduct; | |
import std.array; | |
import std.format : format; | |
import std.parallelism; | |
import std.range : iota; | |
import std.stdio; | |
import game; | |
import search; | |
Card patch(Card card, Abilities ability) { card.abilities |= ability; return card; } | |
Card addAttack(Card card, byte amount) { card.attack += amount; return card; } | |
Card addHealth(Card card, byte amount) { card.health += amount; return card; } | |
Card addDamage(Card card, byte amount) { card.damage += amount; return card; } | |
Card sprintLeft(Card card) { card.flags |= CardFlags.sprintLeft; return card; } | |
Card clearClans(Card card) { card.clans = Clans.none; return card; } | |
Card clearAbilities(Card card) { card.abilities = Abilities.none; return card; } | |
void main() | |
{ | |
version(none) | |
Game game = { | |
hand: [ | |
], | |
deck: [ | |
cards[CardIndex.cagedWolf], | |
cards[CardIndex.stinkbug], | |
cards[CardIndex.stoat], | |
cards[CardIndex.bullfrog], | |
cards[CardIndex.wolf], | |
cards[CardIndex.second].addAttack(1).patch(Abilities.trinketBearer), | |
cards[CardIndex.cockroach].patch(Abilities.beesWithin), | |
cards[CardIndex.packRat], | |
cards[CardIndex.opossum].addHealth(2), | |
cards[CardIndex.urayuli], | |
cards[CardIndex.cardTentacle].patch(Abilities.trinketBearer), | |
cards[CardIndex.workerAnt].clearAbilities().patch(Abilities.worthySacrifice), | |
cards[CardIndex.wolfCub], | |
cards[CardIndex.ouroboros], | |
cards[CardIndex.cardTentacle], | |
cards[CardIndex.packRat], | |
], | |
board: [[ | |
noCard, | |
noCard, | |
noCard, | |
noCard, | |
], [ | |
noCard, | |
noCard, | |
noCard, | |
noCard, | |
], [ | |
noCard, | |
noCard, | |
noCard, | |
noCard, | |
]], | |
draws: 0, | |
playerDamage: [0, 0], | |
bones: 2, | |
totems: [ | |
{ Clans.squirrel, Abilities.sharpQuills }, | |
{}, | |
], | |
items: [ | |
Item.scissors, | |
Item.pliers, | |
Item.boulderInABottle, | |
], | |
}; | |
Game game = { | |
hand: [ | |
cards[CardIndex.cockroach].patch(Abilities.beesWithin), | |
cards[CardIndex.packRat], | |
], | |
deck: [ | |
cards[CardIndex.cagedWolf], | |
cards[CardIndex.stinkbug], | |
cards[CardIndex.stoat], | |
cards[CardIndex.bullfrog], | |
cards[CardIndex.wolf], | |
cards[CardIndex.second].addAttack(1).patch(Abilities.trinketBearer), | |
cards[CardIndex.packRat], | |
cards[CardIndex.opossum].addHealth(2), | |
cards[CardIndex.urayuli], | |
cards[CardIndex.cardTentacle].patch(Abilities.trinketBearer), | |
cards[CardIndex.workerAnt].clearAbilities().patch(Abilities.worthySacrifice), | |
cards[CardIndex.wolfCub], | |
cards[CardIndex.ouroboros], | |
], | |
board: [[ | |
noCard, | |
cards[CardIndex.cardTentacle], | |
noCard, | |
noCard, | |
], [ | |
noCard, | |
cards[CardIndex.elkFawn].patch(Abilities.sharpQuills), | |
cards[CardIndex.elkFawn].sprintLeft().patch(Abilities.sharpQuills), | |
cards[CardIndex.boulder], | |
]], | |
aiMove: [ | |
noCard, | |
cards[CardIndex.elk], | |
noCard, | |
noCard, | |
], | |
draws: 1, | |
playerDamage: [2, 2], | |
bones: 2, | |
totems: [ | |
{ Clans.squirrel, Abilities.sharpQuills }, | |
{ Clans.deer, Abilities.sharpQuills }, | |
], | |
items: [ | |
Item.scissors, | |
Item.pliers, | |
Item.boulderInABottle, | |
], | |
}; | |
findSolution(game); | |
// findBestDeckAddition(game, [ | |
// cards[CardIndex.cardTentacle], | |
// cards[CardIndex.turkeyVulture], | |
// cards[CardIndex.elk], | |
// ]); | |
// findBestStatMod(game, (ref card) { card.health += 2; }); | |
// findBestStatMod(game, (ref card) { if (card.attack > 0) card.attack++; }); | |
// findBestSacrifice(game); | |
// findBestTransfusion(game); | |
// findBestTrial(game, [ | |
// trialOfWisdom, | |
// trialOfPower, | |
// trialOfBones, | |
// ]); | |
} | |
void findSolution(Game game) | |
{ | |
game.initialize(); | |
size_t numIters; | |
search.search(game, (Node* root) { | |
auto game = root.getGame(); | |
writefln("%2d: %5d - %s", ++numIters, root.score, describeAction(game, root.choices[root.bestChoice][0].action)); | |
save(root); | |
return true; | |
}); | |
} | |
void save(Node* node) | |
{ | |
auto f = File("solution.txt.tmp", "wb"); | |
f.writefln("\t// score: %d", node.score); | |
f.writeln(node.getGame().toString()); | |
while (node.expanded) | |
{ | |
f.writeln("======================================="); | |
if (!node.choices.length) | |
break; | |
auto bestChoice = node.choices[node.bestChoice]; | |
auto game = node.getGame(); | |
f.writefln(describeAction(game, bestChoice[0].action)); | |
f.writeln("======================================="); | |
node = bestChoice[0].node; | |
f.writefln("\t// score: %d", node.score); | |
f.writeln(node.getGame().toString()); | |
} | |
f.writeln("======================================="); | |
auto game = node.getGame(); | |
if (game.isOver) | |
f.writeln("Game over"); | |
else | |
if (!node.choices.length) | |
f.writeln("Reached dead end"); | |
else | |
f.writeln("Reached unexpanded node"); | |
f.writeln("Final score: ", node.score); | |
f.close(); | |
rename("solution.txt.tmp", "solution.txt"); | |
} | |
Game addToDeck(Game game, Card card) | |
{ | |
foreach (ref deckCard; game.deck) | |
if (!deckCard) | |
{ | |
deckCard = card; | |
return game; | |
} | |
assert(false, "Deck is full"); | |
} | |
void findBestDeckAddition(Game game, scope Card[] cards) | |
{ | |
auto variants = cards | |
.map!(card => Variant(game.addToDeck(card), card.toString())) | |
.array; | |
return findBestVariant(variants); | |
} | |
void findBestStatMod(Game game, scope void delegate(ref Card) mod) | |
{ | |
auto variants = maxDeckSize.iota | |
.filter!(deckIndex => game.deck[deckIndex]) | |
.map!((deckIndex) { | |
auto game2 = game; | |
mod(game2.deck[deckIndex]); | |
return Variant(game2, game2.deck[deckIndex].toString()); | |
}) | |
.array; | |
return findBestVariant(variants); | |
} | |
void findBestSacrifice(Game game) | |
{ | |
auto variants = maxDeckSize.iota | |
.filter!(deckIndex => game.deck[deckIndex]) | |
.filter!(deckIndex => !(game.deck[deckIndex].flags & CardFlags.noSacrifice)) // ? no pelts at least... | |
.map!((deckIndex) { | |
auto game2 = game; | |
game2.deck[deckIndex] = noCard; | |
return Variant(game2, game.deck[deckIndex].toString()); | |
}) | |
.array; | |
return findBestVariant(variants); | |
} | |
void findBestTransfusion(Game game) | |
{ | |
auto donors = maxDeckSize.iota | |
.filter!(deckIndex => | |
game.deck[deckIndex] && | |
game.deck[deckIndex].abilities && | |
game.deck[deckIndex].abilities == cards[game.deck[deckIndex].index].abilities | |
) | |
.array; | |
auto receivers = maxDeckSize.iota | |
.filter!(deckIndex => | |
game.deck[deckIndex] && | |
game.deck[deckIndex].abilities == cards[game.deck[deckIndex].index].abilities | |
) | |
.array; | |
auto variants = cartesianProduct(donors, receivers) | |
.filter!(pair => pair[0] != pair[1]) | |
.map!((pair) { | |
auto donor = pair[0]; | |
auto receiver = pair[1]; | |
auto game2 = game; | |
game2.deck[receiver].abilities |= game2.deck[donor].abilities; | |
game2.deck[donor] = noCard; | |
return Variant(game2, | |
format("donor: %s, receiver: %s", | |
game.deck[donor].toString(), | |
game.deck[receiver].toString(), | |
) | |
); | |
}) | |
.array; | |
return findBestVariant(variants); | |
} | |
enum searchDepth = 7; | |
struct Variant { Game game; string name; } | |
void findBestVariant(Variant[] variants) | |
{ | |
auto wins = new uint[variants.length]; | |
foreach (seed; size_t.max.iota.parallel(1)) | |
{ | |
scope(failure) writefln("%7d: Error!", seed); | |
auto scores = new Score[variants.length]; | |
Score bestScore = Score.min; | |
foreach (i, ref variant; variants) | |
{ | |
RNG rng; | |
rng.seed(cast(uint)seed); | |
auto seedGame = variant.game.randomGame(rng); | |
seedGame.initialize(); | |
int iterations; | |
search.search(seedGame, (Node* root) { | |
if (++iterations < searchDepth) | |
return true; | |
scores[i] = root.score; | |
return false; | |
}, true); | |
if (scores[i] > bestScore) | |
bestScore = scores[i]; | |
} | |
synchronized | |
{ | |
foreach (i, score; scores) | |
if (score == bestScore) | |
wins[i]++; | |
auto bestWins = wins[].reduce!max; | |
auto bestIndex = wins.countUntil(bestWins); | |
writefln("%7d: %(%d\t%) | Best: #%d (%s) with %d wins", | |
seed, wins, | |
bestIndex, | |
variants[bestIndex].name, | |
bestWins, | |
); | |
} | |
} | |
} | |
struct Trial { int function(Card card) getter; int threshold; } | |
immutable trialOfPower = Trial((Card card) => card.attack , 4); | |
immutable trialOfHealth = Trial((Card card) => card.health , 6); | |
immutable trialOfWisdom = Trial((Card card) => card.abilities == Abilities.unknown ? 0 : popcnt(card.abilities), 3); | |
immutable trialOfBlood = Trial((Card card) => card.bloodCost, 4); | |
immutable trialOfBones = Trial((Card card) => card.bonesCost, 5); | |
immutable trialOfTheFinned = Trial((Card card) => !!(card.abilities & Abilities.waterborne), 1); | |
immutable trialOfRarity = Trial((Card card) => !!(card.flags & CardFlags.rare), 1); | |
immutable trialOfSkins = Trial((Card card) => !!card.index.among(CardIndex.rabbitPelt, CardIndex.wolfPelt, CardIndex.goldenPelt), 1); | |
void findBestTrial(Game game, Trial[] trials) | |
{ | |
size_t numCombinations = 0; | |
auto wins = new int[trials.length]; | |
foreach (card1Index; 0 .. maxDeckSize) if (game.deck[card1Index]) | |
foreach (card2Index; card1Index + 1 .. maxDeckSize) if (game.deck[card2Index]) | |
foreach (card3Index; card2Index + 1 .. maxDeckSize) if (game.deck[card3Index]) | |
{ | |
numCombinations++; | |
size_t[3] indices = [card1Index, card2Index, card3Index]; | |
foreach (trialIndex; 0 .. trials.length) | |
{ | |
int sum; | |
foreach (deckIndex; indices) | |
sum += max(0, trials[trialIndex].getter(game.deck[deckIndex])); | |
if (sum >= trials[trialIndex].threshold) | |
wins[trialIndex]++; | |
} | |
} | |
foreach (trialIndex; 0 .. trials.length) | |
writefln("%d: %6.2f%% (%d/%d)", | |
trialIndex, 100 * double(wins[trialIndex]) / numCombinations, | |
wins[trialIndex], numCombinations, | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import core.bitop : popcnt; | |
import std.algorithm.iteration; | |
import std.algorithm.mutation : swap; | |
import std.algorithm.searching; | |
import std.algorithm.setops; | |
import std.algorithm.sorting; | |
import std.experimental.allocator.mallocator; | |
import std.parallelism; | |
import std.range; | |
import std.stdio : stderr; | |
import containers.hashmap; | |
import ae.utils.array; | |
import ae.utils.parallelism; | |
import game; | |
import memory; | |
alias Score = int; | |
struct Node | |
{ | |
hash_t gameHash; | |
// How did we get here? | |
union | |
{ | |
Node* parent; | |
const(Game)* rootGame; // when parentAction.type == none | |
} | |
Action parentAction; | |
bool scoreIsFinal; | |
Score score; | |
size_t bestChoice; // only when expanded | |
struct Outcome | |
{ | |
Action action; // includes choice and outcome | |
Node* node; | |
} | |
alias Choice = Outcome[]; | |
Choice[] choices; // if non-null, then this node is expanded (might still be empty) | |
bool expanded() const @nogc { return choices.ptr !is null; } | |
Game getGame() const nothrow @trusted | |
{ | |
if (parentAction.type == Action.Type.none) | |
return *rootGame; | |
assert(parent); | |
auto game = parent.getGame(); | |
game.applyAction(parentAction); | |
game.normalize(); | |
return game; | |
} | |
} | |
Score getFinalScore(ref const Game game) @nogc | |
{ | |
assert(game.isOver); | |
Score score; | |
bool weWon = game.playerDamage[Player.me] < game.playerDamage[Player.ai]; | |
if (!weWon) | |
score -= 10_000; | |
else | |
{ | |
score += 10_000; | |
score += (game.playerDamage[Player.ai] - game.playerDamage[Player.me]) * 200; | |
} | |
score += game.bonusScore(); | |
return score; | |
} | |
// This value is used only for freshly expanded nodes, | |
// and will be overwritten on the next iteration. | |
Score getHeuristicScore(ref const Game game) | |
{ | |
if (game.isOver) | |
return getFinalScore(game); | |
Score score; | |
score += (game.playerDamage[Player.ai] - game.playerDamage[Player.me]) * 100; | |
score += game.bonusScore() / 10; | |
foreach (ref card; game.hand) | |
if (card) | |
score += 25; | |
foreach (ref card; game.board[Row.my]) | |
if (card) | |
score += 50; | |
foreach (ref card; game.board[Row.ai]) | |
if (card) | |
score -= 50; | |
foreach (ref card; game.board[Row.aiNext]) | |
if (card) | |
score -= 25; | |
return score; | |
} | |
struct GameRef | |
{ | |
const(Node)* node; | |
hash_t toHash() const nothrow @safe { return node.gameHash; } | |
bool opEquals(ref const GameRef b) const nothrow @safe { return node.getGame() == b.node.getGame(); } | |
} | |
// Simple stable sort with O(1) space complexity, optimized for | |
// sorted or almost-sorted (one unsorted item) arrays. | |
// Similar to Gnome Sort. | |
void mySort(alias pred, R)(R r) | |
{ | |
for (size_t i = 1; i < r.length; i++) | |
if (pred(r[i], r[i - 1])) | |
{ | |
auto j = i; | |
do | |
{ | |
swap(r[j - 1], r[j]); | |
j--; | |
} | |
while (j > 0 && pred(r[j], r[j - 1])); | |
} | |
debug assert(r.isSorted!pred()); | |
} | |
alias wordCompare = (ref a, ref b) => cast(size_t[])(a.bytes) < cast(size_t[])(b.bytes); | |
void normalize(ref Game game) nothrow @nogc | |
{ | |
game.hand[].mySort!wordCompare; | |
game.deck[].mySort!wordCompare; | |
} | |
bool isNormalized(ref const Game game) | |
{ | |
Game normalizedGame = game; | |
normalizedGame.normalize(); | |
return game == normalizedGame; | |
} | |
void expandGame(ref const Game game, scope void delegate(scope const Action[] outcomes) handleChoice) | |
{ | |
if (game.isOver()) | |
return; // Terminal state | |
if (game.draws) | |
{ | |
if (game.hand[].canFind(noCard)) | |
{ | |
bool mustDrawFromDeck = game.deckPicks == 0; // Magpie's abilities force drawing from deck | |
if (!mustDrawFromDeck) | |
{ | |
Action action = { | |
type: Action.Type.drawSquirrel, | |
}; | |
handleChoice(action.toArray); | |
} | |
if (game.deckPicks || (game.boons & Boons.magpiesEye)) | |
{ | |
foreach (ubyte deckIndex; 0 .. maxDeckSize) | |
if (game.deck[deckIndex]) | |
{ | |
Action action = { | |
type: Action.Type.pickFromDeck, | |
deckIndex: deckIndex, | |
}; | |
handleChoice(action.toArray); | |
} | |
} | |
else | |
{ | |
Action[maxDeckSize] actions; | |
size_t numActions; | |
foreach (ubyte deckIndex; 0 .. maxDeckSize) | |
if (game.deck[deckIndex]) | |
{ | |
Action action = { | |
type: Action.Type.drawFromDeck, | |
deckIndex: deckIndex, | |
}; | |
actions[numActions++] = action; | |
} | |
if (numActions > 0) | |
handleChoice(actions[0 .. numActions]); | |
} | |
} | |
} | |
if (!game.draws) | |
{ | |
foreach (ubyte handIndex; 0 .. maxHandSize) | |
if (game.hand[handIndex]) | |
{ | |
Action action = { | |
type: Action.Type.playCard, | |
handIndex: handIndex, | |
}; | |
assert(game.hand[handIndex].bloodCost >= 0, { | |
string s = "Unknown card cost"; | |
debug s ~= ": " ~ cardNames[game.hand[handIndex].index]; | |
return s; | |
}()); | |
if (game.hand[handIndex].bonesCost > game.bones) | |
continue; | |
foreach (ubyte sacrificeOrder; 0 .. sacrificeOrders.length) | |
{ | |
bool ok = { | |
byte blood; | |
foreach (boardColumn; sacrificeOrders[sacrificeOrder]) | |
{ | |
if (blood >= game.hand[handIndex].bloodCost) | |
return false; // too many sacrifices | |
auto card = &game.board[Row.my][boardColumn]; | |
if (!*card) | |
return false; | |
if (card.flags & CardFlags.noSacrifice) | |
return false; | |
if (card.abilities & Abilities.worthySacrifice) | |
blood += 3; | |
else | |
blood++; | |
} | |
return blood >= game.hand[handIndex].bloodCost; | |
}(); | |
if (!ok) | |
continue; | |
action.sacrificeOrder = sacrificeOrder; | |
foreach (ubyte boardColumn; 0 .. boardWidth) | |
{ | |
bool canMoveHere = { | |
if (!game.board[Row.my][boardColumn]) | |
return true; // empty slot, can move here | |
if (sacrificeOrders[sacrificeOrder].canFind(boardColumn)) | |
{ | |
if (game.board[Row.my][boardColumn].abilities & Abilities.manyLives) | |
return false; // card will remain | |
return true; // card will be sacrificed, can move here | |
} | |
return false; // occupied slot, can't move here | |
}(); | |
if (canMoveHere) | |
{ | |
action.boardColumn = boardColumn; | |
handleChoice(action.toArray); | |
} | |
} | |
} | |
} | |
foreach (ubyte itemIndex; 0 .. maxItems) | |
if (game.items[itemIndex]) | |
{ | |
foreach (byte boardColumn; -1 .. boardWidth) | |
{ | |
bool canMoveHere = { | |
switch (game.items[itemIndex]) | |
{ | |
case Item.placeholder: | |
return false; | |
case Item.fishHook: | |
return boardColumn >= 0 && game.board[Row.ai][boardColumn] && !game.board[Row.my][boardColumn]; | |
case Item.scissors: | |
return boardColumn >= 0 && game.board[Row.ai][boardColumn]; | |
default: | |
return boardColumn == -1; // Does not target a board cell | |
} | |
}(); | |
if (!canMoveHere) | |
continue; | |
Action action = { | |
type: Action.Type.useItem, | |
itemIndex: itemIndex, | |
boardColumn: boardColumn, | |
}; | |
handleChoice(action.toArray); | |
} | |
} | |
{ | |
Action action = { | |
type: Action.Type.endTurn, | |
}; | |
handleChoice(action.toArray); | |
} | |
} | |
} | |
void search(bool verbose = false)(ref const Game initGame, scope bool delegate(Node* root) iterCallback, bool singleThreaded = false) | |
{ | |
auto numThreads = singleThreaded ? 1 : totalCPUs; | |
// Dedicated arena for Nodes, to allow iterating over all nodes | |
auto nodeArena = Arena("nodes", 24UL * 1024 * 1024 * 1024 / totalCPUs * numThreads); | |
scope(exit) static if (verbose) stderr.writefln("nodeArena: %d / %d", nodeArena.usedSize, nodeArena.capacity); | |
struct ThreadData | |
{ | |
Arena arena; | |
size_t numExpansions; | |
size_t expandExploreHead, expandCommitHead; | |
} | |
auto threadData = new ThreadData[numThreads]; | |
scope (exit) | |
foreach (threadIndex; 0 .. numThreads) | |
threadData[threadIndex].arena = Arena.init; | |
foreach (threadIndex; 0 .. numThreads) | |
threadData[threadIndex].arena = Arena("thread", 32UL * 1024 * 1024 * 1024 / totalCPUs); | |
scope(exit) static if (verbose) stderr.writefln("thread arenas: %d / %d", | |
numThreads.iota.map!(threadIndex => threadData[threadIndex].arena.usedSize).sum, | |
numThreads.iota.map!(threadIndex => threadData[threadIndex].arena.capacity).sum, | |
); | |
HashMap!(GameRef, Node*, Mallocator, hashOf, /*supportGC:*/false, /*storeHash:*/false) hashTable; | |
Node* getNodeForGame(ref const Game game, hash_t gameHash) | |
{ | |
assert(game.isNormalized()); | |
Node dummyNode; | |
dummyNode.rootGame = &game; | |
dummyNode.gameHash = gameHash; | |
if (auto p = GameRef(&dummyNode) in hashTable) | |
return *p; | |
return null; | |
} | |
auto root = nodeArena.alloc!Node; | |
Game rootGame = initGame; | |
rootGame.normalize(); | |
root.rootGame = &rootGame; | |
root.gameHash = hashOf(rootGame); | |
root.score = getHeuristicScore(rootGame); | |
void updateScore(ref Node node) @nogc | |
{ | |
assert(node.expanded); | |
if (node.scoreIsFinal) | |
return; | |
if (!node.choices.length) | |
{ | |
// if (node.game.isOver()) | |
// // reuse heuristic score, which is final in a game-over state | |
// assert(node.score == getFinalScore(node.game)); | |
// else | |
// node.score = -10_000; // dead end? | |
node.scoreIsFinal = true; | |
return; | |
} | |
size_t bestChoice = 0; | |
Score bestScore = Score.min; | |
bool scoreIsFinal = true; | |
foreach (i, choice; node.choices) | |
{ | |
Score avgScore; | |
auto outcomes = choice; | |
{ | |
Score sum; | |
foreach (outcome; outcomes) | |
{ | |
sum += outcome.node.score; | |
scoreIsFinal = scoreIsFinal && outcome.node.scoreIsFinal; | |
} | |
if (outcomes.length == 1) // single outcome - most common case | |
avgScore = sum; | |
else | |
avgScore = sum / cast(Score)outcomes.length; | |
assert(avgScore < 50_000); | |
} | |
if (avgScore > bestScore) | |
{ | |
bestScore = avgScore; | |
bestChoice = i; | |
} | |
} | |
bestScore -= 1; // distance malus | |
node.score = bestScore; | |
node.bestChoice = bestChoice; | |
node.scoreIsFinal = scoreIsFinal; | |
} | |
// --- Expansion storage | |
struct Expansion | |
{ | |
Node* parentNode; | |
Action parentAction; | |
hash_t gameHash; | |
// TODO: maybe keep a Game copy here (check what's faster) | |
Game game; | |
Score heuristicScore; | |
// After sorting, is this the first distinct item | |
// (i.e. it is different from the previous item)? | |
bool unique; | |
} | |
alias expansionCompare = (ref a, ref b) | |
{ | |
if (a.gameHash != b.gameHash) | |
return a.gameHash < b.gameHash; | |
return cast(size_t[])(a.game.bytes) < cast(size_t[])(b.game.bytes); | |
}; | |
auto expansionsArena = Arena("expansions", 2UL * 1024 * 1024 * 1024 / totalCPUs * numThreads); | |
auto maxExpansionsPerThread = (expansionsArena.end - expansionsArena.start) / 2 / Expansion.sizeof / numThreads; | |
auto maxExpansions = maxExpansionsPerThread * numThreads; | |
auto expansionsBuffer = expansionsArena.alloc!Expansion(maxExpansions); | |
auto uniqueExpansionsBuffer = expansionsArena.alloc!(Expansion, false)(maxExpansions); | |
auto threadExpansionsBuffers = numThreads | |
.iota | |
.map!(threadIndex => expansionsBuffer[threadIndex * maxExpansionsPerThread .. (threadIndex + 1) * maxExpansionsPerThread]) | |
.array; | |
// --- Main loop | |
do | |
{ | |
auto allNodes = cast(Node[])nodeArena.data; | |
// Expansion loop | |
bool keepExpanding = true; | |
auto thread0Start = threadData[0].expandCommitHead; | |
while (keepExpanding) | |
{ | |
static if (verbose) stderr.writefln("- Expanding"); | |
// Exploratory step - expand unexpanded states and put all new games in a buffer | |
static if (verbose) stderr.writefln(" - Exploring"); | |
foreach (threadIndex; numThreads.iota.parallel(1)) | |
{ | |
auto threadExpansionsBuffer = threadExpansionsBuffers[threadIndex]; | |
size_t numThreadExpansions; | |
ref size_t nodeIndex() { return threadData[threadIndex].expandExploreHead; } | |
for (; nodeIndex < allNodes.length; nodeIndex++) | |
{ | |
auto node = &allNodes[nodeIndex]; | |
if (nodeIndex % numThreads == threadIndex && !node.expanded) | |
{ | |
const Game game = node.getGame(); | |
void choiceHandler(scope const Action[] actions) | |
{ | |
assert(actions.length, "Zero outcomes"); | |
foreach (ref action; actions) | |
{ | |
if (numThreadExpansions == maxExpansionsPerThread) | |
return; // Out of space | |
Game nextGame = game; | |
nextGame.applyAction(action); | |
nextGame.normalize(); | |
auto gameHash = hashOf(nextGame); | |
if (getNodeForGame(nextGame, gameHash)) | |
continue; // already visited | |
Expansion expansion; | |
expansion.parentNode = node; | |
expansion.parentAction = action; | |
expansion.game = nextGame; | |
expansion.gameHash = gameHash; | |
threadExpansionsBuffer[numThreadExpansions++] = expansion; | |
} | |
} | |
expandGame(game, &choiceHandler); | |
if (numThreadExpansions == maxExpansionsPerThread) | |
break; // Out of space | |
} | |
} | |
threadData[threadIndex].numExpansions = numThreadExpansions; | |
// Sort it (in preparation of full sort/deduplicate). | |
threadExpansionsBuffer[0 .. numThreadExpansions].sort!expansionCompare(); | |
} | |
static if (verbose) stderr.writefln(" - %d/%d expansions", | |
numThreads.iota.map!(threadIndex => threadData[threadIndex].numExpansions).sum, | |
maxExpansions, | |
); | |
// We will need to retry the loop if any of the thread buffers got full. | |
keepExpanding = numThreads.iota.any!( | |
threadIndex => threadData[threadIndex].numExpansions == maxExpansionsPerThread | |
); | |
// Deduplicate and count | |
static if (verbose) stderr.writefln(" - Deduplicating"); | |
size_t numUniqueExpansions = 0; | |
auto threadExpansions = numThreads.iota.map!(threadIndex => | |
threadExpansionsBuffers[threadIndex][0 .. threadData[threadIndex].numExpansions] | |
).array; | |
foreach (ref expansion; threadExpansions.multiwayUnion!expansionCompare) | |
uniqueExpansionsBuffer[numUniqueExpansions++] = expansion; | |
static if (verbose) stderr.writefln(" - %d/%d unique expansions", numUniqueExpansions, maxExpansions); | |
// Allocate nodes | |
auto numNewNodes = numUniqueExpansions; | |
auto newNodes = nodeArena.alloc!Node(numNewNodes); | |
// Populate nodes | |
static if (verbose) stderr.writefln(" - Populating"); | |
foreach (threadIndex; numThreads.iota.parallel(1)) | |
{ | |
foreach (expansionIndex, ref expansion; uniqueExpansionsBuffer[0 .. numUniqueExpansions]) | |
if (expansionIndex % numThreads == threadIndex) | |
{ | |
Node node; | |
node.parent = expansion.parentNode; | |
node.parentAction = expansion.parentAction; | |
node.gameHash = expansion.gameHash; | |
node.score = getHeuristicScore(expansion.game); | |
newNodes[expansionIndex] = node; | |
} | |
} | |
// Register in hash table | |
static if (verbose) stderr.writefln(" - Registering"); | |
foreach (ref node; newNodes) | |
hashTable[GameRef(&node)] = &node; | |
// Final expansion - save pointers | |
static if (verbose) stderr.writefln(" - Saving expansion results"); | |
foreach (threadIndex; numThreads.iota.parallel(1)) | |
{ | |
ref size_t nodeIndex() { return threadData[threadIndex].expandCommitHead; } | |
for (; nodeIndex < allNodes.length; nodeIndex++) | |
{ | |
auto node = &allNodes[nodeIndex]; | |
if (nodeIndex % numThreads == threadIndex && !node.expanded) | |
{ | |
enum maxChoices = maxHandSize * boardWidth * (1 << boardWidth); | |
Node.Choice[maxChoices] choices = void; | |
size_t numChoices = 0; | |
bool stop = false; | |
const Game game = node.getGame(); | |
Node.Outcome runAction(Action action) /*@nogc*/ | |
{ | |
Node.Outcome outcome; | |
outcome.action = action; | |
Game nextGame = game; | |
nextGame.applyAction(action); | |
nextGame.normalize(); | |
auto gameHash = hashOf(nextGame); | |
auto nextNode = getNodeForGame(nextGame, gameHash); | |
if (!nextNode) | |
stop = true; | |
outcome.node = nextNode; | |
return outcome; | |
} | |
void choiceHandler(scope const Action[] actions) | |
{ | |
assert(actions.length, "Zero outcomes"); | |
auto outcomes = threadData[threadIndex].arena.alloc!(Node.Outcome)(actions.length); | |
foreach (i; 0 .. actions.length) | |
outcomes[i] = runAction(actions[i]); | |
choices[numChoices++] = outcomes; | |
} | |
expandGame(game, &choiceHandler); | |
if (stop) | |
break; // Ran into unregistered node - exploratory step ran out of space here | |
assert(numChoices <= maxChoices); | |
node.choices = threadData[threadIndex].arena.alloc!(Node.Choice)(numChoices); | |
node.choices[] = choices[0 .. numChoices]; | |
assert(node.expanded); | |
} | |
} | |
assert(threadData[threadIndex].expandExploreHead <= threadData[threadIndex].expandCommitHead); | |
} | |
if (!singleThreaded && allNodes.length != thread0Start) stderr.writef("[%d%%]\r", 100 * (threadData[0].expandCommitHead - thread0Start) / (allNodes.length - thread0Start)); stderr.flush(); | |
} | |
// Score propagation step | |
static if (verbose) stderr.writefln("- Propagating scores"); | |
foreach_reverse (ref node; allNodes) | |
updateScore(node); | |
static if (verbose) stderr.writefln("- Iteration done"); | |
} | |
while (iterCallback(root)); | |
} | |
unittest | |
{ | |
Game game; | |
if (false) search(game, null); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment