Compare commits
71
Commits
@@ -5,3 +5,4 @@ dist/
|
||||
.DS_Store
|
||||
data/
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[ 241ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8891/favicon.ico:0
|
||||
@@ -1,4 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- heading "Wiz-War" [level=1] [ref=e4]
|
||||
- generic [ref=e5]: A game of magical combat in a stone labyrinth
|
||||
- generic [ref=e13]: the classic 1993 edition · faithfully reproduced · play free with friends
|
||||
@@ -267,6 +267,7 @@
|
||||
"text": "You may throw it. Does three points physical damage. You cannot stab with it. Retrievable by anyone after it is thrown.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!",
|
||||
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
|
||||
],
|
||||
"verification": {
|
||||
@@ -499,6 +500,7 @@
|
||||
"text": "You may throw it. Does two points physical damage. Once thrown, it may be picked up and thrown again by any player.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!",
|
||||
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
|
||||
],
|
||||
"verification": {
|
||||
@@ -611,13 +613,13 @@
|
||||
},
|
||||
{
|
||||
"id": "number-2",
|
||||
"name": "Number \u201c2\u201d",
|
||||
"name": "Number “2”",
|
||||
"set": "basic",
|
||||
"cardType": "number",
|
||||
"value": 2,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 12,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -627,13 +629,13 @@
|
||||
},
|
||||
{
|
||||
"id": "number-3",
|
||||
"name": "Number \u201c3\u201d",
|
||||
"name": "Number “3”",
|
||||
"set": "basic",
|
||||
"cardType": "number",
|
||||
"value": 3,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 10,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -643,13 +645,13 @@
|
||||
},
|
||||
{
|
||||
"id": "number-4",
|
||||
"name": "Number \u201c4\u201d",
|
||||
"name": "Number “4”",
|
||||
"set": "basic",
|
||||
"cardType": "number",
|
||||
"value": 4,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 7,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -659,13 +661,13 @@
|
||||
},
|
||||
{
|
||||
"id": "number-5",
|
||||
"name": "Number \u201c5\u201d",
|
||||
"name": "Number “5”",
|
||||
"set": "basic",
|
||||
"cardType": "number",
|
||||
"value": 5,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 4,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -675,13 +677,13 @@
|
||||
},
|
||||
{
|
||||
"id": "number-6",
|
||||
"name": "Number \u201c6\u201d",
|
||||
"name": "Number “6”",
|
||||
"set": "basic",
|
||||
"cardType": "number",
|
||||
"value": 6,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -1069,7 +1071,8 @@
|
||||
"text": "Swap places with any other character during your turn. Counts as your movement. FULL REFLECTION, if used against SWAP, merely nullifies it.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"You can use a SWAP before TELEPORT, but not after (within the same turn, of course). Since SWAP indicates that it replaces your movement, then if a REUSE SPELL card is used, you cannot use SWAP again in the same turn, as you have already performed your movement for the turn. Aesthetically this doesn't make a lot of sense, and the card shouldn't say that SWAP replaces movement, but rather if you use SWAP, you foresake your movement, which would allow you to use it twice in a turn, as it would not actually be your movement. Swaps tend to disobey the rule about creating objects in occupied spaces, but that's okay, they're supposed to. SWAP MEET conflicts somewhat with the rule that says you can't create an object where there is already an object, since SWAP MEET clearly says that you swap carried objects, meaning you would HAVE to be in the same space as the \"created object\". SWAP MEET, though, is an acception to the rules, and its operation is clearly explained on the card. Swap Meet is also a little ambiguous; you only swap ONE of your objects for ONE of the opponent's, not two for two."
|
||||
"You can use a SWAP before TELEPORT, but not after (within the same turn, of course). Since SWAP indicates that it replaces your movement, then if a REUSE SPELL card is used, you cannot use SWAP again in the same turn, as you have already performed your movement for the turn. Aesthetically this doesn't make a lot of sense, and the card shouldn't say that SWAP replaces movement, but rather if you use SWAP, you foresake your movement, which would allow you to use it twice in a turn, as it would not actually be your movement. Swaps tend to disobey the rule about creating objects in occupied spaces, but that's okay, they're supposed to.",
|
||||
"SWAP MEET conflicts somewhat with the rule that says you can't create an object where there is already an object, since SWAP MEET clearly says that you swap carried objects, meaning you would HAVE to be in the same space as the \"created object\". SWAP MEET, though, is an acception to the rules, and its operation is clearly explained on the card. Swap Meet is also a little ambiguous; you only swap ONE of your objects for ONE of the opponent's, not two for two."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -1101,8 +1104,7 @@
|
||||
"text": "This spell lets you move up to four spaces (not diagonally) from where you are, ignoring walls and objects. You may take your normal movement before playing this card, but your movement ends after you play it.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"If you use this spell as a Counteraction, the attack has no chance of hitting you. Yes, with a REUSE SPELL, you can TELEPORT twice in a turn. You can teleport only into a vacant space; the 8th edition clarifies this.",
|
||||
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
|
||||
"If you use this spell as a Counteraction, the attack has no chance of hitting you. Yes, with a REUSE SPELL, you can TELEPORT twice in a turn. You can teleport only into a vacant space; the 8th edition clarifies this."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -1120,7 +1122,7 @@
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"If REFLECTED, opponent chooses where he wishes to send you. You may finish your normal movement after he moves you (unlike a regular TELEPORT).",
|
||||
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
|
||||
"Teleport Spells Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -1420,7 +1422,7 @@
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"Yes, you can drop objects in here, even while you are jumping over the PIT. No, contrary to what the card says, you can't create objects in or on or over the PIT, which eliminates all those bizarre questions about creating SOLID STONE over a PIT and having it fall down, or making a WALL sideways over a PIT, and so on. TINY SWAP can't be used to do this either, as this would qualify as creating an object in a space where an object already is, which is against the basic rules. FILL SQUARE WITH STONE cancels a PIT, and any objects inside the PIT at the time appear at the top, on the floor, so nothing gets \"squashed\". It takes a whole turn to climb out of, as well as into, a PIT, and your movement would end adjacent to the PIT. You could still add NUMBER cards to your movement in such a case, though. You can jump diagonally across a PIT if it's at an intersection, but it still takes 2 movement points to get across. A player outside and adjacent to the PIT has LOS into the PIT, but a player in the PIT has no LOS outside of the PIT (except in the very rare instance where someone jumps or flies over and he uses an INTERRUPT to cast an attack...you get the idea). If a PUSH OBJECT is used to shove a treasure in on someone in a PIT, they take 2 points damage just as normal from a PUSHED OBJECT.",
|
||||
"Climbing into a PIT, you must start the turn adjacent to the PIT, and may not perform any other actions while climbing. You could, if you like, just fall in and take the damage. Two or more adjacent PITS would just make one long PIT."
|
||||
"Create Pit, more Climbing into a PIT, you must start the turn adjacent to the PIT, and may not perform any other actions while climbing. You could, if you like, just fall in and take the damage. Two or more adjacent PITS would just make one long PIT."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -1528,13 +1530,13 @@
|
||||
},
|
||||
{
|
||||
"id": "exp1-number-2",
|
||||
"name": "Number \u201c2\u201d (Expansion #1)",
|
||||
"name": "Number “2” (Expansion #1)",
|
||||
"set": "expansion1",
|
||||
"cardType": "number",
|
||||
"value": 2,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 5,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -1544,13 +1546,13 @@
|
||||
},
|
||||
{
|
||||
"id": "exp1-number-3",
|
||||
"name": "Number \u201c3\u201d (Expansion #1)",
|
||||
"name": "Number “3” (Expansion #1)",
|
||||
"set": "expansion1",
|
||||
"cardType": "number",
|
||||
"value": 3,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 4,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -1560,13 +1562,13 @@
|
||||
},
|
||||
{
|
||||
"id": "exp1-number-4",
|
||||
"name": "Number \u201c4\u201d (Expansion #1)",
|
||||
"name": "Number “4” (Expansion #1)",
|
||||
"set": "expansion1",
|
||||
"cardType": "number",
|
||||
"value": 4,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 3,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -1576,13 +1578,13 @@
|
||||
},
|
||||
{
|
||||
"id": "exp1-number-5",
|
||||
"name": "Number \u201c5\u201d (Expansion #1)",
|
||||
"name": "Number “5” (Expansion #1)",
|
||||
"set": "expansion1",
|
||||
"cardType": "number",
|
||||
"value": 5,
|
||||
"subtypes": [],
|
||||
"los": false,
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
|
||||
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
|
||||
"quantity": 2,
|
||||
"faqRulings": [],
|
||||
"verification": {
|
||||
@@ -1617,7 +1619,6 @@
|
||||
"text": "Fills an entire square with a cube of lime-flavored gel. Anyone pushing their way through ends their turn upon entering and may not leave until the next turn. Spells cast at the slime get stuck there, and affect anyone in the slime or entering it later on (spells \"go off\" only once). Counteractions against \"trapped\" attacks do not affect the caster of the attack. A WATERWALL or a 5-point WATERBOLT will wash the slime away. There is no L.O.S. through it.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"Sure, you can leave items in SLIME, but you can't teleport someone there or create something else inside, like a MONSTER, or leave a creation spell trapped inside. This is per the rule that you can't create an object in a space where there is already an object. HANDFUL OF TACKS would work just fine in SLIME, as it isn't a spell. Only water will damage the SLIME, so other attacks are useless. Spells stuck in SLIME only go off once, the first time someone walks into the SLIME. SLIME is a big cube filling the corridor, and offers no LOS, nor can it be flown over.",
|
||||
"What works in a SLIME? Some folks have trouble deciding what would go well in a slime. Try a FIREBALL, or LIGHTNINGBLAST, or SLOW DEATH, or SUDDEN DEATH. You get the idea. After a player sets off the spell, it only affects him, later players entering the SLIME are not affected by the spells which have already been set off."
|
||||
],
|
||||
"verification": {
|
||||
@@ -1652,7 +1653,7 @@
|
||||
"text": "Creates a force field immediately adjacent to you on one side only, preventing an opponent from entering the space you occupy or casting spells on or past you. Lasts only until the end of the opponent's turn.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"The Force Field is stationary, it does not move with the player. Cards that represent non-spell items, such as daggers, can fly through the field just fine. Likewise, Wizardblade can slice through the field."
|
||||
"For those of you who don't own this spell, it says;COUNTERACTION. L.O.S. Creates a force field immediately adjacent to you on one side only, preventing an opponent from entering the space you occupy or cating [sic] spells on or past you. Lasts only until the end of the opponent's turn. The Force Field is stationary, it does not move with the player. Cards that represent non-spell items, such as daggers, can fly through the field just fine. Likewise, Wizardblade can slice through the field."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -1736,7 +1737,8 @@
|
||||
"text": "This spell lets you throw a carried treasure at someone or something (including an empty space). Physical damage equals the number of spaces thrown. The spell is actually cast on yourself, so REFLECTION won't work against it and AROUND THE CORNER won't work with it. If thrown diagonally, measure the distance as the number of spaces a player would move. The attack itself is physical and not magical.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"In the very rare occurence of a passage-loop, where HEAVE-HO would travel forever, we've limited it to a maximum of 10 spaces. AROUND THE CORNER has no effect on HEAVE-HO, as HEAVE-HO is cast on yourself, not on the treasure. In the example below, where the \"x's\" represent Wizards, HEAVE-HO does 3 points of damage, which are counted as spaces as the Wizard walks; _________________________ | | | | | X | | | |_______|_______|_______| | | | | | | | X | |_______|_______|_______|"
|
||||
"In the very rare occurence of a passage-loop, where HEAVE-HO would travel forever, we've limited it to a maximum of 10 spaces. AROUND THE CORNER has no effect on HEAVE-HO, as HEAVE-HO is cast on yourself, not on the treasure. In the example below, where the \"x's\" represent Wizards, HEAVE-HO does 3 points of damage, which are counted as spaces as the Wizard walks;",
|
||||
"_________________________ | | | | | X | | | |_______|_______|_______| | | | | | | | X | |_______|_______|_______|"
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -2049,7 +2051,7 @@
|
||||
"text": "Wand covers enemy with webs, reducing movement by 3 (enemy can still use NUMBER cards for additional movement). Lasts one turn. Any fire damage done to player in webs causes two extra points of damage due to webs burning. The wand has a limited number of charges determined by NUMBER card played when first used. Discard the wand when last charge is gone.",
|
||||
"quantity": 1,
|
||||
"faqRulings": [
|
||||
"Webs, if burned, disappear immediately. h Strength If you tear a treasure out of someone else's hands, this does NOT count as picking up an object and does not end your turn. In fact, you can tear a treasure out of somebody's hands and drop it on the floor even while carrying another treasure."
|
||||
"Webs, if burned, disappear immediately."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -2379,8 +2381,7 @@
|
||||
"text": "For the next NUMBER card worth of turns, the opponent will teleport 1 to 4 spaces in a random direction (roll the die twice). This occurs during your turn. If a BLINK takes opponent to SOLID STONE, reroll distance, otherwise opponent is affected by contents of the destination square. Can be cast on self as a counteraction to an LOS spell (the spell would miss), but no NUMBER card can be played.",
|
||||
"quantity": null,
|
||||
"faqRulings": [
|
||||
"See TELEPORT, or TELEPORT SPELLS.",
|
||||
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
|
||||
"See TELEPORT, or TELEPORT SPELLS."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
@@ -3484,8 +3485,7 @@
|
||||
"text": "This amulet allows you to teleport a number of spaces equal to a role of the die, but it sucks up your life points at a rate equal to another roll of the die. This damage may not be stopped or counteracted in any way, and this card overrules any other card on this issue, including other magic objects (though this does not prevent healing spells from working). May be used once per turn. Replaces normal movement. Display immediately.",
|
||||
"quantity": null,
|
||||
"faqRulings": [
|
||||
"If you attempt to use this and your roll lands you inside solid stone, you do not teleport, but you still lose life-points.",
|
||||
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
|
||||
"If you attempt to use this and your roll lands you inside solid stone, you do not teleport, but you still lose life-points."
|
||||
],
|
||||
"verification": {
|
||||
"status": "verbatim",
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
// The automaton: a clockwork wizard. It plays from its own redacted view —
|
||||
// the same information a human seat receives — and returns one command per
|
||||
// consultation; the server keeps consulting until the maze stops asking.
|
||||
// It never throws: when in doubt it passes, discards, or ends its turn.
|
||||
//
|
||||
// Temperaments color its judgment: the HUNTER marches for treasure, the
|
||||
// BERSERKER for blood, the WORRIER for the shadows between the two.
|
||||
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import { cellKey, edgeKey, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { sightedCellsFor, type GameView } from "./view";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
||||
|
||||
/**
|
||||
* Difficulty degrades resources and repertoire, never judgment. The
|
||||
* apprentice draws one card a turn and knows a modest spellbook; the adept
|
||||
* draws two but keeps no ambushes or amplifies; the archmage knows all.
|
||||
* Every tier plays its cards correctly — none of them is ever stupid.
|
||||
*/
|
||||
export type AutomatonTier = "apprentice" | "adept" | "archmage";
|
||||
export const AUTOMATON_TIERS: AutomatonTier[] = ["apprentice", "adept", "archmage"];
|
||||
|
||||
interface TierTraits {
|
||||
draw: number;
|
||||
/** Added to every counteraction threshold: thrift, not blindness. */
|
||||
counterThrift: number;
|
||||
afflictions: boolean;
|
||||
amplify: boolean;
|
||||
ambush: boolean;
|
||||
guardGold: boolean;
|
||||
/** Shed dead cards at end of turn to keep the draw flowing. */
|
||||
sheds: boolean;
|
||||
buffs: boolean;
|
||||
dejaVu: boolean;
|
||||
}
|
||||
const TIERS: Record<AutomatonTier, TierTraits> = {
|
||||
apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false, sheds: false },
|
||||
adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true, sheds: true },
|
||||
archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true, sheds: true },
|
||||
};
|
||||
|
||||
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
|
||||
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
|
||||
fireball: { base: 5, perNumber: false },
|
||||
"sudden-death": { base: 10, perNumber: false },
|
||||
powerthrust: { base: 2, perNumber: true },
|
||||
waterbolt: { base: 0, perNumber: true, needsNumber: true },
|
||||
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
|
||||
wizardblade: { base: 0, perNumber: true, needsNumber: true },
|
||||
"power-drain": { base: 0, perNumber: true, needsNumber: true },
|
||||
dagger: { base: 3, perNumber: false },
|
||||
"large-rock": { base: 2, perNumber: false },
|
||||
"blaster-wand": { base: 3, perNumber: false },
|
||||
disease: { base: 3, perNumber: false, sameSquare: true },
|
||||
"butt-head": { base: 2, perNumber: false },
|
||||
"heave-ho": { base: 2, perNumber: false },
|
||||
"illusionary-attack": { base: 3, perNumber: false },
|
||||
};
|
||||
|
||||
/** Attacks that carry no points: dropping, dragging, swapping, peeking.
|
||||
* A damage counter drains against nothing — only a full stop denies them. */
|
||||
const UTILITY_ATTACKS = new Set([
|
||||
"drop-object", "teleport-opponent", "mental-force", "mental-swap", "swap",
|
||||
"swap-meet", "card-erasure", "telepath", "sticky-wand", "shift-wand",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
|
||||
* number for the duration where one helps.
|
||||
*/
|
||||
const AFFLICTIONS: Record<string, { withNumber: boolean; vsCarrier?: boolean }> = {
|
||||
blind: { withNumber: true },
|
||||
slow: { withNumber: true },
|
||||
"no-spell": { withNumber: true },
|
||||
medusa: { withNumber: true },
|
||||
"lock-in-place": { withNumber: true },
|
||||
"walking-dead": { withNumber: false },
|
||||
"slow-death": { withNumber: false },
|
||||
idiot: { withNumber: false },
|
||||
weakness: { withNumber: false, vsCarrier: true },
|
||||
"go-away": { withNumber: true },
|
||||
"thought-steal": { withNumber: false },
|
||||
};
|
||||
|
||||
/** Every stone earns its place on the table the moment it is drawn. */
|
||||
const STONES = new Set([
|
||||
"bloodstone", "brainstone", "powerstone", "shadowstone",
|
||||
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||||
]);
|
||||
|
||||
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]);
|
||||
const UNLOCKS = ["master-key", "pick-lock", "remove-lock"];
|
||||
|
||||
function me(view: GameView) {
|
||||
return view.players.find((p) => p.id === view.you)!;
|
||||
}
|
||||
|
||||
function livingEnemies(view: GameView) {
|
||||
return view.players.filter((p) => p.alive && p.id !== view.you);
|
||||
}
|
||||
|
||||
function inHand(view: GameView, cardId: string): CardInstance | undefined {
|
||||
return view.yourHand.find((c) => c.cardId === cardId);
|
||||
}
|
||||
|
||||
function numbersInHand(view: GameView): CardInstance[] {
|
||||
return view.yourHand
|
||||
.filter((c) => cardDef(c.cardId).cardType === "number")
|
||||
.sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0));
|
||||
}
|
||||
|
||||
/** Cards the automaton happily discards to churn its hand, worst first. */
|
||||
function discardValue(c: CardInstance): number {
|
||||
const def = cardDef(c.cardId);
|
||||
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
|
||||
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
|
||||
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null) return 7;
|
||||
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
|
||||
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
|
||||
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
|
||||
c.cardId === "glue") return 6;
|
||||
if (def.cardType === "number") return 4 + (def.value ?? 0);
|
||||
if (def.cardType === "attack") return 3;
|
||||
return 2; // situational neutrals go first
|
||||
}
|
||||
|
||||
interface PathResult {
|
||||
dir: Side;
|
||||
distance: number;
|
||||
/** A locked door stands on the first step of this path. */
|
||||
doorAhead?: { cell: Cell; side: Side };
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS over walkable steps toward the nearest goal. Doors count as passable
|
||||
* when the clockwork can unlock them; the first such door is reported so the
|
||||
* key gets used before the boot.
|
||||
*/
|
||||
function pathToward(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
goals: Set<string>,
|
||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean } = {},
|
||||
): PathResult | null {
|
||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||
const nearEnemy = (c: Cell) =>
|
||||
enemyCells.some((e) => Math.abs(e.x - c.x) + Math.abs(e.y - c.y) <= 2);
|
||||
const cameBy = new Map<string, { prev: string; dir: Side; viaDoor: boolean }>();
|
||||
const seen = new Set<string>([cellKey(from)]);
|
||||
let frontier: Cell[] = [from];
|
||||
let found: string | null = null;
|
||||
let depth = 0;
|
||||
for (; depth < 60 && frontier.length > 0 && !found; depth++) {
|
||||
const next: Cell[] = [];
|
||||
for (const c of frontier) {
|
||||
for (const dir of SIDES) {
|
||||
const t = stepTarget(view.board, c, dir);
|
||||
let to: Cell;
|
||||
let viaDoor = false;
|
||||
if (t.kind === "blocked") {
|
||||
if (t.by !== "door") continue;
|
||||
const k = edgeKey(c, dir);
|
||||
const alreadyOpen =
|
||||
view.openDoorEdges.includes(k) || view.doorStates[k] === "removed";
|
||||
if (!alreadyOpen && !opts.canUnlock) continue;
|
||||
const n = { x: c.x + (dir === "E" ? 1 : dir === "W" ? -1 : 0),
|
||||
y: c.y + (dir === "S" ? 1 : dir === "N" ? -1 : 0) };
|
||||
if (!view.board.cells[cellKey(n)]) continue;
|
||||
to = n;
|
||||
viaDoor = !alreadyOpen;
|
||||
} else {
|
||||
to = t.to;
|
||||
}
|
||||
const k = cellKey(to);
|
||||
if (seen.has(k)) continue;
|
||||
if (view.squareContents[k]?.kind === "stone") continue;
|
||||
seen.add(k);
|
||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||
if (goals.has(k)) { found = k; break; }
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime") continue;
|
||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||
next.push(to);
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
if (!found) return null;
|
||||
let cursor = found;
|
||||
for (;;) {
|
||||
const hop = cameBy.get(cursor)!;
|
||||
if (hop.prev === cellKey(from)) {
|
||||
return {
|
||||
dir: hop.dir,
|
||||
distance: depth,
|
||||
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
|
||||
};
|
||||
}
|
||||
cursor = hop.prev;
|
||||
}
|
||||
}
|
||||
|
||||
/** The treasure squares worth marching for. */
|
||||
function treasureGoals(view: GameView): Set<string> {
|
||||
const self = me(view);
|
||||
const goals = new Set<string>();
|
||||
if (self.carriedTreasureId) {
|
||||
goals.add(cellKey(self.home));
|
||||
return goals;
|
||||
}
|
||||
for (const t of view.treasures) {
|
||||
if (!t.position || t.carriedBy) continue;
|
||||
if (t.owner === view.you) continue;
|
||||
if (cellKey(t.position) === cellKey(self.home)) continue;
|
||||
goals.add(cellKey(t.position));
|
||||
}
|
||||
return goals;
|
||||
}
|
||||
|
||||
/** The wizard making off with MY gold, if any. */
|
||||
function thiefOfMine(view: GameView) {
|
||||
const carriers = new Set(
|
||||
view.treasures.filter((t) => t.owner === view.you && t.carriedBy && t.carriedBy !== view.you)
|
||||
.map((t) => t.carriedBy!),
|
||||
);
|
||||
return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null;
|
||||
}
|
||||
|
||||
function overLimit(view: GameView): number {
|
||||
return Math.max(0, view.yourHand.length - 7);
|
||||
}
|
||||
|
||||
|
||||
/** End the turn drawing fully — shedding dead weight first if the hand is
|
||||
* too full to take the draw. doEndTurn caps the draw by the room left, so a
|
||||
* clogged hand never refreshes; only cards the brain has no play for are
|
||||
* shed (good counters and attacks are hoarded, as a human would). */
|
||||
function endTurnDrawing(view: GameView, tier: TierTraits): Command {
|
||||
const limit = me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
||||
const deficit = tier.draw - (limit - view.yourHand.length);
|
||||
if (tier.sheds && deficit > 0) {
|
||||
const shed = [...view.yourHand]
|
||||
.filter((c) => discardValue(c) <= 3)
|
||||
.sort((a, b) => discardValue(a) - discardValue(b))
|
||||
.slice(0, deficit)
|
||||
.map((c) => c.instanceId);
|
||||
if (shed.length > 0) return { type: "discard", instanceIds: shed };
|
||||
}
|
||||
return { type: "endTurn", draw: tier.draw };
|
||||
}
|
||||
|
||||
function worstCards(view: GameView, n: number): string[] {
|
||||
return [...view.yourHand]
|
||||
.sort((a, b) => discardValue(a) - discardValue(b))
|
||||
.slice(0, n)
|
||||
.map((c) => c.instanceId);
|
||||
}
|
||||
|
||||
/** A square up to four spaces away that maximizes distance from enemies. */
|
||||
function escapeCell(view: GameView, from: Cell): Cell | null {
|
||||
const enemies = livingEnemies(view).map((p) => p.position);
|
||||
let best: { cell: Cell; score: number } | null = null;
|
||||
for (const k of Object.keys(view.board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
const d = Math.abs(x - from.x) + Math.abs(y - from.y);
|
||||
if (d === 0 || d > 4) continue;
|
||||
if (view.squareContents[k]) continue;
|
||||
const score = Math.min(...enemies.map((e) => Math.abs(e.x - x) + Math.abs(e.y - y)), 99);
|
||||
if (!best || score > best.score) best = { cell: { x, y }, score };
|
||||
}
|
||||
return best?.cell ?? null;
|
||||
}
|
||||
|
||||
/** Counteraction judgment; the worrier flinches at less. */
|
||||
function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Command {
|
||||
const stack = view.stack!;
|
||||
const you = view.you;
|
||||
const find = (id: string) => view.yourHand.find((c) => c.cardId === id);
|
||||
|
||||
if (stack.defenderId !== you) {
|
||||
// Pressing the attack: ANTI-ANTI burns the counter that would stop us
|
||||
// (the engine refuses it against escapes; the fallback pass covers that).
|
||||
if (stack.attackerId === you) {
|
||||
const last = [...stack.counters].reverse().find((c) => !c.nullified);
|
||||
const anti = find("anti-anti");
|
||||
if (anti && last && last.card.cardId !== "teleport" && last.card.cardId !== "invisible") {
|
||||
return { type: "counteract", instanceId: anti.instanceId };
|
||||
}
|
||||
}
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
|
||||
const attackId = stack.attackCard?.cardId ?? null;
|
||||
const atk = attackId ? ATTACKS[attackId] : null;
|
||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||
|
||||
if (attackId && UTILITY_ATTACKS.has(attackId)) {
|
||||
const stopper = find("full-shield") ?? find("force-field");
|
||||
const stakes =
|
||||
(attackId === "drop-object" && me(view).carriedTreasureId != null) ||
|
||||
((attackId === "teleport-opponent" || attackId === "mental-force" || attackId === "swap") &&
|
||||
style === "worrier");
|
||||
if (stopper && stakes) return { type: "counteract", instanceId: stopper.instanceId };
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
const incoming = stack.attackCard
|
||||
? attackId === "stone-dead"
|
||||
// NUMBER times the stones in the victim's hand — our hand.
|
||||
? (stack.numberValue ?? 1) * view.yourHand.filter((c) => STONES.has(c.cardId)).length
|
||||
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||
: 1;
|
||||
if (stack.kind === "spell") {
|
||||
// REVERSE turns the biggest blasts into a meal.
|
||||
const reverse = find("reverse");
|
||||
if (reverse && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||
// ABSORB SPELL steals the good ones for later.
|
||||
const absorbSpell = find("absorb-spell");
|
||||
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||
const shield = find("full-shield");
|
||||
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||
return { type: "counteract", instanceId: shield.instanceId };
|
||||
}
|
||||
const reflect = find("full-reflection");
|
||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
// A curse in flight is best refused at the door.
|
||||
if (affliction) {
|
||||
const cleanse = find("remove-curse");
|
||||
if (cleanse) return { type: "counteract", instanceId: cleanse.instanceId };
|
||||
}
|
||||
const half = find("reflection");
|
||||
if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId };
|
||||
}
|
||||
const absorb = find("absorb");
|
||||
if (absorb && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
|
||||
const blunt = find("blunt");
|
||||
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
// The berserker shares its pain out of spite.
|
||||
const empathy = find("empathy");
|
||||
if (empathy && style === "berserker" && incoming >= 2) {
|
||||
return { type: "counteract", instanceId: empathy.instanceId };
|
||||
}
|
||||
// Nothing to block with: teleport clear of the big ones.
|
||||
const tp = find("teleport");
|
||||
if (tp && incoming >= 4 - flinch) {
|
||||
const out = escapeCell(view, me(view).position);
|
||||
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
||||
}
|
||||
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
||||
const vanish = find("invisible");
|
||||
if (vanish && (!stack.creatureId || view.deckRev >= 13) && incoming >= 3 - flinch) {
|
||||
const num = numbersInHand(view)[0];
|
||||
return {
|
||||
type: "counteract", instanceId: vanish.instanceId,
|
||||
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
|
||||
};
|
||||
}
|
||||
// A displayed shieldstone lets numbers soak the small ones.
|
||||
const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone");
|
||||
if (stoneShown && incoming >= 2) {
|
||||
const num = numbersInHand(view)[0];
|
||||
if (num) return { type: "counteract", instanceId: num.instanceId };
|
||||
}
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
/** The best attack available against a visible target, numbers and amplify included. */
|
||||
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Command | null {
|
||||
const numbers = numbersInHand(view);
|
||||
const biggest = numbers[numbers.length - 1];
|
||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||
const target = view.players.find((p) => p.id === targetId)!;
|
||||
const together = cellKey(target.position) === cellKey(me(view).position);
|
||||
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
|
||||
let best: { cmd: Command; damage: number } | null = null;
|
||||
for (const c of view.yourHand) {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) continue;
|
||||
// Heave-Ho throws the treasure we are carrying: priced for defense,
|
||||
// never worth casting.
|
||||
if (c.cardId === "heave-ho") continue;
|
||||
if (atk.sameSquare && !together) continue;
|
||||
// A wand needs its charges set by a number on first use.
|
||||
const isWand = c.cardId === "blaster-wand";
|
||||
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
||||
if ((uncharged || atk.needsNumber) && !biggest) continue;
|
||||
const withNumber = (atk.perNumber || uncharged) && biggest;
|
||||
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||
if (damage <= 0) continue;
|
||||
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
|
||||
const amplified = amplify && damage >= 4 && !isWand &&
|
||||
c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||
if (amplified) damage *= 2;
|
||||
if (!best || damage > best.damage) {
|
||||
best = {
|
||||
damage,
|
||||
cmd: {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
// An illusion needs a spell to imitate; a fireball sells best.
|
||||
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
|
||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// STONE DEAD: number times the stones the victim carries — the displayed
|
||||
// ones are the visible floor of that count.
|
||||
const sd = view.yourHand.find((c) => c.cardId === "stone-dead");
|
||||
if (sd && biggest) {
|
||||
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
|
||||
const dmg = biggestValue * shown;
|
||||
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
|
||||
best = {
|
||||
damage: dmg,
|
||||
cmd: {
|
||||
type: "cast", instanceId: sd.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
numberInstanceIds: [biggest.instanceId],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return best?.cmd ?? null;
|
||||
}
|
||||
|
||||
/** An affliction worth casting when no damage lands, mid number attached. */
|
||||
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
|
||||
const numbers = numbersInHand(view);
|
||||
const mid = numbers[Math.floor(numbers.length / 2)];
|
||||
for (const c of view.yourHand) {
|
||||
const aff = AFFLICTIONS[c.cardId];
|
||||
if (!aff) continue;
|
||||
if (aff.vsCarrier && !isThief) continue;
|
||||
if (aff.withNumber && !mid) continue;
|
||||
return {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An empty, sighted square near the target for a summoned creature. */
|
||||
function summonSpot(view: GameView, near: Cell): Cell | null {
|
||||
const sighted = sightedCellsFor(view);
|
||||
let best: { cell: Cell; d: number } | null = null;
|
||||
for (const k of sighted) {
|
||||
if (view.squareContents[k]) continue;
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (view.creatures.some((c) => c.position.x === x && c.position.y === y)) continue;
|
||||
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
|
||||
const d = Math.abs(x - near.x) + Math.abs(y - near.y);
|
||||
if (!best || d < best.d) best = { cell: { x, y }, d };
|
||||
}
|
||||
return best?.cell ?? null;
|
||||
}
|
||||
|
||||
/** Self-buffs and housekeeping worth a neutral cast this turn. */
|
||||
function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Command | null {
|
||||
const self = me(view);
|
||||
const numbers = numbersInHand(view);
|
||||
const mid = numbers[Math.floor(numbers.length / 2)];
|
||||
|
||||
// Extra turns are always good turns.
|
||||
const speed = inHand(view, "speed");
|
||||
if (speed && view.turn.round > 1 && !view.turn.actionsEnded) {
|
||||
return { type: "cast", instanceId: speed.instanceId };
|
||||
}
|
||||
// Free life is free.
|
||||
const gift = inHand(view, "gift-from-above");
|
||||
if (gift) return { type: "cast", instanceId: gift.instanceId };
|
||||
// Every stone goes on the table the turn it is drawn.
|
||||
for (const c of view.yourHand) {
|
||||
if (STONES.has(c.cardId) && !self.displayed.some((d) => d.instanceId === c.instanceId)) {
|
||||
return { type: "cast", instanceId: c.instanceId };
|
||||
}
|
||||
}
|
||||
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
||||
// A curse on the clockwork gets scrubbed off.
|
||||
const cursed = view.sustained.some(
|
||||
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
|
||||
);
|
||||
const cleanse = inHand(view, "remove-curse");
|
||||
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId };
|
||||
// Three or more wizards: LIFESAVER takes elimination off the table.
|
||||
const lifesaver = inHand(view, "lifesaver");
|
||||
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
||||
// The ward guards the gold while its owner is away robbing yours.
|
||||
if (inHand(view, "ward") && !view.yourWardArmed) {
|
||||
return { type: "armWard", armed: true };
|
||||
}
|
||||
const enemyNear = livingEnemies(view).some(
|
||||
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
|
||||
);
|
||||
// The berserker grows; the worrier fades.
|
||||
if (style === "berserker" && enemyNear && mid) {
|
||||
const big = inHand(view, "big-man");
|
||||
if (big) {
|
||||
return { type: "cast", instanceId: big.instanceId, numberInstanceIds: [mid.instanceId] };
|
||||
}
|
||||
}
|
||||
if (style === "worrier" && enemyNear && mid) {
|
||||
for (const id of ["invisible", "mist-body", "shrink"]) {
|
||||
const buff = inHand(view, id);
|
||||
if (buff) {
|
||||
return { type: "cast", instanceId: buff.instanceId, numberInstanceIds: [mid.instanceId] };
|
||||
}
|
||||
}
|
||||
// Or slam a wall in the pursuer's face.
|
||||
const wall = inHand(view, "create-wall");
|
||||
if (wall) {
|
||||
const foe = livingEnemies(view).sort((a, b) =>
|
||||
(Math.abs(a.position.x - self.position.x) + Math.abs(a.position.y - self.position.y)) -
|
||||
(Math.abs(b.position.x - self.position.x) + Math.abs(b.position.y - self.position.y)))[0]!;
|
||||
const dx = foe.position.x - self.position.x;
|
||||
const dy = foe.position.y - self.position.y;
|
||||
const side: Side = Math.abs(dx) >= Math.abs(dy) && dx !== 0
|
||||
? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
|
||||
if ((view.board.edges[edgeKey(self.position, side)] ?? "open") === "open") {
|
||||
return { type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: self.position, side } };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
|
||||
const myFloorTreasure = tier.guardGold
|
||||
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||
: undefined;
|
||||
if (myFloorTreasure && enemyNear) {
|
||||
const safe = inHand(view, "safe");
|
||||
if (safe) {
|
||||
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
|
||||
}
|
||||
const glue = inHand(view, "glue");
|
||||
if (glue && mid) {
|
||||
return {
|
||||
type: "cast", instanceId: glue.instanceId,
|
||||
target: { kind: "cell", cell: myFloorTreasure.position! },
|
||||
numberInstanceIds: [mid.instanceId],
|
||||
};
|
||||
}
|
||||
}
|
||||
// Empty of violence: DEJA-VU pulls the best attack back from the pile.
|
||||
const dv = tier.dejaVu ? inHand(view, "deja-vu") : undefined;
|
||||
if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) {
|
||||
const buried = [...view.discardPile].reverse().find(
|
||||
(c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber,
|
||||
);
|
||||
if (buried) {
|
||||
return { type: "cast", instanceId: dv.instanceId, params: { cardId: buried.cardId } };
|
||||
}
|
||||
}
|
||||
// An ambush costs nothing to hold and everything to walk into.
|
||||
const via = tier.ambush ? (inHand(view, "interrupt") ?? inHand(view, "opportunity-fire")) : undefined;
|
||||
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
|
||||
c.cardId !== "blaster-wand");
|
||||
if (via && spell && view.yourAmbushes.length === 0) {
|
||||
const trigger: AmbushTrigger = style === "worrier" ? { kind: "near" } : { kind: "treasure" };
|
||||
return { type: "setAmbush", instanceId: via.instanceId, trigger, spellInstanceId: spell.instanceId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One decision from the automaton's seat, or null when the maze is not
|
||||
* asking it anything.
|
||||
*/
|
||||
export function automatonCommand(
|
||||
view: GameView,
|
||||
style: AutomatonStyle = "hunter",
|
||||
tierName: AutomatonTier = "archmage",
|
||||
): Command | null {
|
||||
const you = view.you;
|
||||
const tier = TIERS[tierName] ?? TIERS.archmage;
|
||||
if (view.phase !== "playing") return null;
|
||||
|
||||
if (view.pendingDiscard === you) {
|
||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
||||
}
|
||||
if (view.chaosPending && view.chaosPending.queue[0] === you) {
|
||||
const shield = inHand(view, "full-shield");
|
||||
return style === "worrier" && shield
|
||||
? { type: "counteract", instanceId: shield.instanceId }
|
||||
: { type: "pass" };
|
||||
}
|
||||
if (view.stack) {
|
||||
return view.stack.waitingOn === you ? respond(view, style, tier) : null;
|
||||
}
|
||||
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
|
||||
if (view.activePlayerId !== you) return null;
|
||||
|
||||
const self = me(view);
|
||||
const here = cellKey(self.position);
|
||||
|
||||
if (view.turn.actionsEnded) return endTurnDrawing(view, tier);
|
||||
|
||||
// Deliver or grab treasure underfoot.
|
||||
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
||||
if (!self.carriedTreasureId) {
|
||||
const prize = view.treasures.find(
|
||||
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
|
||||
here !== cellKey(self.home),
|
||||
);
|
||||
if (prize) return { type: "pickUpTreasure" };
|
||||
}
|
||||
|
||||
// Wounded clockwork teleports clear of visible hunters.
|
||||
if (self.life <= 5) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position)));
|
||||
const tp = inHand(view, "teleport");
|
||||
if (hunted && tp) {
|
||||
const out = escapeCell(view, self.position);
|
||||
if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } };
|
||||
}
|
||||
}
|
||||
|
||||
const care = selfCare(view, style, tier);
|
||||
if (care) return care;
|
||||
|
||||
// Command the menagerie: creatures march and maul before the wizard moves.
|
||||
for (const c of view.creatures) {
|
||||
if (c.controllerId !== you || c.justCreated) continue;
|
||||
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
|
||||
if (enemyHere && !c.attackUsed &&
|
||||
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
||||
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
||||
}
|
||||
if (c.movementUsed < c.movesPerTurn) {
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
const hunt = pathToward(view, c.position, enemyCells, {});
|
||||
if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
|
||||
}
|
||||
}
|
||||
|
||||
const thief = thiefOfMine(view);
|
||||
|
||||
// One attack per turn: the thief of my gold dies first, then the weakest.
|
||||
if (!view.turn.attackUsed && view.turn.round > 1) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position)));
|
||||
if (visible.length > 0) {
|
||||
const target = thief && visible.some((p) => p.id === thief.id)
|
||||
? thief
|
||||
: visible.sort((a, b) => a.life - b.life)[0]!;
|
||||
// DROP OBJECT shakes my treasure out of the thief's hands.
|
||||
if (thief && target.id === thief.id) {
|
||||
const dob = inHand(view, "drop-object");
|
||||
if (dob) {
|
||||
return {
|
||||
type: "cast", instanceId: dob.instanceId,
|
||||
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
|
||||
};
|
||||
}
|
||||
}
|
||||
const spell = bestAttack(view, target.id, tier);
|
||||
if (spell) return spell;
|
||||
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
|
||||
if (misery) return misery;
|
||||
if (cellKey(target.position) === here && style !== "worrier") {
|
||||
return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
// No shot at a wizard: raise a creature to do the walking.
|
||||
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
|
||||
if (summon && livingEnemies(view).length > 0) {
|
||||
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
|
||||
const spot = summonSpot(view, near);
|
||||
if (spot) {
|
||||
return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// March. The thief-chase outranks everything; the berserker hunts wizards
|
||||
// over gold; the worrier keeps its distance; the hunter goes to the gold.
|
||||
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
||||
const gold = treasureGoals(view);
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
const canUnlock = UNLOCKS.some((id) => inHand(view, id));
|
||||
const objectives = thief
|
||||
? new Set([cellKey(thief.position)])
|
||||
: style === "berserker" && !self.carriedTreasureId
|
||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||
: gold;
|
||||
const path =
|
||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ??
|
||||
pathToward(view, self.position, objectives, { canUnlock }) ??
|
||||
pathToward(view, self.position, enemyCells, { canUnlock });
|
||||
if (path) {
|
||||
// A locked door on the very next step: use the key first.
|
||||
if (path.doorAhead) {
|
||||
for (const id of UNLOCKS) {
|
||||
const key = inHand(view, id);
|
||||
if (key) {
|
||||
return {
|
||||
type: "cast", instanceId: key.instanceId,
|
||||
target: { kind: "edge", cell: path.doorAhead.cell, side: path.doorAhead.side },
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) {
|
||||
const numbers = numbersInHand(view);
|
||||
// The war chest: when an attack in hand wants a number, the
|
||||
// biggest one is reserved — a waterbolt unfired outbids a longer
|
||||
// march, and walking is free next turn.
|
||||
const wantsNumber = view.yourHand.some((c) => {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) return false;
|
||||
return atk.perNumber || atk.needsNumber === true ||
|
||||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
|
||||
});
|
||||
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
|
||||
const helper = spendable.find(
|
||||
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
|
||||
) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined);
|
||||
if (helper) {
|
||||
return { type: "playNumberForMovement", instanceId: helper.instanceId };
|
||||
}
|
||||
}
|
||||
return { type: "move", direction: path.dir };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return endTurnDrawing(view, tier);
|
||||
}
|
||||
|
||||
/** The safe fallback when the automaton's choice was refused. */
|
||||
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
|
||||
const you = view.you;
|
||||
if (view.pendingDiscard === you) {
|
||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
||||
}
|
||||
if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" };
|
||||
return { type: "endTurn", draw: (TIERS[tierName] ?? TIERS.archmage).draw };
|
||||
}
|
||||
@@ -190,7 +190,7 @@ export function assembleBoard(
|
||||
// Wraparound warps. An opening that faces another sector is a corridor, not
|
||||
// a warp. The remaining outer-boundary openings pair per options.warpPairs
|
||||
// (from the setup diagram's letters) or, by default, straight across the
|
||||
// map. (L-shaped 3p aisle-warp corners are TODO.)
|
||||
// map — including the aisle-warp corner arcs, which are just warp pairs.
|
||||
const warps: Warp[] = [];
|
||||
const inMap = (c: Cell) => cells[cellKey(c)] === true;
|
||||
const openingCell = (sectorIndex: number, side: Side): Cell => {
|
||||
@@ -268,14 +268,23 @@ export function hasLineOfSight(
|
||||
blockedCells?: Record<string, true>,
|
||||
): boolean {
|
||||
if (cellKey(from) === cellKey(to)) return true;
|
||||
// Centers of cells: (x + 0.5, y + 0.5).
|
||||
const x0 = from.x + 0.5, y0 = from.y + 0.5;
|
||||
const x1 = to.x + 0.5, y1 = to.y + 0.5;
|
||||
return segmentClear(
|
||||
board, from.x + 0.5, from.y + 0.5, to.x + 0.5, to.y + 0.5,
|
||||
blockedCells, [cellKey(from), cellKey(to)],
|
||||
);
|
||||
}
|
||||
|
||||
/** Is this raw sight segment unobstructed by walls or solid cells? */
|
||||
function segmentClear(
|
||||
board: AssembledBoard,
|
||||
x0: number, y0: number, x1: number, y1: number,
|
||||
blockedCells: Record<string, true> | undefined,
|
||||
skipCells: string[],
|
||||
): boolean {
|
||||
if (blockedCells) {
|
||||
for (const key of Object.keys(blockedCells)) {
|
||||
if (skipCells.includes(key)) continue;
|
||||
const [bx, by] = key.split(",").map(Number) as [number, number];
|
||||
if ((bx === from.x && by === from.y) || (bx === to.x && by === to.y)) continue;
|
||||
// The sight line is blocked if it crosses any side of the solid cell.
|
||||
const sides: [number, number, number, number][] = [
|
||||
[bx, by, bx + 1, by],
|
||||
@@ -334,12 +343,16 @@ function onSegment(ax: number, ay: number, bx: number, by: number, px: number, p
|
||||
}
|
||||
|
||||
/**
|
||||
* Straight-corridor sight through a wraparound opening. Rulebook: "If
|
||||
* casting a spell, or checking line of sight through the AUTO WARP, treat
|
||||
* it as a straight line, and the two connected boards as though they were
|
||||
* adjacent" — and the lettered openings reconnect board edges the same way
|
||||
* ("you will reenter at point A"). The line must run down the corridor, out
|
||||
* one mouth, and straight in through the paired mouth: no diagonals.
|
||||
* Sight through a wraparound opening — the boards "have open sides", and
|
||||
* the AUTO WARP rule shows how to trace sight across a join: "treat it as
|
||||
* a straight line, and the two connected boards as though they were
|
||||
* adjacent". The texts never deny the lettered openings what they grant
|
||||
* the aisle (only DIMENSIONAL WARP's tokens carry an explicit "no L.O.S."
|
||||
* — and those are not board warps). So the far side is
|
||||
* virtually abutted at the mouth (rotated if the pairing turns a corner,
|
||||
* as the aisle warps do), and the center-to-center line must pass through
|
||||
* the one-cell opening: diagonals through the gap are as legal as they are
|
||||
* through any doorway.
|
||||
*/
|
||||
export function hasWarpLineOfSight(
|
||||
board: AssembledBoard,
|
||||
@@ -347,31 +360,65 @@ export function hasWarpLineOfSight(
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): boolean {
|
||||
const DIR: Record<Side, { x: number; y: number }> = {
|
||||
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
|
||||
};
|
||||
const EPS = 1e-9;
|
||||
for (const w of board.warps) {
|
||||
const mouthA = w.from.cell;
|
||||
const out = w.from.side;
|
||||
const sideA = w.from.side;
|
||||
const mouthB = w.to.cell;
|
||||
const inward = opposite(w.to.side);
|
||||
// The viewer stands in mouth A's corridor, on the interior side.
|
||||
const fromAligned = out === "N" || out === "S" ? from.x === mouthA.x : from.y === mouthA.y;
|
||||
if (!fromAligned) continue;
|
||||
const fromInterior =
|
||||
out === "N" ? from.y >= mouthA.y : out === "S" ? from.y <= mouthA.y :
|
||||
out === "E" ? from.x <= mouthA.x : from.x >= mouthA.x;
|
||||
if (!fromInterior) continue;
|
||||
// The target sits in mouth B's corridor, from the mouth inward.
|
||||
const toAligned = inward === "N" || inward === "S" ? to.x === mouthB.x : to.y === mouthB.y;
|
||||
if (!toAligned) continue;
|
||||
const toInterior =
|
||||
inward === "N" ? to.y <= mouthB.y : inward === "S" ? to.y >= mouthB.y :
|
||||
inward === "E" ? to.x >= mouthB.x : to.x <= mouthB.x;
|
||||
if (!toInterior) continue;
|
||||
// A filled mouth blocks the tunnel unless the viewer/target IS the mouth.
|
||||
// A filled mouth chokes the tunnel unless the viewer/target IS the mouth.
|
||||
if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue;
|
||||
if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue;
|
||||
|
||||
// Rotation taking the far board's inward direction onto sideA.
|
||||
const u = DIR[inward], v = DIR[sideA];
|
||||
const cos = u.x * v.x + u.y * v.y;
|
||||
const sin = u.x * v.y - u.y * v.x;
|
||||
const centerB = { x: mouthB.x + 0.5, y: mouthB.y + 0.5 };
|
||||
const nA = neighbor(mouthA, sideA);
|
||||
const anchor = { x: nA.x + 0.5, y: nA.y + 0.5 };
|
||||
const T = (p: { x: number; y: number }) => ({
|
||||
x: cos * (p.x - centerB.x) - sin * (p.y - centerB.y) + anchor.x,
|
||||
y: sin * (p.x - centerB.x) + cos * (p.y - centerB.y) + anchor.y,
|
||||
});
|
||||
const Tinv = (p: { x: number; y: number }) => ({
|
||||
x: cos * (p.x - anchor.x) + sin * (p.y - anchor.y) + centerB.x,
|
||||
y: -sin * (p.x - anchor.x) + cos * (p.y - anchor.y) + centerB.y,
|
||||
});
|
||||
|
||||
const c0 = { x: from.x + 0.5, y: from.y + 0.5 };
|
||||
const c1 = T({ x: to.x + 0.5, y: to.y + 0.5 });
|
||||
|
||||
// The line must cross the rim through the open mouth of A.
|
||||
let t: number, off: number, P: { x: number; y: number };
|
||||
if (sideA === "E" || sideA === "W") {
|
||||
const rimX = sideA === "E" ? mouthA.x + 1 : mouthA.x;
|
||||
if (Math.abs(c1.x - c0.x) < EPS) continue;
|
||||
t = (rimX - c0.x) / (c1.x - c0.x);
|
||||
const y = c0.y + t * (c1.y - c0.y);
|
||||
off = y - mouthA.y;
|
||||
P = { x: rimX, y };
|
||||
} else {
|
||||
const rimY = sideA === "S" ? mouthA.y + 1 : mouthA.y;
|
||||
if (Math.abs(c1.y - c0.y) < EPS) continue;
|
||||
t = (rimY - c0.y) / (c1.y - c0.y);
|
||||
const x = c0.x + t * (c1.x - c0.x);
|
||||
off = x - mouthA.x;
|
||||
P = { x, y: rimY };
|
||||
}
|
||||
if (t <= EPS || t >= 1 - EPS) continue;
|
||||
// Grazing the mouth's corners counts as blocked — strict reading.
|
||||
if (off <= EPS || off >= 1 - EPS) continue;
|
||||
|
||||
const Pfar = Tinv(P);
|
||||
if (
|
||||
hasLineOfSight(board, from, mouthA, blockedCells) &&
|
||||
hasLineOfSight(board, mouthB, to, blockedCells)
|
||||
segmentClear(board, c0.x, c0.y, P.x, P.y, blockedCells,
|
||||
[cellKey(from), cellKey(mouthA)]) &&
|
||||
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
|
||||
[cellKey(to), cellKey(mouthB)])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+662
-99
File diff suppressed because it is too large
Load Diff
@@ -9,3 +9,4 @@ export * from "./cards";
|
||||
export * from "./setups";
|
||||
export * from "./game";
|
||||
export * from "./view";
|
||||
export * from "./automaton";
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// shuffled and randomly rotated, then laid out per player count; each player
|
||||
// gets the sector containing their home star.
|
||||
//
|
||||
// Implemented: 2 players (1x2 column, crossed side pairings per the diagram)
|
||||
// and 4 players (2x2 square, straight-across pairings). 3 players (L-shape
|
||||
// with the AUTO WARP corner connector) and 5+ (two sets) are TODO.
|
||||
// All five layouts are verified against the rulebook's Set-Up Diagram
|
||||
// (research/images/6e-rules-setup-diagram.jpg): 2p crossed pairs, 3p stair
|
||||
// with the Aisle Warp arc, 4p and 6p straight-across, 5p plus with corner
|
||||
// warps at every notch. board.test.ts pins each pairing.
|
||||
|
||||
import {
|
||||
assembleBoard,
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface GameView {
|
||||
winReason: "treasures" | "lastStanding" | null;
|
||||
turn: TurnState;
|
||||
activePlayerId: PlayerId;
|
||||
/** The game's rules revision — the client mirrors rev-gated legality. */
|
||||
deckRev: number;
|
||||
/** Board with dynamic wall changes already merged in. */
|
||||
board: AssembledBoard;
|
||||
players: PlayerPublicView[];
|
||||
@@ -47,6 +49,8 @@ export interface GameView {
|
||||
treasures: TreasureState[];
|
||||
deckCount: number;
|
||||
discardCount: number;
|
||||
/** The discard pile, face-up as at the table (last = most recent). */
|
||||
discardPile: CardInstance[];
|
||||
/** Cards on the stack are face-up: the whole exchange is public. */
|
||||
stack: CastStack | null;
|
||||
pendingDiscard: PlayerId | null;
|
||||
@@ -67,6 +71,10 @@ export interface GameView {
|
||||
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
||||
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
|
||||
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
||||
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||
/** Whether YOUR ward is set to spring. */
|
||||
yourWardArmed: boolean;
|
||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||
yourAmbushes: AmbushState[];
|
||||
/** Once the game is finished, every hand goes face-up on the table. */
|
||||
@@ -114,9 +122,11 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
revealedHands: state.phase === "finished"
|
||||
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
|
||||
: null,
|
||||
deckRev: state.config.deckRev ?? 1,
|
||||
treasures: state.treasures.map((t) => ({ ...t })),
|
||||
deckCount: state.deck.length,
|
||||
discardCount: state.discard.length,
|
||||
discardPile: [...state.discard],
|
||||
stack: state.stack,
|
||||
pendingDiscard: state.pendingDiscard,
|
||||
sustained: state.sustained.map((s) => ({ ...s })),
|
||||
@@ -132,6 +142,10 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
wandCharges: { ...state.wandCharges },
|
||||
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
|
||||
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
|
||||
chaosPending: state.chaosPending
|
||||
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
||||
: null,
|
||||
yourWardArmed: state.wardArmed.includes(playerId),
|
||||
yourAmbushes: state.ambushes
|
||||
.filter((a) => a.ownerId === playerId)
|
||||
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
||||
@@ -171,3 +185,113 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const CREATION_CARD_IDS = new Set([
|
||||
"fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud",
|
||||
"fill-square-with-slime", "create-pit", "handful-of-tacks",
|
||||
]);
|
||||
const SUMMON_CARD_IDS = new Set([
|
||||
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Squares a cell-target card can legally aim at, for the client's dimming
|
||||
* aid — mirroring the engine's own validation from the viewer's knowledge.
|
||||
* Null = this card's eligibility is not modeled; light everything.
|
||||
*/
|
||||
export function eligibleCellsFor(view: GameView, cardId: string): Set<string> | null {
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return null;
|
||||
const cells = Object.keys(view.board.cells);
|
||||
const key = (x: number, y: number) => `${x},${y}`;
|
||||
const sighted = sightedCellsFor(view);
|
||||
|
||||
if (CREATION_CARD_IDS.has(cardId)) {
|
||||
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
||||
// treasure, object, or a warp token — and in the caster's sight.
|
||||
const out = new Set<string>();
|
||||
for (const k of cells) {
|
||||
if (view.squareContents[k]) continue;
|
||||
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
|
||||
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
|
||||
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
|
||||
if ((view.groundObjects[k] ?? []).length > 0) continue;
|
||||
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
|
||||
if (!sighted.has(k)) continue;
|
||||
out.add(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (SUMMON_CARD_IDS.has(cardId)) {
|
||||
// Summon: any sighted square free of content and creatures.
|
||||
const out = new Set<string>();
|
||||
for (const k of cells) {
|
||||
if (view.squareContents[k]) continue;
|
||||
if (view.creatures.some((c) => key(c.position.x, c.position.y) === k)) continue;
|
||||
if (!sighted.has(k)) continue;
|
||||
out.add(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (cardId === "teleport") {
|
||||
// Up to four spaces, walls and objects ignored; not into solid stone.
|
||||
// BFS over existing cells, matching the engine's wallIgnoringDistance.
|
||||
const out = new Set<string>();
|
||||
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
||||
const queue = [me.position];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
const d = dist.get(key(cur.x, cur.y))!;
|
||||
if (d >= 4) continue;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||
const n = { x: cur.x + dx, y: cur.y + dy };
|
||||
const nk = key(n.x, n.y);
|
||||
if (!view.board.cells[nk] || dist.has(nk)) continue;
|
||||
dist.set(nk, d + 1);
|
||||
queue.push(n);
|
||||
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (cardId === "boobytrap") {
|
||||
// Tokens go anywhere on the board except solid stone — no sight needed;
|
||||
// a trap you can see coming is a poor trap.
|
||||
return new Set(cells.filter((k) => view.squareContents[k]?.kind !== "stone"));
|
||||
}
|
||||
|
||||
if (cardId === "glue" || cardId === "safe") {
|
||||
// Both seize a square that HOLDS something — a loose object or an
|
||||
// unclaimed treasure — within the caster's sight. The safe additionally
|
||||
// needs the square clear of terrain to build around its prize.
|
||||
const out = new Set<string>();
|
||||
for (const k of cells) {
|
||||
if (cardId === "safe" && view.squareContents[k]) continue;
|
||||
const hasObject =
|
||||
(view.groundObjects[k] ?? []).length > 0 ||
|
||||
view.treasures.some((t) => t.position && !t.carriedBy && key(t.position.x, t.position.y) === k);
|
||||
if (!hasObject) continue;
|
||||
if (!sighted.has(k)) continue;
|
||||
out.add(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (cardId === "stone-to-water") {
|
||||
return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone"));
|
||||
}
|
||||
if (cardId === "dispel-creation") {
|
||||
// Anything created — terrain or creature, whoever made it — in sight.
|
||||
const out = new Set<string>();
|
||||
for (const k of cells) {
|
||||
const created = view.squareContents[k] != null ||
|
||||
view.creatures.some((c) => key(c.position.x, c.position.y) === k);
|
||||
if (created && sighted.has(k)) out.add(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCommand,
|
||||
createGame,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(state: GameState): PlayerId {
|
||||
return (
|
||||
state.stack?.waitingOn ??
|
||||
state.pendingDiscard ??
|
||||
state.chaosPending?.queue[0] ??
|
||||
state.outOfTurnWindow?.playerId ??
|
||||
state.players[state.turn.activeIndex]!.id
|
||||
);
|
||||
}
|
||||
|
||||
/** Drive a full bot-vs-bot game; returns the final state and command count. */
|
||||
function playOut(
|
||||
seed: number, players: number, expansion = true,
|
||||
styles: AutomatonStyle[] = [], tiers: AutomatonTier[] = [],
|
||||
) {
|
||||
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
|
||||
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
|
||||
const tierOf = new Map(ids.map((id, i) => [id, tiers[i] ?? "archmage"]));
|
||||
let { state } = createGame({
|
||||
playerIds: ids,
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
deckRev: 8,
|
||||
});
|
||||
let commands = 0;
|
||||
let stuck = 0;
|
||||
const CAP = 4000;
|
||||
while (state.phase === "playing" && commands < CAP) {
|
||||
const seat = actingSeat(state);
|
||||
const view = viewFor(state, seat);
|
||||
const tier = tierOf.get(seat);
|
||||
const chosen = automatonCommand(view, styleOf.get(seat), tier);
|
||||
const cmd = chosen ?? automatonFallback(view, tier);
|
||||
let r = applyCommand(state, seat, cmd);
|
||||
if (!r.ok) {
|
||||
// Retry with the fallback only if the fallback was not what just failed.
|
||||
const fb = automatonFallback(view, tier);
|
||||
if (chosen) r = applyCommand(state, seat, fb);
|
||||
if (!r.ok) {
|
||||
stuck++;
|
||||
if (stuck > 3) {
|
||||
throw new Error(
|
||||
`automaton stuck at seat ${seat} after ${commands} commands: ` +
|
||||
`${JSON.stringify(cmd)} -> ${JSON.stringify(fb)} both refused (${r.error})`,
|
||||
);
|
||||
}
|
||||
// Last-ditch: burn the turn structure forward.
|
||||
r = applyCommand(state, seat, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) r = applyCommand(state, seat, { type: "pass" });
|
||||
if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`);
|
||||
}
|
||||
} else {
|
||||
stuck = 0;
|
||||
}
|
||||
state = r.state;
|
||||
commands++;
|
||||
}
|
||||
return { state, commands };
|
||||
}
|
||||
|
||||
describe("automaton vs automaton", () => {
|
||||
it("two clockwork wizards fight a game to its end", () => {
|
||||
const { state } = playOut(11, 2);
|
||||
expect(state.phase).toBe("finished");
|
||||
expect(state.winner).not.toBeNull();
|
||||
});
|
||||
|
||||
it("holds up across many seeds without wedging", () => {
|
||||
let finished = 0;
|
||||
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34]) {
|
||||
const { state } = playOut(seed, 2);
|
||||
if (state.phase === "finished") finished++;
|
||||
}
|
||||
// Cautious clockwork can stall a maze; most games must still conclude.
|
||||
expect(finished).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("a full table of four automatons concludes", () => {
|
||||
const { state } = playOut(7, 4);
|
||||
expect(state.phase).toBe("finished");
|
||||
});
|
||||
|
||||
it("the apprentice handicap bites where cards decide: combat mirrors", () => {
|
||||
// Deterministic across these seeds: same brains, same dice.
|
||||
let arch = 0, appr = 0;
|
||||
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233]) {
|
||||
const { state } = playOut(seed, 2, true,
|
||||
["berserker", "berserker"], ["archmage", "apprentice"]);
|
||||
if (state.winner === "bot1") arch++;
|
||||
if (state.winner === "bot2") appr++;
|
||||
}
|
||||
expect(arch).toBeGreaterThan(appr);
|
||||
});
|
||||
|
||||
it("every temperament finishes its wars", () => {
|
||||
for (const styles of [
|
||||
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],
|
||||
] as AutomatonStyle[][]) {
|
||||
let finished = 0;
|
||||
for (const seed of [3, 17, 29]) {
|
||||
const { state } = playOut(seed, 2, true, styles);
|
||||
if (state.phase === "finished") finished++;
|
||||
}
|
||||
expect(finished).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||
// burn round 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
while (actingSeat(state) !== "human") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
const human = state.players.find((p: { id: string }) => p.id === "human")!;
|
||||
const bot = state.players.find((p: { id: string }) => p.id === "bot")!;
|
||||
bot.position = { ...human.position };
|
||||
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
||||
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
||||
rig?.(state, "bot");
|
||||
const r = applyCommand(state, "human", {
|
||||
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
|
||||
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
|
||||
});
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
return r.state;
|
||||
}
|
||||
|
||||
it("passes on drop-object rather than absorbing nothing", () => {
|
||||
const state = underAttack("drop-object", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
]);
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "pass" });
|
||||
});
|
||||
|
||||
it("shields a drop-object aimed at its carried treasure", () => {
|
||||
const state = underAttack("drop-object", [
|
||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
], (s, defender) => {
|
||||
const d = s.players.find((p: { id: string }) => p.id === defender)!;
|
||||
const t = s.treasures.find((t: { owner: string }) => t.owner !== defender)!;
|
||||
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
|
||||
});
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a clogged hand gets shed, not hoarded", () => {
|
||||
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
// Seven situational neutrals the brain has no play for: a dead hand.
|
||||
bot.hand = Array.from({ length: 7 }, (_, i) => (
|
||||
{ instanceId: `illusion-wall#${i}`, cardId: "illusion-wall" }
|
||||
));
|
||||
// Walk the bot's turn until it wants to end: it must shed before drawing.
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
const cmd = automatonCommand(view, "hunter", "archmage")!;
|
||||
if (cmd.type === "discard") {
|
||||
expect(cmd.instanceIds.length).toBe(2);
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
const next = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")!;
|
||||
expect(next).toEqual({ type: "endTurn", draw: 2 });
|
||||
return;
|
||||
}
|
||||
if (cmd.type === "endTurn") throw new Error("ended the turn without shedding a dead hand");
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) throw new Error(`${cmd.type}: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
throw new Error("never reached the end of the turn");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
type SectorPlacement,
|
||||
} from "../src/board";
|
||||
import { buildDeck } from "../src/cards";
|
||||
import { activePlayer, applyCommand, createGame } from "../src/game";
|
||||
import { createRng } from "../src/rng";
|
||||
import { setupBoard } from "../src/setups";
|
||||
import { giveCard } from "./helpers";
|
||||
|
||||
describe("card data", () => {
|
||||
it("basic deck is exactly 125 cards (official 6e rulebook)", () => {
|
||||
@@ -109,9 +113,7 @@ describe("board assembly", () => {
|
||||
});
|
||||
|
||||
describe("player counts 2-6", () => {
|
||||
it("assembles a coherent board for every supported count", async () => {
|
||||
const { setupBoard } = await import("../src/setups");
|
||||
const { createRng } = await import("../src/rng");
|
||||
it("assembles a coherent board for every supported count", () => {
|
||||
for (const n of [2, 3, 4, 5, 6]) {
|
||||
const { board } = setupBoard(n, createRng(n * 7));
|
||||
expect(board.homes.length).toBe(n);
|
||||
@@ -127,8 +129,7 @@ describe("player counts 2-6", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("full games start at every count", async () => {
|
||||
const { createGame } = await import("../src/game");
|
||||
it("full games start at every count", () => {
|
||||
for (const n of [3, 5, 6]) {
|
||||
const ids = Array.from({ length: n }, (_, i) => `w${i}`);
|
||||
const { state } = createGame({ playerIds: ids, seed: 99 + n, sets: ["basic", "expansion1"] });
|
||||
@@ -138,38 +139,104 @@ describe("player counts 2-6", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("line of sight through wraparound openings", () => {
|
||||
describe("sight and the wraparound openings", () => {
|
||||
const twoSector: SectorPlacement[] = [
|
||||
{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 },
|
||||
{ boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 },
|
||||
];
|
||||
|
||||
it("sees straight down the corridor, out one mouth and in the other", () => {
|
||||
it("the open sides carry sight: mouths see each other through the join", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
// (2,0) N wraps to (2,9): the mouths see each other through the join.
|
||||
expect(hasLineOfSight(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(false);
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(true);
|
||||
expect(sightBetween(board, { x: 2, y: 9 }, { x: 2, y: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it("demands a straight corridor: off-axis viewers see nothing", () => {
|
||||
it("walls inside the corridor still block, and a filled mouth chokes", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
expect(sightBetween(board, { x: 1, y: 0 }, { x: 2, y: 9 })).toBe(false);
|
||||
expect(sightBetween(board, { x: 3, y: 1 }, { x: 2, y: 9 })).toBe(false);
|
||||
expect(sightBetween(board, { x: 2, y: 2 }, { x: 2, y: 9 })).toBe(false);
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, { "2,9": true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("walls inside either corridor still block the warp sight", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
// Layout A: the wall between (2,0) and (2,1)'s row (home column sight is
|
||||
// cut above row 2 — pinned earlier) blocks a deeper viewer's warp sight.
|
||||
const deepViewer = { x: 2, y: 2 };
|
||||
expect(hasLineOfSight(board, deepViewer, { x: 2, y: 0 })).toBe(false);
|
||||
expect(sightBetween(board, deepViewer, { x: 2, y: 9 })).toBe(false);
|
||||
describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
||||
function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean {
|
||||
if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5;
|
||||
if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5;
|
||||
if (side === "W") return c.x === origin.x && c.y >= origin.y && c.y < origin.y + 5;
|
||||
return c.x === origin.x + 4 && c.y >= origin.y && c.y < origin.y + 5;
|
||||
}
|
||||
function hasWarp(board: { warps: { from: { cell: { x: number; y: number } }; to: { cell: { x: number; y: number } } }[] },
|
||||
a: { o: { x: number; y: number }; s: string }, b: { o: { x: number; y: number }; s: string }): boolean {
|
||||
return board.warps.some((w) => onEdge(w.from.cell, a.o, a.s) && onEdge(w.to.cell, b.o, b.s));
|
||||
}
|
||||
|
||||
it("3 players: the aisle-warp arc plus B, C, A letter pairs", () => {
|
||||
const { board } = setupBoard(3, createRng(7));
|
||||
const top = { x: 5, y: 0 }, bl = { x: 0, y: 5 }, br = { x: 5, y: 5 };
|
||||
expect(hasWarp(board, { o: top, s: "W" }, { o: bl, s: "N" })).toBe(true); // Aisle Warp
|
||||
expect(hasWarp(board, { o: top, s: "N" }, { o: br, s: "S" })).toBe(true); // B
|
||||
expect(hasWarp(board, { o: top, s: "E" }, { o: bl, s: "S" })).toBe(true); // C
|
||||
expect(hasWarp(board, { o: bl, s: "W" }, { o: br, s: "E" })).toBe(true); // A
|
||||
});
|
||||
|
||||
it("a filled mouth chokes the tunnel", () => {
|
||||
const board = assembleBoard(twoSector);
|
||||
const stone = { "2,9": true as const };
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false);
|
||||
it("5 players: opposite tips wrap, notch corners arc around", () => {
|
||||
const { board } = setupBoard(5, createRng(7));
|
||||
const n = { x: 5, y: 0 }, w = { x: 0, y: 5 }, e = { x: 10, y: 5 }, s = { x: 5, y: 10 };
|
||||
expect(hasWarp(board, { o: n, s: "N" }, { o: s, s: "S" })).toBe(true); // A
|
||||
expect(hasWarp(board, { o: w, s: "W" }, { o: e, s: "E" })).toBe(true); // B
|
||||
expect(hasWarp(board, { o: n, s: "W" }, { o: w, s: "N" })).toBe(true); // corners
|
||||
expect(hasWarp(board, { o: n, s: "E" }, { o: e, s: "N" })).toBe(true);
|
||||
expect(hasWarp(board, { o: s, s: "W" }, { o: w, s: "S" })).toBe(true);
|
||||
expect(hasWarp(board, { o: s, s: "E" }, { o: e, s: "S" })).toBe(true);
|
||||
});
|
||||
|
||||
it("6 players: straight-across wrap on both axes", () => {
|
||||
const { board } = setupBoard(6, createRng(7));
|
||||
for (const w of board.warps) {
|
||||
const colinear = w.from.cell.x === w.to.cell.x || w.from.cell.y === w.to.cell.y;
|
||||
expect(colinear).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("relocation discards the aisle warp: only opposite edges connect after", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
|
||||
const bent = (warps: typeof state.board.warps) =>
|
||||
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
|
||||
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
|
||||
const me = activePlayer(state);
|
||||
const rel = giveCard(state, me.id, "relocate-sector");
|
||||
// Slide the top sector from (5,0) across to (0,0): an L becomes a block.
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: rel.instanceId,
|
||||
target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(bent(r.state.board.warps).length).toBe(0); // "only opposite board edges connect"
|
||||
});
|
||||
});
|
||||
|
||||
describe("the aisle warp treats its corner as adjacent — sight included", () => {
|
||||
it("sight passes through the AUTO WARP, diagonals and all", () => {
|
||||
const { board } = setupBoard(3, createRng(7));
|
||||
const aisle = board.warps.find(
|
||||
(w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y,
|
||||
)!;
|
||||
expect(aisle).toBeDefined();
|
||||
// From the aisle mouth, squares beyond the corner are visible — including
|
||||
// at least one OFF the straight axis (the adjacency promise).
|
||||
const mouth = aisle.from.cell;
|
||||
const axisVertical = aisle.from.side === "N" || aisle.from.side === "S";
|
||||
let seen = 0, offAxis = 0;
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (hasLineOfSight(board, mouth, { x, y })) continue;
|
||||
if (sightBetween(board, mouth, { x, y })) {
|
||||
seen++;
|
||||
if (axisVertical ? x !== mouth.x : y !== mouth.y) offAxis++;
|
||||
}
|
||||
}
|
||||
expect(seen).toBeGreaterThan(0);
|
||||
expect(offAxis).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES } from "../src/board";
|
||||
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { viewFor } from "../src/view";
|
||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
|
||||
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
||||
@@ -277,8 +278,7 @@ describe("speedstone", () => {
|
||||
});
|
||||
|
||||
describe("the post-game reveal", () => {
|
||||
it("shows an eliminated player's hand as they fell, not their emptied one", async () => {
|
||||
const { viewFor } = await import("../src/view");
|
||||
it("shows an eliminated player's hand as they fell, not their emptied one", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
@@ -350,7 +350,7 @@ describe("attacking walls and doors", () => {
|
||||
expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("a powered fireball brings a wall down at 20 accumulated damage", () => {
|
||||
it("repeated fireballs bring a wall down at 20 accumulated damage", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
let me = activePlayer(state);
|
||||
@@ -387,3 +387,522 @@ describe("attacking walls and doors", () => {
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ward arming and chaos shields (rules rev 3)", () => {
|
||||
function rev3Game(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
|
||||
}
|
||||
|
||||
it("ward springs only when its owner armed it", () => {
|
||||
let { state } = rev3Game();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const thief = activePlayer(state);
|
||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||
giveCard(state, owner.id, "ward", "W", 0);
|
||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||
|
||||
// Unarmed: the grab goes unpunished.
|
||||
thief.position = { ...treasure.position! };
|
||||
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
|
||||
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
|
||||
|
||||
// Armed (on the owner's own turn): the trap bites for 3.
|
||||
const ownerTurnState = (() => {
|
||||
let s = state;
|
||||
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
|
||||
return s;
|
||||
})();
|
||||
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
|
||||
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
|
||||
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
|
||||
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
|
||||
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
expect(s3.wardArmed).not.toContain(owner.id);
|
||||
});
|
||||
|
||||
it("chaos: bystanders may shield out, reflections are refused", () => {
|
||||
let { state } = rev3Game();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== caster.id)!;
|
||||
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
|
||||
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
|
||||
giveCard(state, defender.id, "full-reflection", "R", 0);
|
||||
giveCard(state, bystander.id, "full-shield", "S", 0);
|
||||
const bystanderHand = bystander.hand.map((c) => c.instanceId).sort();
|
||||
|
||||
state = must(state, caster.id, {
|
||||
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
// "REFLECTIONS have no effect."
|
||||
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "full-reflection#R" }).ok).toBe(false);
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
// Now the bystander's window: they shield out and keep their hand.
|
||||
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
||||
state = must(state, bystander.id, { type: "counteract", instanceId: "full-shield#S" });
|
||||
expect(state.chaosPending).toBeNull();
|
||||
const after = state.players.find((p) => p.id === bystander.id)!;
|
||||
expect(after.hand.map((c) => c.instanceId).sort()).toEqual(
|
||||
bystanderHand.filter((id) => id !== "full-shield#S").sort());
|
||||
});
|
||||
|
||||
it("chaos: the defender's full shield sits them out without stopping it", () => {
|
||||
let { state } = rev3Game();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== caster.id)!;
|
||||
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
|
||||
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
|
||||
giveCard(state, defender.id, "full-shield", "S", 0);
|
||||
const defenderHand = () => state.players.find((p) => p.id === defender.id)!.hand.map((c) => c.instanceId).sort();
|
||||
const kept = defenderHand().filter((id) => id !== "full-shield#S").sort();
|
||||
|
||||
state = must(state, caster.id, {
|
||||
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
|
||||
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
|
||||
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
|
||||
// Bystander declines; the scramble happens without the defender.
|
||||
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
||||
state = must(state, bystander.id, { type: "pass" });
|
||||
expect(state.chaosPending).toBeNull();
|
||||
expect(defenderHand()).toEqual(kept);
|
||||
});
|
||||
|
||||
it("legacy games (rev < 3) keep the automatic ward", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const thief = activePlayer(state);
|
||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||
giveCard(state, owner.id, "ward", "W", 0);
|
||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||
thief.position = { ...treasure.position! };
|
||||
state = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zero-damage utility attacks", () => {
|
||||
it("an uncountered DROP OBJECT is not reported as stopped — and the treasure falls", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const treasure = state.treasures.find((t) => t.owner !== defender)!;
|
||||
treasure.carriedBy = defender;
|
||||
treasure.position = null;
|
||||
d.carriedTreasureId = treasure.id;
|
||||
const dob = giveCard(state, attacker, "drop-object");
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
const result = applyCommand(state, defender, { type: "pass" });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const resolved = result.events.find((e) => e.type === "attackResolved");
|
||||
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(false);
|
||||
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBeNull();
|
||||
});
|
||||
|
||||
it("FULL SHIELD genuinely stops it — the treasure stays carried", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const treasure = state.treasures.find((t) => t.owner !== defender)!;
|
||||
treasure.carriedBy = defender;
|
||||
treasure.position = null;
|
||||
d.carriedTreasureId = treasure.id;
|
||||
const dob = giveCard(state, attacker, "drop-object");
|
||||
giveCard(state, defender, "full-shield", "FS", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
const result = applyCommand(state, defender, { type: "pass" });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const resolved = result.events.find((e) => e.type === "attackResolved");
|
||||
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
|
||||
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport as a counteraction", () => {
|
||||
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const escape = { x: d.position.x, y: d.position.y + 1 };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
|
||||
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const home = { ...d.position };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
|
||||
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(home));
|
||||
expect(after.life).toBe(10);
|
||||
});
|
||||
|
||||
it("reaches at most four spaces", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
const far = { x: d.position.x, y: d.position.y + 5 };
|
||||
expect(applyCommand(state, defender, {
|
||||
type: "counteract", instanceId: tp.instanceId, params: { cell: far },
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slime holds spells", () => {
|
||||
function slimeRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||
// A slime square beside the victim, with the victim adjacent to walk in.
|
||||
const spot = { x: victim.position.x, y: victim.position.y };
|
||||
const cellBeside = (() => {
|
||||
const view = boardView(state);
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view, spot, side);
|
||||
if (t.kind === "step" && !state.players.some((p) => cellKey(p.position) === cellKey(t.to)) &&
|
||||
!boardView(state).homes.some((h) => cellKey(h) === cellKey(t.to))) {
|
||||
return { cell: t.to, side };
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no free square beside the victim");
|
||||
})();
|
||||
state.squareContents[cellKey(cellBeside.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
|
||||
// The caster stands with the victim: one open edge to the slime = clear LOS.
|
||||
caster.position = { ...victim.position };
|
||||
return { state, caster: caster.id, victim: victim.id, slimeAt: cellBeside };
|
||||
}
|
||||
|
||||
it("a fireball cast at the slime waits, then goes off on the next visitor", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(1);
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
// The victim walks in: stuck, and the fireball erupts.
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
expect(state.stack?.attackCard?.cardId).toBe("fireball");
|
||||
expect(state.stack?.trapped).toBe(true);
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(10);
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
||||
});
|
||||
|
||||
it("reflections off a trapped spell never touch its caster", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, rig.caster, { type: "pass" });
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
||||
});
|
||||
|
||||
it("a five-point waterbolt washes the slime (and its cargo) away", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
const wb = giveCard(state, rig.victim, "waterbolt", "WB", 0);
|
||||
giveCard(state, rig.victim, "number-5", "N", 1);
|
||||
state = must(state, rig.victim, {
|
||||
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-5#N"],
|
||||
target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
expect(state.squareContents[cellKey(rig.slimeAt.cell)]).toBeUndefined();
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("answering counteractions (FAQ rulings)", () => {
|
||||
function rev6() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
|
||||
}
|
||||
|
||||
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
|
||||
let { state } = rev6();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
|
||||
giveCard(state, defender, "full-reflection", "FR", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, attacker, { type: "counteract", instanceId: ab.instanceId });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(a.hand.some((c) => c.instanceId === "full-reflection#FR")).toBe(true);
|
||||
expect(a.life).toBe(15); // nothing reflected
|
||||
expect(d.life).toBe(10); // the fireball lands after all
|
||||
});
|
||||
|
||||
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
|
||||
let { state } = rev6();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
|
||||
giveCard(state, defender, "full-shield", "FS", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
||||
const r = applyCommand(state, attacker, { type: "counteract", instanceId: ab.instanceId });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
|
||||
});
|
||||
|
||||
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => {
|
||||
let { state } = rev6();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const escape = { x: d.position.x, y: d.position.y + 1 };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
||||
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
expect(r.ok).toBe(false);
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
});
|
||||
|
||||
describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
state.sustained.push({
|
||||
id: "fx-blind", cardId: "blind", casterId: other.id, targetId: me.id,
|
||||
remainingTurns: 2, data: {},
|
||||
});
|
||||
giveCard(state, me.id, "speed", "SP", 0);
|
||||
state = must(state, me.id, { type: "cast", instanceId: "speed#SP" });
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
// The bonus turn began: one duration turn burned, recipient-counted.
|
||||
expect(activePlayer(state).id).toBe(me.id);
|
||||
expect(state.sustained.find((s) => s.id === "fx-blind")!.remainingTurns).toBe(1);
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
expect(activePlayer(state).id).toBe(other.id);
|
||||
});
|
||||
|
||||
it("nothing can be created on a dimensional warp token", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const spot = { x: me.position.x + 1, y: me.position.y };
|
||||
state.dimWarps.push({ a: spot, b: { x: 0, y: 0 } });
|
||||
const tb = giveCard(state, me.id, "thornbush", "T", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/warp shimmers/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirection (rules rev 9)", () => {
|
||||
it("the two chosen exits connect; their old partners pair with each other", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
// Pick two exits that are NOT already partners.
|
||||
const warps = state.board.warps;
|
||||
const wa = warps[0]!;
|
||||
const wb = warps.find((w) => cellKey(w.from.cell) !== cellKey(wa.from.cell) &&
|
||||
cellKey(w.from.cell) !== cellKey(wa.to.cell))!;
|
||||
const paCell = { ...wa.to.cell };
|
||||
const pbCell = { ...wb.to.cell };
|
||||
const rd = giveCard(state, me.id, "redirection", "RD", 0);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: rd.instanceId,
|
||||
target: { kind: "cell", cell: wb.from.cell }, params: { cell: wa.from.cell },
|
||||
});
|
||||
const after = (from: Cell) => state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(from))!;
|
||||
expect(cellKey(after(wa.from.cell).to.cell)).toBe(cellKey(wb.from.cell));
|
||||
expect(cellKey(after(wb.from.cell).to.cell)).toBe(cellKey(wa.from.cell));
|
||||
expect(cellKey(after(paCell).to.cell)).toBe(cellKey(pbCell));
|
||||
expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell));
|
||||
});
|
||||
});
|
||||
|
||||
describe("total stops end the exchange (rules rev 10)", () => {
|
||||
function rev10Game() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
return { state, attacker, defender };
|
||||
}
|
||||
|
||||
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const lifeBefore = d.life;
|
||||
d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" };
|
||||
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "force-field#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
|
||||
});
|
||||
|
||||
it("a partial counter still invites the defender to stack more", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
||||
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
expect(state.stack).not.toBeNull();
|
||||
expect(state.stack!.waitingOn).toBe(defender);
|
||||
});
|
||||
|
||||
it("a nullified shield is no stop — the exchange returns to the defender", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
|
||||
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
||||
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
||||
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBeLessThan(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapes and elemental walls as counteractions", () => {
|
||||
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const atk = giveCard(state, attacker, attackId);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
defenderCards.forEach((c, i) => { d.hand[i] = c; });
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: atk.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
return { state, attacker, defender, atk };
|
||||
}
|
||||
|
||||
it("invisible vanishes mid-stack: the sustained goes up, the hit needs a 1", () => {
|
||||
let { state, defender } = rigged("fireball", [
|
||||
{ instanceId: "invisible#T", cardId: "invisible" },
|
||||
{ instanceId: "number-3#T", cardId: "number-3" },
|
||||
]);
|
||||
state = must(state, defender, {
|
||||
type: "counteract", instanceId: "invisible#T", numberInstanceIds: ["number-3#T"],
|
||||
});
|
||||
const fx = state.sustained.find((s) => s.cardId === "invisible" && s.targetId === defender);
|
||||
expect(fx).toBeDefined();
|
||||
expect(fx!.remainingTurns).toBe(3);
|
||||
// The attached number left the hand with the spell.
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(d.hand.some((c) => c.instanceId === "number-3#T")).toBe(false);
|
||||
});
|
||||
|
||||
it("anti-anti cannot pin the vanishing — escape is escape", () => {
|
||||
let { state, attacker, defender } = rigged("fireball", [
|
||||
{ instanceId: "invisible#T", cardId: "invisible" },
|
||||
]);
|
||||
state = must(state, defender, { type: "counteract", instanceId: "invisible#T" });
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
|
||||
const refused = applyCommand(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("waterwall meets the fireball and stops it whole", () => {
|
||||
let { state, attacker, defender } = rigged("fireball", [
|
||||
{ instanceId: "waterwall#T", cardId: "waterwall" },
|
||||
]);
|
||||
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
|
||||
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
// A total stop: the attacker's declined answer resolves at once (rev 10+).
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
|
||||
});
|
||||
|
||||
it("waterwall refuses everything that is not a fireball", () => {
|
||||
let { state, defender } = rigged("dagger", [
|
||||
{ instanceId: "waterwall#T", cardId: "waterwall" },
|
||||
]);
|
||||
const refused = applyCommand(state, defender, { type: "counteract", instanceId: "waterwall#T" });
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId } from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
|
||||
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
|
||||
@@ -205,6 +205,16 @@ describe("monsters", () => {
|
||||
});
|
||||
expect(state.creatures[0]!.maxDamage).toBe(8);
|
||||
|
||||
// "Doubles the existing life-points OR movement rate" — the other choice.
|
||||
const movesBefore = state.creatures[0]!.movesPerTurn;
|
||||
const mm2 = giveCard(state, me, "mega-monster", "MM2", 3);
|
||||
state = must(state, me, {
|
||||
type: "cast", instanceId: mm2.instanceId, target: { kind: "creature", creatureId: id },
|
||||
params: { boost: "movement" },
|
||||
});
|
||||
expect(state.creatures[0]!.movesPerTurn).toBe(movesBefore * 2);
|
||||
expect(state.creatures[0]!.maxDamage).toBe(8);
|
||||
|
||||
const dc = giveCard(state, me, "dispel-creation", "DC", 2);
|
||||
state = must(state, me, {
|
||||
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position },
|
||||
@@ -282,3 +292,237 @@ describe("expansion support cards", () => {
|
||||
expect(state.creatures.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
function rev4() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
|
||||
}
|
||||
function wraithOnVictim(state: GameState) {
|
||||
state = toRound2(state);
|
||||
const controller = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== controller.id)!;
|
||||
const spot = emptyNeighborCell(state, controller.position);
|
||||
state.creatures.push({
|
||||
id: "w1", kind: "wraith", controllerId: controller.id, position: { ...controller.position },
|
||||
damage: 0, maxDamage: 4, movesPerTurn: 3, movementUsed: 0,
|
||||
wallPassesPerTurn: 1, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
|
||||
});
|
||||
victim.position = { ...spot.cell };
|
||||
return { state, controller: controller.id, victim: victim.id, side: spot.side };
|
||||
}
|
||||
|
||||
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
|
||||
let { state } = rev4();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "blunt", "B", 0);
|
||||
const handBefore = state.players.find((p) => p.id === rig.victim)!.hand.filter(Boolean).length;
|
||||
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
||||
expect(state.stack?.creatureId).toBe("w1");
|
||||
state = must(state, rig.victim, { type: "counteract", instanceId: "blunt#B" });
|
||||
state = must(state, rig.controller, { type: "pass" });
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
const v = state.players.find((p) => p.id === rig.victim)!;
|
||||
expect(v.life).toBe(14); // 2 halved up to 1
|
||||
expect(v.hand.length).toBe(handBefore - 2); // blunt spent + a card stolen
|
||||
});
|
||||
|
||||
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
|
||||
let { state } = rev4();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
||||
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
||||
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, rig.controller, { type: "pass" });
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
const v = state.players.find((p) => p.id === rig.victim)!;
|
||||
expect(v.life).toBe(15);
|
||||
const wraith = state.creatures.find((c) => c.id === "w1")!;
|
||||
expect(wraith.damage).toBe(2);
|
||||
});
|
||||
|
||||
it("legacy games (rev < 4) keep the instant touch", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe("big man (rules rev 5)", () => {
|
||||
function bigRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
state = toRound2(state);
|
||||
const giant = activePlayer(state);
|
||||
state.sustained.push({
|
||||
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
|
||||
remainingTurns: 9, data: {},
|
||||
});
|
||||
return { state, giant };
|
||||
}
|
||||
|
||||
/** Any square with two open steps in a straight line (test surgery spot). */
|
||||
function straightRun(state: GameState): { at: Cell; side: Side; mid: Cell; far: Cell } {
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const at = { x, y };
|
||||
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||
for (const side of SIDES) {
|
||||
const t1 = stepTarget(view, at, side);
|
||||
if (t1.kind !== "step") continue;
|
||||
const t2 = stepTarget(view, t1.to, side);
|
||||
if (t2.kind !== "step") continue;
|
||||
return { at, side, mid: t1.to, far: t2.to };
|
||||
}
|
||||
}
|
||||
throw new Error("no straight corridor anywhere — impossible");
|
||||
}
|
||||
|
||||
it("pushes a wizard down the corridor as he advances", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
const run = straightRun(state);
|
||||
const giant = state.players.find((p) => p.id === rig.giant.id)!;
|
||||
giant.position = { ...run.at };
|
||||
victim.position = { ...run.mid };
|
||||
state = must(state, rig.giant.id, { type: "move", direction: run.side });
|
||||
const after = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(run.far));
|
||||
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
|
||||
});
|
||||
|
||||
it("no room to shove means no way forward", () => {
|
||||
const rig = bigRig();
|
||||
const state = rig.state;
|
||||
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
const view = boardView(state);
|
||||
// Find a step whose far side is walled: victim there cannot be pushed.
|
||||
for (const side of SIDES) {
|
||||
const t1 = stepTarget(view, rig.giant.position, side);
|
||||
if (t1.kind !== "step") continue;
|
||||
const t2 = stepTarget(view, t1.to, side);
|
||||
if (t2.kind === "step") continue;
|
||||
victim.position = { ...t1.to };
|
||||
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(false);
|
||||
return;
|
||||
}
|
||||
throw new Error("setup: seed offered no walled pocket beside the home");
|
||||
});
|
||||
|
||||
it("monsters cannot enter the giant's square", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const view = boardView(state);
|
||||
const spot = emptyNeighborCell(state, rig.giant.position);
|
||||
state.creatures.push({
|
||||
id: "t1", kind: "troll", controllerId: state.players.find((p) => p.id !== rig.giant.id)!.id,
|
||||
position: { ...spot.cell },
|
||||
damage: 0, maxDamage: 8, movesPerTurn: 3, movementUsed: 0,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
|
||||
});
|
||||
state = must(state, rig.giant.id, { type: "endTurn", draw: 0 });
|
||||
const controller = activePlayer(state).id;
|
||||
const back = (["N", "S", "E", "W"] as const).find((d) => {
|
||||
const t = stepTarget(view, spot.cell, d);
|
||||
return t.kind === "step" && cellKey(t.to) === cellKey(rig.giant.position);
|
||||
})!;
|
||||
const r = applyCommand(state, controller, { type: "moveCreature", creatureId: "t1", direction: back });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("steps over a pit for two movement points, never entering it", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const run = straightRun(state);
|
||||
state.players.find((p) => p.id === rig.giant.id)!.position = { ...run.at };
|
||||
state.squareContents[cellKey(run.mid)] = { kind: "pit", damage: 0, createdBy: rig.giant.id };
|
||||
state = must(state, rig.giant.id, { type: "move", direction: run.side, over: true });
|
||||
const g = state.players.find((p) => p.id === rig.giant.id)!;
|
||||
expect(cellKey(g.position)).toBe(cellKey(run.far));
|
||||
expect(state.turn.movementUsed).toBe(2);
|
||||
expect(g.inPit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
||||
function creatureVsInvisible(deckRev: number) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state).id;
|
||||
const victim = state.players.find((p) => p.id !== me)!;
|
||||
const r = summon(state, me, "skeleton");
|
||||
state = r.state;
|
||||
const bones = state.creatures[0]!;
|
||||
bones.justCreated = false;
|
||||
bones.attackUsed = false;
|
||||
bones.position = { ...victim.position };
|
||||
state.sustained.push({
|
||||
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
|
||||
remainingTurns: 3, data: {},
|
||||
});
|
||||
return { state, me, victim, trollId: bones.id };
|
||||
}
|
||||
|
||||
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
// Deterministic seed: this particular swing misses and the blow dissipates.
|
||||
let { state, me, victim, trollId } = creatureVsInvisible(13);
|
||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
const rolled = state.rng;
|
||||
expect(rolled).toBeDefined();
|
||||
const after = state.players.find((p) => p.id === victim.id)!;
|
||||
const missed = after.life === lifeBefore;
|
||||
// Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle.
|
||||
expect(missed || after.life < lifeBefore).toBe(true);
|
||||
});
|
||||
|
||||
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
||||
let { state, me, victim, trollId } = creatureVsInvisible(12);
|
||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
||||
// No die was consumed on the way.
|
||||
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elimination sweeps the board either way (rules rev 14)", () => {
|
||||
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
|
||||
const victim = state.players.find((p) => p.id === "c")!;
|
||||
state.creatures.push({
|
||||
id: "imp1", kind: "fire-imp", controllerId: "c",
|
||||
position: { ...victim.position },
|
||||
damage: 0, maxDamage: 5, movesPerTurn: 0, movementUsed: 0,
|
||||
attackUsed: false, justCreated: false,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
state.sustained.push({
|
||||
id: "fx-med", cardId: "medusa", casterId: "a", targetId: "c",
|
||||
remainingTurns: 3, data: {},
|
||||
});
|
||||
// Both of c's treasures sit captured on living enemies' home bases.
|
||||
const mine = state.treasures.filter((t) => t.owner === "c");
|
||||
expect(mine.length).toBe(2);
|
||||
mine[0]!.position = { ...state.players.find((p) => p.id === "a")!.home };
|
||||
mine[0]!.carriedBy = null;
|
||||
mine[1]!.position = { ...state.players.find((p) => p.id === "b")!.home };
|
||||
mine[1]!.carriedBy = null;
|
||||
const active = activePlayer(state).id;
|
||||
const r = applyCommand(state, active, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
expect(state.players.find((p) => p.id === "c")!.alive).toBe(false);
|
||||
expect(state.creatures.some((c) => c.controllerId === "c")).toBe(false);
|
||||
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
|
||||
|
||||
describe("expansion terrain", () => {
|
||||
@@ -161,3 +162,53 @@ describe("expansion terrain", () => {
|
||||
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
||||
});
|
||||
});
|
||||
|
||||
describe("eligibility dimming mirrors the engine", () => {
|
||||
it("boobytrap tokens go anywhere but solid stone, sight be damned", () => {
|
||||
let { state } = newGame();
|
||||
const caster = activePlayer(state).id;
|
||||
const view = viewFor(state, caster);
|
||||
const lit = eligibleCellsFor(view, "boobytrap")!;
|
||||
// Every board square is fair game (the fresh maze holds no solid stone),
|
||||
// including squares far outside the caster's sight.
|
||||
expect(lit.size).toBe(Object.keys(view.board.cells).length);
|
||||
});
|
||||
|
||||
it("glue lights only sighted squares that hold something", () => {
|
||||
let { state } = newGame();
|
||||
const caster = activePlayer(state);
|
||||
// Drop a dagger at the caster's feet — the one guaranteed-sighted object.
|
||||
const here = { ...caster.position };
|
||||
state.groundObjects[cellKey(here)] = [{ instanceId: "dagger#G", cardId: "dagger" }];
|
||||
const lit = eligibleCellsFor(viewFor(state, caster.id), "glue")!;
|
||||
expect(lit.has(cellKey(here))).toBe(true);
|
||||
// Empty squares stay dim — glue needs something to glue down.
|
||||
const view = viewFor(state, caster.id);
|
||||
for (const k of lit) {
|
||||
const held =
|
||||
(view.groundObjects[k] ?? []).length > 0 ||
|
||||
view.treasures.some((t) => t.position && cellKey(t.position) === k);
|
||||
expect(held).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("dispel dimming lights creatures and demands sight, whoever made them", () => {
|
||||
let { state } = newGame();
|
||||
const caster = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== caster.id)!;
|
||||
// An enemy wraith beside the caster: created by the OTHER wizard.
|
||||
state.creatures.push({
|
||||
id: "w1", kind: "wraith", controllerId: other.id,
|
||||
position: { x: caster.position.x, y: caster.position.y },
|
||||
damage: 0, maxDamage: Infinity, movesPerTurn: 3, movementUsed: 0,
|
||||
attackUsed: false, justCreated: false,
|
||||
wallPassesPerTurn: 1, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const lit = eligibleCellsFor(viewFor(state, caster.id), "dispel-creation")!;
|
||||
expect(lit.has(cellKey(caster.position))).toBe(true);
|
||||
// Everything lit is created AND sighted.
|
||||
const view = viewFor(state, caster.id);
|
||||
const seen = sightedCellsFor(view);
|
||||
for (const k of lit) expect(seen.has(k)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn, viewFor } from "../src";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||
import { cellKey, edgeKey, type Cell } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
@@ -334,3 +334,104 @@ describe("6e card-face corrections", () => {
|
||||
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relocation past the origin (rules rev 11)", () => {
|
||||
function rev11Game() {
|
||||
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
|
||||
return state;
|
||||
}
|
||||
|
||||
it("a sector may land at negative coordinates — the maze renormalizes", () => {
|
||||
let state = rev11Game();
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
);
|
||||
const otherIdx = idx === 0 ? 1 : 0;
|
||||
const otherOrigin = state.board.placements[otherIdx]!.origin;
|
||||
// Land WEST of the other sector: x = otherOrigin.x - 5, likely negative.
|
||||
const dest = { x: otherOrigin.x - 5, y: otherOrigin.y };
|
||||
const otherPosBefore = { ...other.position };
|
||||
const rel = giveCard(state, me.id, "relocate-sector");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: rel.instanceId,
|
||||
target: { kind: "cell", cell: dest }, params: { cell: me.position },
|
||||
});
|
||||
// The maze zero-anchors: origins snug against both axes.
|
||||
const origins = state.board.placements.map((p: { origin: Cell }) => p.origin);
|
||||
expect(Math.min(...origins.map((o: Cell) => o.x))).toBe(0);
|
||||
expect(Math.min(...origins.map((o: Cell) => o.y))).toBe(0);
|
||||
// Everyone stands on a cell the reassembled board knows.
|
||||
for (const p of state.players) {
|
||||
expect(state.board.cells[cellKey(p.position)]).toBe(true);
|
||||
expect(state.board.cells[cellKey(p.home)]).toBe(true);
|
||||
}
|
||||
// The static sector's tenant rode the renormalization shift exactly.
|
||||
const finalOther = state.board.placements[otherIdx]!.origin;
|
||||
const shifted = state.players.find((p: { id: string }) => p.id === other.id)!;
|
||||
expect(shifted.position).toEqual({
|
||||
x: otherPosBefore.x + (finalOther.x - otherOrigin.x),
|
||||
y: otherPosBefore.y + (finalOther.y - otherOrigin.y),
|
||||
});
|
||||
});
|
||||
|
||||
it("older revisions still refuse the negative landing", () => {
|
||||
let { state } = newGame(); // helper default: rev-ungated (1)
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
);
|
||||
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
||||
const rel = giveCard(state, me.id, "relocate-sector");
|
||||
const refused = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: rel.instanceId,
|
||||
target: { kind: "cell", cell: { x: otherOrigin.x - 5, y: otherOrigin.y - 5 } },
|
||||
params: { cell: me.position },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
|
||||
let state = rev11Game();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
);
|
||||
const myOrigin = state.board.placements[idx]!.origin;
|
||||
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
||||
const inMine = (c: Cell) =>
|
||||
c.x >= myOrigin.x && c.x < myOrigin.x + 5 && c.y >= myOrigin.y && c.y < myOrigin.y + 5;
|
||||
// Seed passengers on the moving sector.
|
||||
const spot = { x: myOrigin.x + 2, y: myOrigin.y + 2 };
|
||||
expect(inMine(spot)).toBe(true);
|
||||
state.creatures.push({
|
||||
id: "c1", kind: "troll", controllerId: me.id, position: { ...spot },
|
||||
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
|
||||
attackUsed: false, justCreated: false,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
state.gluedCells[cellKey(spot)] = true;
|
||||
state.dimWarps.push({ a: { ...spot }, b: { x: otherOrigin.x + 1, y: otherOrigin.y + 1 } });
|
||||
state.boobytraps.push({ casterId: me.id, cells: [{ ...spot }], realKey: cellKey(spot) });
|
||||
|
||||
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
|
||||
const rel = giveCard(state, me.id, "relocate-sector");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: rel.instanceId,
|
||||
target: { kind: "cell", cell: dest }, params: { cell: me.position },
|
||||
});
|
||||
// Expected coordinates follow the final placements (move + zero-anchor).
|
||||
const finalMine = state.board.placements[idx]!.origin;
|
||||
const finalOther = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
||||
const moved = { x: spot.x + finalMine.x - myOrigin.x, y: spot.y + finalMine.y - myOrigin.y };
|
||||
// The warp's far token sat one square inside the static sector.
|
||||
const staticShifted = { x: finalOther.x + 1, y: finalOther.y + 1 };
|
||||
expect(state.creatures[0]!.position).toEqual(moved);
|
||||
expect(state.gluedCells[cellKey(moved)]).toBe(true);
|
||||
expect(state.dimWarps[0]!.a).toEqual(moved);
|
||||
expect(state.dimWarps[0]!.b).toEqual(staticShifted);
|
||||
expect(state.boobytraps[0]!.cells[0]).toEqual(moved);
|
||||
expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// The automatons' table talk, one voice per temperament. Edit freely — the
|
||||
// server picks from these pools at random, sparingly (menace over chatter).
|
||||
//
|
||||
// Triggers: grabGold (picked up a treasure), deliverGold (dropped one on a
|
||||
// home base), dealPain (hurt somebody), takePain (got hurt), kill, die,
|
||||
// summon (created a creature), buildWall, escape (self-teleported away),
|
||||
// springTrap (drew a TRAP card), dodge (an attack missed them), win.
|
||||
|
||||
import type { AutomatonStyle } from "@wizwar/engine";
|
||||
|
||||
export type BanterTrigger =
|
||||
| "grabGold" | "deliverGold" | "dealPain" | "takePain" | "kill" | "die"
|
||||
| "summon" | "buildWall" | "escape" | "springTrap" | "dodge" | "win";
|
||||
|
||||
export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, string[]>>> = {
|
||||
hunter: {
|
||||
grabGold: [
|
||||
"ACQUISITION COMPLETE.",
|
||||
"YOUR GOLD HAS BEEN REALLOCATED.",
|
||||
"ASSET SECURED.",
|
||||
"APPRAISAL: ADEQUATE. TAKING IT ANYWAY.",
|
||||
],
|
||||
deliverGold: [
|
||||
"DELIVERY CONFIRMED. PLEASURE DOING BUSINESS.",
|
||||
"LOGISTICS IS MY LOVE LANGUAGE.",
|
||||
"RECEIPT AVAILABLE UPON REQUEST.",
|
||||
],
|
||||
dealPain: [
|
||||
"AN INEFFICIENT USE OF YOUR TURN.",
|
||||
"COSTS HAVE BEEN PASSED TO THE CONSUMER.",
|
||||
"THIS IS BILLABLE.",
|
||||
],
|
||||
takePain: [
|
||||
"DAMAGE NOTED. INVOICE PENDING.",
|
||||
"THAT COMES OUT OF YOUR ESTATE.",
|
||||
],
|
||||
kill: [
|
||||
"INVENTORY TRANSFERRED. CONDOLENCES.",
|
||||
"ACCOUNT CLOSED.",
|
||||
],
|
||||
die: [
|
||||
"FILING FOR BANKRUPTCY.",
|
||||
"THE MARKET HAS CORRECTED.",
|
||||
],
|
||||
summon: ["A SUBCONTRACTOR HAS BEEN RETAINED."],
|
||||
buildWall: ["ZONING APPROVED."],
|
||||
escape: ["RELOCATION EXPENSES: JUSTIFIED."],
|
||||
springTrap: ["AN UNFORESEEN LIABILITY."],
|
||||
dodge: ["PROJECTIONS SAID YOU WOULD MISS."],
|
||||
win: ["PLEASURE DOING BUSINESS WITH ALL OF YOU."],
|
||||
},
|
||||
berserker: {
|
||||
grabGold: [
|
||||
"GOLD IS MERELY BAIT.",
|
||||
"SHINY. IRRELEVANT.",
|
||||
],
|
||||
dealPain: [
|
||||
"YES. AGAIN.",
|
||||
"PAIN RECEIPT PRINTED.",
|
||||
"HOLD STILL. THIS IS THE FUN PART.",
|
||||
"THE MAZE PROVIDES.",
|
||||
],
|
||||
takePain: [
|
||||
"GOOD. NOW IT IS INTERESTING.",
|
||||
"I FELT THAT. DO IT AGAIN.",
|
||||
],
|
||||
kill: [
|
||||
"SCHEDULED DEMISE: DELIVERED.",
|
||||
"NEXT.",
|
||||
"THE PILE GROWS.",
|
||||
],
|
||||
die: [
|
||||
"SAVE MY SEAT.",
|
||||
"I WILL BE BACK FOR THE REMATCH.",
|
||||
],
|
||||
summon: [
|
||||
"I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY.",
|
||||
"IT EATS WHAT I TELL IT TO EAT.",
|
||||
],
|
||||
buildWall: ["MORE CORNERS TO TRAP YOU IN."],
|
||||
escape: ["A TACTICAL LEAP. NOT A RETREAT."],
|
||||
springTrap: ["WHO PUT THAT THERE. RESPECT."],
|
||||
dodge: ["SWING HARDER."],
|
||||
win: ["LAST ONE STANDING. AS FORETOLD."],
|
||||
},
|
||||
worrier: {
|
||||
grabGold: [
|
||||
"taking this. sorry. sorry.",
|
||||
"borrowing it. permanently. sorry.",
|
||||
],
|
||||
deliverGold: ["home. safe. breathe."],
|
||||
dealPain: [
|
||||
"ow. logged.",
|
||||
"unnecessary!",
|
||||
"that looked like it hurt. it was supposed to. i think.",
|
||||
],
|
||||
takePain: [
|
||||
"rude.",
|
||||
"i wrote it down. the list is long.",
|
||||
],
|
||||
kill: [
|
||||
"oh no. oh no. it worked?",
|
||||
"i'm not proud. mostly.",
|
||||
],
|
||||
die: [
|
||||
"called it.",
|
||||
"this is fine. this is fine. this is not fine.",
|
||||
],
|
||||
summon: ["please be nice to it. actually don't."],
|
||||
buildWall: [
|
||||
"good wall. safe wall.",
|
||||
"one more wall between me and everything.",
|
||||
],
|
||||
escape: ["nope nope nope."],
|
||||
springTrap: ["i knew the floor was suspicious."],
|
||||
dodge: ["missed me. oh thank goodness. i mean: ha."],
|
||||
win: ["wait. me? check again."],
|
||||
},
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
// {type:"makeTransfer"} mint a seat-transfer phrase
|
||||
// {type:"claimTransfer", code} claim a seat on a new device
|
||||
// {type:"catchUp", sinceSeq} replay of moves missed while away
|
||||
// {type:"chat", text} table talk to the room
|
||||
// {type:"myGames", seats} summaries for held seats
|
||||
// {type:"stats"} the engagement tally
|
||||
// {type:"hotseatReport", ...} anonymous hotseat game counts
|
||||
@@ -17,6 +18,7 @@
|
||||
// {type:"room", roomId, players, hostId, started, colors}
|
||||
// {type:"events", events} redacted for this recipient
|
||||
// {type:"state", view, seq} redacted full view (after every change)
|
||||
// {type:"chat", player, text, at} one line of table talk
|
||||
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
|
||||
// {type:"error", message}
|
||||
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
getRoom,
|
||||
joinRoom,
|
||||
loadPersistedRooms,
|
||||
addAutomaton,
|
||||
addChat,
|
||||
driveOneAutomaton,
|
||||
makeTransferCode,
|
||||
redactFor,
|
||||
roomCount,
|
||||
@@ -44,6 +49,7 @@ import {
|
||||
type Room,
|
||||
} from "./rooms";
|
||||
import { engagementStats, recordHotseat } from "./stats";
|
||||
import { BOT_LINES, type BanterTrigger } from "./banter";
|
||||
|
||||
// --- Abuse limits: this is a public server on a small box. -----------------
|
||||
const MAX_SOCKETS = 300; // concurrent connections
|
||||
@@ -111,7 +117,15 @@ const httpServer = createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
const body = readFileSync(real);
|
||||
res.writeHead(200, { "content-type": MIME[extname(real)] ?? "application/octet-stream" });
|
||||
// Hashed assets may cache forever; everything else must revalidate, or a
|
||||
// stale index.html pins users to a dead bundle across deploys.
|
||||
const cache = real.includes(`${sep}assets${sep}`)
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache";
|
||||
res.writeHead(200, {
|
||||
"content-type": MIME[extname(real)] ?? "application/octet-stream",
|
||||
"cache-control": cache,
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(500).end();
|
||||
@@ -159,6 +173,9 @@ function roomInfo(room: Room) {
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
[...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +187,70 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The clockwork plays at a watchable pace: one command every beat, each
|
||||
* broadcast as it lands, until the maze wants a human again.
|
||||
*/
|
||||
const BOT_STEP_MS = 1500;
|
||||
|
||||
/** What a step's event means to this bot — its own deed when it acted,
|
||||
* its own suffering when somebody else did. Null: nothing worth a word. */
|
||||
function banterTrigger(
|
||||
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
|
||||
): BanterTrigger | null {
|
||||
if (actor === seat) {
|
||||
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
|
||||
if (e.type === "treasureDropped" && e.player === seat) return "deliverGold";
|
||||
if (e.type === "damaged" && e.player !== seat) return "dealPain";
|
||||
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
|
||||
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
|
||||
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
|
||||
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
||||
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
|
||||
if (e.type === "gameWon" && e.player === seat) return "win";
|
||||
}
|
||||
if (e.type === "damaged" && e.player === seat) return "takePain";
|
||||
if (e.type === "died" && e.player === seat) return "die";
|
||||
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every seated bot gets a chance to remark on the step — the actor on its
|
||||
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
|
||||
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
|
||||
for (const [seat, bot] of room.bots) {
|
||||
const lines = BOT_LINES[bot.style] ?? {};
|
||||
for (const e of events) {
|
||||
const trigger = banterTrigger(e, seat, actor);
|
||||
const pool = trigger ? lines[trigger] : undefined;
|
||||
if (!pool || Math.random() > 0.5) continue;
|
||||
const text = pool[Math.floor(Math.random() * pool.length)]!;
|
||||
const said = addChat(room, seat, text);
|
||||
if (!("error" in said)) {
|
||||
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
|
||||
}
|
||||
return; // one remark per step, whole table
|
||||
}
|
||||
}
|
||||
}
|
||||
const pumping = new Set<string>();
|
||||
function runBots(room: Room): void {
|
||||
if (pumping.has(room.id)) return;
|
||||
pumping.add(room.id);
|
||||
const tick = () => {
|
||||
const step = driveOneAutomaton(room);
|
||||
if (!step) {
|
||||
pumping.delete(room.id);
|
||||
return;
|
||||
}
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
botRemark(room, step.seat, step.events as { type: string }[]);
|
||||
setTimeout(tick, BOT_STEP_MS);
|
||||
};
|
||||
setTimeout(tick, 800); // a beat after the human's own action settles
|
||||
}
|
||||
|
||||
function broadcastRoomState(room: Room): void {
|
||||
broadcast(room, () => roomInfo(room));
|
||||
if (room.state) {
|
||||
@@ -234,11 +315,27 @@ wss.on("connection", (socket) => {
|
||||
session.roomId = room.id;
|
||||
session.token = result.token;
|
||||
send(socket, { type: "seat", playerId: name, token: result.token });
|
||||
// Rejoining a running game: replay the chronicle so far.
|
||||
// Rejoining a running game: replay the chronicle so far. The
|
||||
// replayed flag keeps one-time fanfare (the opening roll-off)
|
||||
// from firing again on every return to the room.
|
||||
if (room.state) {
|
||||
send(socket, { type: "events", events: redactFor(room.events, name) });
|
||||
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
|
||||
}
|
||||
broadcastRoomState(room);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "addBot": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
|
||||
const result = addAutomaton(
|
||||
room,
|
||||
typeof msg.style === "string" ? msg.style : undefined,
|
||||
typeof msg.tier === "string" ? msg.tier : undefined,
|
||||
);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "start": {
|
||||
@@ -249,6 +346,7 @@ wss.on("connection", (socket) => {
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
||||
broadcastRoomState(room);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "command": {
|
||||
@@ -261,6 +359,27 @@ wss.on("connection", (socket) => {
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
botRemark(room, session.playerId, result.events as { type: string }[]);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "rollDie": {
|
||||
// The tabletop D4 for house calls: publishes to the room's talk,
|
||||
// so it persists, replays, and shows in unread badges like chat.
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const roll = 1 + Math.floor(Math.random() * 4);
|
||||
const result = addChat(room, session.playerId, `rolls the die \u2014 ${roll}`);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
|
||||
break;
|
||||
}
|
||||
case "chat": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const result = addChat(room, session.playerId, String(msg.text ?? ""));
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
|
||||
break;
|
||||
}
|
||||
case "makeTransfer": {
|
||||
@@ -293,7 +412,7 @@ wss.on("connection", (socket) => {
|
||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||
}
|
||||
session.lastCatchUpAt = now;
|
||||
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0));
|
||||
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0), msg.full === true);
|
||||
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
||||
send(socket, { type: "catchUp", steps });
|
||||
break;
|
||||
|
||||
@@ -6,16 +6,22 @@
|
||||
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
applyCommand,
|
||||
automatonCommand,
|
||||
automatonFallback,
|
||||
AUTOMATON_STYLES,
|
||||
AUTOMATON_TIERS,
|
||||
createGame,
|
||||
redactEvent,
|
||||
viewFor,
|
||||
type AutomatonStyle,
|
||||
type AutomatonTier,
|
||||
type Command,
|
||||
type GameEvent,
|
||||
type GameState,
|
||||
type GameView,
|
||||
type PlayerId,
|
||||
} from "@wizwar/engine";
|
||||
import { appendLine, ensureDataDir, readAllRooms, type RoomLine } from "./store";
|
||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { recordRoom } from "./stats";
|
||||
|
||||
export interface LoggedCommand {
|
||||
@@ -39,10 +45,17 @@ export interface Room {
|
||||
state: GameState | null; // null until started
|
||||
log: LoggedCommand[];
|
||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||
/** Table talk, persisted with the room (public to all seats). */
|
||||
chat: { player: PlayerId; text: string; at: string }[];
|
||||
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
||||
const RULES_REV = 14;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
||||
@@ -69,7 +82,9 @@ function makeRoomCode(): string {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)];
|
||||
}
|
||||
return rooms.has(code) ? makeRoomCode() : code;
|
||||
// The disk check covers rooms that exist on file but failed to restore —
|
||||
// reusing such a code would append a new game into the orphaned file.
|
||||
return rooms.has(code) || roomFileExists(code) ? makeRoomCode() : code;
|
||||
}
|
||||
|
||||
export function roomCount(): number {
|
||||
@@ -90,6 +105,8 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
state: null,
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -176,9 +193,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
|
||||
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
||||
if (room.state) return { error: "already started" };
|
||||
const colors = resolveColors(room);
|
||||
const result = startInMemory(room, expansion, colors, 2);
|
||||
const result = startInMemory(room, expansion, colors, RULES_REV);
|
||||
if ("error" in result) return result;
|
||||
appendLine(room.id, { kind: "start", expansion, colors, deckRev: 2 });
|
||||
appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
|
||||
recordRoom(room);
|
||||
return result;
|
||||
}
|
||||
@@ -205,6 +222,87 @@ export function runCommand(
|
||||
return { events: result.events };
|
||||
}
|
||||
|
||||
const CHAT_MAX_LENGTH = 300;
|
||||
const CHAT_KEEP = 500;
|
||||
|
||||
export function addChat(room: Room, playerId: PlayerId, rawText: string): { text: string; at: string } | { error: string } {
|
||||
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
||||
const text = rawText.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, CHAT_MAX_LENGTH);
|
||||
if (!text) return { error: "say something" };
|
||||
const at = new Date().toISOString();
|
||||
room.chat.push({ player: playerId, text, at });
|
||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||
// Talk takes its place in the chronicle where it was said, so rejoining
|
||||
// players see it interwoven with the action, not sunk to the bottom.
|
||||
room.events.push({ type: "tableTalk", player: playerId, text });
|
||||
appendLine(room.id, { kind: "chat", player: playerId, text, at });
|
||||
return { text, at };
|
||||
}
|
||||
|
||||
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
|
||||
|
||||
/** Seat a clockwork wizard (host's choice, before the game starts). */
|
||||
export function addAutomaton(
|
||||
room: Room,
|
||||
styleWanted?: string,
|
||||
tierWanted?: string,
|
||||
): { name: PlayerId } | { error: string } {
|
||||
if (room.state) return { error: "the game has started" };
|
||||
if (room.players.length >= 6) return { error: "room is full" };
|
||||
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
|
||||
if (!name) return { error: "the workshop is empty" };
|
||||
const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle);
|
||||
const style: AutomatonStyle = known
|
||||
? (styleWanted as AutomatonStyle)
|
||||
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
|
||||
const secret = !known; // the mystery machine keeps its mood to itself
|
||||
const tier: AutomatonTier = AUTOMATON_TIERS.includes(tierWanted as AutomatonTier)
|
||||
? (tierWanted as AutomatonTier)
|
||||
: "adept";
|
||||
room.players.push(name);
|
||||
room.bots.set(name, { style, secret, tier });
|
||||
appendLine(room.id, { kind: "join", name, bot: true, style, tier, ...(secret ? { secret: true } : {}) });
|
||||
return { name };
|
||||
}
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(room: Room): PlayerId | null {
|
||||
const s = room.state;
|
||||
if (!s || s.phase !== "playing") return null;
|
||||
return (
|
||||
s.stack?.waitingOn ??
|
||||
s.pendingDiscard ??
|
||||
s.chaosPending?.queue[0] ??
|
||||
s.outOfTurnWindow?.playerId ??
|
||||
s.players[s.turn.activeIndex]!.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One automaton command, if the maze is waiting on clockwork. Null when a
|
||||
* human holds the floor (or the game is over, or the clockwork is wedged).
|
||||
*/
|
||||
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
|
||||
const seat = actingSeat(room);
|
||||
if (!seat || !room.bots.has(seat)) return null;
|
||||
const view = viewFor(room.state!, seat);
|
||||
const bot = room.bots.get(seat);
|
||||
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
||||
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
||||
if ("error" in r) {
|
||||
// A refused choice retries with the fallback — unless the fallback IS
|
||||
// what just failed — then burns down the ladder to endTurn and pass.
|
||||
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
||||
if ("error" in r) {
|
||||
console.error(`automaton ${seat} wedged in ${room.id}: ${r.error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { seat, events: r.events };
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
roomId: string;
|
||||
name: PlayerId;
|
||||
@@ -218,18 +316,21 @@ export interface GameSummary {
|
||||
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
|
||||
round: number | null;
|
||||
lastMoveAt: string | null;
|
||||
/** Total table-talk messages; the client tracks which it has seen. */
|
||||
chatCount: number;
|
||||
}
|
||||
|
||||
/** A seat-holder's one-line view of a room, for the lobby ledger. */
|
||||
export function summarize(room: Room, playerId: PlayerId): GameSummary {
|
||||
const s = room.state;
|
||||
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
|
||||
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null;
|
||||
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null;
|
||||
const turnHolder = waitingOn ?? active;
|
||||
let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null;
|
||||
if (s?.phase === "playing" && turnHolder === playerId) {
|
||||
attention =
|
||||
s.stack?.waitingOn === playerId ? "counteract"
|
||||
: s.chaosPending?.queue[0] === playerId ? "counteract"
|
||||
: s.pendingDiscard === playerId ? "discard"
|
||||
: s.outOfTurnWindow?.playerId === playerId ? "interrupt"
|
||||
: "turn";
|
||||
@@ -246,6 +347,7 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
|
||||
attention,
|
||||
round: s?.turn.round ?? null,
|
||||
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
||||
chatCount: room.chat.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,17 +360,20 @@ export interface CatchUpStep {
|
||||
actor: PlayerId;
|
||||
events: GameEvent[];
|
||||
view: GameView;
|
||||
/** Table talk uttered between the previous command and this one. */
|
||||
chat?: { player: PlayerId; text: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the game and capture a redacted view after each command from
|
||||
* `sinceSeq` on — the "what happened while you were away" reel.
|
||||
*/
|
||||
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number): CatchUpStep[] | { error: string } {
|
||||
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
|
||||
if (!room.state) return { error: "game not started" };
|
||||
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
||||
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
|
||||
const MAX_STEPS = 200;
|
||||
const from = Math.max(sinceSeq, room.log.length - MAX_STEPS);
|
||||
const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
|
||||
const { state: fresh } = createGame(room.state.config);
|
||||
let current = fresh;
|
||||
const steps: CatchUpStep[] = [];
|
||||
@@ -277,11 +382,16 @@ export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number):
|
||||
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
||||
current = result.state;
|
||||
if (entry.seq >= from) {
|
||||
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
||||
const said = room.chat
|
||||
.filter((c) => c.at > prevAt && c.at <= entry.at)
|
||||
.map((c) => ({ player: c.player, text: c.text }));
|
||||
steps.push({
|
||||
seq: entry.seq,
|
||||
actor: entry.playerId,
|
||||
events: redactFor(result.events, playerId),
|
||||
view: viewFor(current, playerId),
|
||||
...(said.length > 0 ? { chat: said } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -389,16 +499,33 @@ export function loadPersistedRooms(): void {
|
||||
state: null,
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
};
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.kind === "join") {
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
room.bots.set(line.name, {
|
||||
style: (line.style as AutomatonStyle) ?? "hunter",
|
||||
secret: line.secret === true,
|
||||
// Tier-less join lines predate tiers, when every automaton
|
||||
// played the full repertoire — not the "adept" lobby default.
|
||||
tier: (line.tier as AutomatonTier) ?? "archmage",
|
||||
});
|
||||
} else {
|
||||
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
|
||||
if (!joinHash) throw new Error("join line has no token");
|
||||
room.players.push(line.name);
|
||||
room.tokens.set(line.name, joinHash);
|
||||
}
|
||||
} else if (line.kind === "start") {
|
||||
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||
} else if (line.kind === "chat") {
|
||||
// File order preserves the interleaving with commands.
|
||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||
room.events.push({ type: "tableTalk", player: line.player, text: line.text });
|
||||
} else if (line.kind === "command") {
|
||||
if (!room.state) throw new Error("command before start in log");
|
||||
const result = applyCommand(room.state, line.playerId, line.command as Command);
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface EngagementStats {
|
||||
firstGameAt: string | null;
|
||||
/** How many of the games were hotseat tables reporting in anonymously. */
|
||||
hotseatGames: number;
|
||||
/** Games with at least one clockwork wizard at the table. */
|
||||
automatonGames: number;
|
||||
}
|
||||
|
||||
interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
|
||||
@@ -40,7 +42,7 @@ let data: StatsFile = {
|
||||
commandsPlayed: 0, minutesAtTable: 0,
|
||||
winsByTreasure: 0, winsByLastStanding: 0,
|
||||
longestGameCommands: 0, fullestTable: 0, firstGameAt: null,
|
||||
hotseatGames: 0, wizards: [], roomStages: {},
|
||||
hotseatGames: 0, automatonGames: 0, wizards: [], roomStages: {},
|
||||
};
|
||||
let wizardSet = new Set<string>();
|
||||
let loaded = false;
|
||||
@@ -84,6 +86,7 @@ export function recordRoom(room: Room): void {
|
||||
const stage = data.roomStages[room.id];
|
||||
|
||||
for (const p of room.players) {
|
||||
if (room.bots.has(p)) continue; // the clockwork are not wizards seated
|
||||
const key = p.toLowerCase();
|
||||
if (!wizardSet.has(key)) { wizardSet.add(key); dirty = true; }
|
||||
}
|
||||
@@ -95,6 +98,7 @@ export function recordRoom(room: Room): void {
|
||||
}
|
||||
if (room.state && data.roomStages[room.id] === "created") {
|
||||
data.gamesStarted++;
|
||||
if (room.bots.size > 0) data.automatonGames++;
|
||||
data.fullestTable = Math.max(data.fullestTable, room.players.length);
|
||||
data.roomStages[room.id] = "started";
|
||||
dirty = true;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// command. Because the engine is deterministic, replaying a file rebuilds
|
||||
// the exact game state — server restarts lose nothing.
|
||||
|
||||
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface RoomMetaLine {
|
||||
@@ -24,6 +24,14 @@ export interface JoinLine {
|
||||
tokenHash?: string;
|
||||
/** Legacy plaintext token (pre-hashing files only). */
|
||||
token?: string;
|
||||
/** An automaton seat: no token; the server plays it. */
|
||||
bot?: true;
|
||||
/** The automaton's temperament. */
|
||||
style?: string;
|
||||
/** The automaton's difficulty tier. */
|
||||
tier?: string;
|
||||
/** A mystery machine: the temperament is not revealed to the table. */
|
||||
secret?: true;
|
||||
}
|
||||
|
||||
export interface StartLine {
|
||||
@@ -43,7 +51,14 @@ export interface CommandLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine;
|
||||
export interface ChatLine {
|
||||
kind: "chat";
|
||||
player: string;
|
||||
text: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
@@ -60,6 +75,11 @@ export function ensureDataDir(): void {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/** A room file may exist even when the room failed to restore into memory. */
|
||||
export function roomFileExists(roomId: string): boolean {
|
||||
return existsSync(fileFor(roomId));
|
||||
}
|
||||
|
||||
export function appendLine(roomId: string, line: RoomLine): void {
|
||||
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
|
||||
}
|
||||
|
||||
+611
-52
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import type { Side } from "@wizwar/engine";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
import { FX_SPRITES } from "./fx-sprites";
|
||||
|
||||
const CELL = 48;
|
||||
const WALL = 7;
|
||||
@@ -17,7 +18,12 @@
|
||||
onWarpClick,
|
||||
onCellPeek,
|
||||
markedCell = null,
|
||||
markedCells = null,
|
||||
litCells = null,
|
||||
markedSector = null,
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
effects = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
@@ -31,11 +37,37 @@
|
||||
onCellPeek?: (cell: { x: number; y: number }) => void;
|
||||
/** First square of a two-square spell: marked so the click reads as taken. */
|
||||
markedCell?: { x: number; y: number } | null;
|
||||
/** A multi-square placement in progress (boobytrap tokens), in click
|
||||
* order — the first is the real trap, so each mark shows its number. */
|
||||
markedCells?: { x: number; y: number }[] | null;
|
||||
/** When set, squares NOT in this set dim — the targeting aid. */
|
||||
litCells?: Set<string> | null;
|
||||
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
|
||||
markedSector?: { x: number; y: number } | null;
|
||||
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
|
||||
* beyond the maze, since an empty slot has no floor of its own to click. */
|
||||
ghostSlots?: { x: number; y: number }[] | null;
|
||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||
/** Short-lived spell flourishes; purely cosmetic. */
|
||||
effects?: import("./fx").BoardFx[] | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
|
||||
const SECTOR = 5;
|
||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||
* negative coordinates (the maze renormalizes after the landing). The
|
||||
* canvas grows in whichever direction holds them. */
|
||||
const minBx = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.x)) * CELL - 8);
|
||||
const minBy = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.y)) * CELL - 8);
|
||||
const boundsW = $derived(
|
||||
Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 8 - minBx,
|
||||
);
|
||||
const boundsH = $derived(
|
||||
Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 8 - minBy,
|
||||
);
|
||||
|
||||
|
||||
// Press-and-hold peeks the square's card; a fired hold swallows the click.
|
||||
let peekTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let peekFired = false;
|
||||
@@ -74,6 +106,12 @@
|
||||
if (cardId.endsWith("-wand")) return "magic-wand";
|
||||
return null;
|
||||
}
|
||||
// Ongoing spells wear their look: ghostly when unseen, webbed when caught,
|
||||
// stone-gray under Medusa's gaze.
|
||||
function hasSpell(id: string, cardId: string): boolean {
|
||||
return view.sustained.some((e) => e.cardId === cardId && e.targetId === id);
|
||||
}
|
||||
|
||||
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
||||
function wizardScale(id: string): number {
|
||||
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
|
||||
@@ -104,7 +142,12 @@
|
||||
.map(([key, state]) => {
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
return { kind, x, y, state };
|
||||
// A door's lock can be gone for good, jammed shut, or picked open
|
||||
// for the turn — each earns its own look.
|
||||
const lock = state === "door"
|
||||
? (view.doorStates[key] ?? (view.openDoorEdges.includes(key) ? "ajar" : null))
|
||||
: null;
|
||||
return { kind, x, y, state, lock };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -149,10 +192,27 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Explicit width/height give the svg an intrinsic size: Safari cannot
|
||||
derive one from the viewBox alone and collapses the board to 0x0 inside
|
||||
a max-height flex column. Doubled so CSS max-* caps still govern. -->
|
||||
<svg
|
||||
viewBox={`-8 -8 ${view.board.width * CELL + 16} ${view.board.height * CELL + 16}`}
|
||||
viewBox={`${minBx} ${minBy} ${boundsW} ${boundsH}`}
|
||||
width={boundsW * 2}
|
||||
height={boundsH * 2}
|
||||
class="board"
|
||||
>
|
||||
<!-- open ground a relocated sector may claim -->
|
||||
{#each ghostSlots ?? [] as g (`${g.x},${g.y}`)}
|
||||
<rect
|
||||
x={g.x * CELL + 2} y={g.y * CELL + 2}
|
||||
width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
||||
class="ghost-slot"
|
||||
role="button" tabindex="-1"
|
||||
onclick={() => onGhostClick?.(g)}
|
||||
onkeydown={() => {}}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- floor -->
|
||||
{#each cells as c (`${c.x},${c.y}`)}
|
||||
<rect
|
||||
@@ -296,19 +356,22 @@
|
||||
|
||||
<!-- walls & doors & firewalls -->
|
||||
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
|
||||
{@const cls = e.state === "door" ? "door" : e.state === "firewall" ? "firewall" : "wall"}
|
||||
{@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : e.state === "firewall" ? "firewall" : "wall"}
|
||||
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
|
||||
: e.lock === "jammed" ? "lock jammed — sealed for good"
|
||||
: e.lock === "ajar" ? "unlocked until end of turn" : null}
|
||||
{#if e.kind === "V"}
|
||||
<rect
|
||||
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
|
||||
width={WALL} height={CELL + WALL}
|
||||
class={cls}
|
||||
/>
|
||||
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
||||
{:else}
|
||||
<rect
|
||||
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
||||
width={CELL + WALL} height={WALL}
|
||||
class={cls}
|
||||
/>
|
||||
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -383,6 +446,28 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- placements so far of a multi-square spell, numbered in click order -->
|
||||
{#each markedCells ?? [] as m, i (`${m.x},${m.y}`)}
|
||||
<rect
|
||||
x={m.x * CELL + 3} y={m.y * CELL + 3}
|
||||
width={CELL - 6} height={CELL - 6}
|
||||
rx="6" class="marked-cell"
|
||||
/>
|
||||
<text
|
||||
x={m.x * CELL + CELL / 2} y={m.y * CELL + CELL / 2}
|
||||
class="marked-count" text-anchor="middle" dominant-baseline="central"
|
||||
>{i + 1}</text>
|
||||
{/each}
|
||||
|
||||
<!-- the sector picked up, awaiting its new ground -->
|
||||
{#if markedSector}
|
||||
<rect
|
||||
x={markedSector.x * CELL + 3} y={markedSector.y * CELL + 3}
|
||||
width={SECTOR * CELL - 6} height={SECTOR * CELL - 6}
|
||||
rx="8" class="marked-sector"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- wizards -->
|
||||
{#each [...wizardsByCell.entries()] as [key, group] (key)}
|
||||
{#each group as p, i (p.id)}
|
||||
@@ -405,12 +490,21 @@
|
||||
width={half * 2} height={half * 2}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="token-art hit"
|
||||
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
|
||||
class:stone-gazed={hasSpell(p.id, "medusa")}
|
||||
/>
|
||||
<rect
|
||||
x={cx - half} y={cy - half}
|
||||
width={half * 2} height={half * 2}
|
||||
class="wizard-ring" stroke={playerColor(p.id)}
|
||||
/>
|
||||
{#if hasSpell(p.id, "sticky-web")}
|
||||
<g class="webbing">
|
||||
<line x1={cx - half} y1={cy - half * 0.4} x2={cx + half} y2={cy + half * 0.5} />
|
||||
<line x1={cx - half * 0.6} y1={cy + half} x2={cx + half * 0.7} y2={cy - half} />
|
||||
<line x1={cx - half} y1={cy + half * 0.7} x2={cx + half} y2={cy - half * 0.2} />
|
||||
</g>
|
||||
{/if}
|
||||
{:else}
|
||||
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
|
||||
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
|
||||
@@ -493,6 +587,13 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
{#each effects ?? [] as fx (fx.id)}
|
||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
||||
<Sprite fx={fx as never} />
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
@@ -529,10 +630,21 @@
|
||||
stroke-width: 1.4;
|
||||
rx: 2;
|
||||
}
|
||||
/* Lock removed: pale, an open doorway forever. */
|
||||
.door.removed { fill: #d4bd8e; }
|
||||
/* Lock jammed: near-black, sealed. */
|
||||
.door.jammed { fill: #3a2a18; stroke: #14100b; }
|
||||
/* Picked or keyed open until end of turn. */
|
||||
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
|
||||
.firewall {
|
||||
fill: #d0342c;
|
||||
stroke: #7c1a14;
|
||||
stroke-width: 1.2;
|
||||
animation: firewall-lick 1.1s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes firewall-lick {
|
||||
from { fill: #d0342c; filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
|
||||
to { fill: #ef6c3a; filter: drop-shadow(0 0 6px rgba(255, 140, 30, 0.9)); }
|
||||
}
|
||||
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
||||
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
||||
@@ -563,6 +675,11 @@
|
||||
.creature-body { fill: #3b3428; stroke-width: 2.5; }
|
||||
.creature-body.selected { fill: #6b5a34; }
|
||||
.creature-label { font-size: 11px; font-weight: bold; fill: #f4ead2; text-anchor: middle; pointer-events: none; }
|
||||
/* Decorative shapes never eat clicks — the floor rect beneath handles them. */
|
||||
.home-star, .stone, .bush, .rose, .ooze, .slime, .dust, .pit, .tacks, .safe,
|
||||
.dimwarp, .treasure-g, .treasure, .treasure-swirl, .treasure-core {
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-star { stroke: #2b2218; stroke-width: 1.4; opacity: 0.92; }
|
||||
.treasure { stroke: #2b2218; stroke-width: 1.3; }
|
||||
.treasure-swirl { fill: none; stroke: #2b2218; stroke-width: 1.2; stroke-dasharray: 5 2.2; }
|
||||
@@ -597,6 +714,93 @@
|
||||
pointer-events: none;
|
||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.marked-count {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
fill: #d3852b;
|
||||
pointer-events: none;
|
||||
}
|
||||
.marked-sector {
|
||||
fill: rgba(211, 133, 43, 0.12);
|
||||
stroke: #d3852b;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 10 6;
|
||||
pointer-events: none;
|
||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.ghost-slot {
|
||||
fill: rgba(122, 162, 122, 0.10);
|
||||
stroke: #7aa27a;
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 10 6;
|
||||
cursor: pointer;
|
||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
|
||||
|
||||
.fx-layer { pointer-events: none; }
|
||||
|
||||
/* --- persistent ambiance: ongoing spells wear their look ------------ */
|
||||
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
|
||||
@keyframes ghost-breathe { 50% { opacity: 0.22; } }
|
||||
.token-art.stone-gazed { filter: grayscale(0.9) brightness(0.8); }
|
||||
.webbing line {
|
||||
stroke: rgba(220, 218, 205, 0.85);
|
||||
stroke-width: 1.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
|
||||
.fx-dust.late { animation-delay: 0.09s; }
|
||||
.fx-dust.later { animation-delay: 0.17s; }
|
||||
|
||||
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@keyframes fx-flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
60% { opacity: 1; } 100% { opacity: 0; }
|
||||
}
|
||||
@keyframes fx-ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
@keyframes fx-ring-small {
|
||||
0% { opacity: 0.9; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(2.6); }
|
||||
}
|
||||
@keyframes fx-shield-pulse {
|
||||
0% { opacity: 0; transform: scale(0.6); }
|
||||
30% { opacity: 1; transform: scale(1.1); }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { opacity: 0; transform: scale(1.15); }
|
||||
}
|
||||
@keyframes fx-drop {
|
||||
0% { opacity: 0.9; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-14px); }
|
||||
}
|
||||
@keyframes fx-drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
@keyframes fx-pow-hit {
|
||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||
55% { transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
@keyframes fx-claw-rake {
|
||||
0% { opacity: 0; transform: translateY(-6px); }
|
||||
20% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
@keyframes fx-swallow {
|
||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||
}
|
||||
@keyframes fx-spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||
30% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
||||
}
|
||||
.warp-dest {
|
||||
fill: none;
|
||||
stroke: #2e7d32;
|
||||
@@ -610,7 +814,9 @@
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marked-cell, .warp-dest { animation: none; }
|
||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||
.fx-layer { display: none; }
|
||||
.firewall, .token-art.ghosted { animation: none; }
|
||||
}
|
||||
.wizard { cursor: pointer; }
|
||||
.wizard-label {
|
||||
|
||||
@@ -8,15 +8,21 @@
|
||||
attached = false,
|
||||
marked = false,
|
||||
displayed = false,
|
||||
glowing = false,
|
||||
charges = null,
|
||||
onclick,
|
||||
onfaq,
|
||||
}: {
|
||||
card: CardInstance;
|
||||
selected?: boolean;
|
||||
attached?: boolean;
|
||||
marked?: boolean;
|
||||
displayed?: boolean;
|
||||
/** Playable RIGHT NOW out of turn — a golden pulse invites the tap. */
|
||||
glowing?: boolean;
|
||||
charges?: number | null;
|
||||
/** When set, cards with official FAQ rulings wear a tappable FAQ seal. */
|
||||
onfaq?: (cardId: string) => void;
|
||||
onclick?: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -34,12 +40,25 @@
|
||||
}
|
||||
});
|
||||
const rightRail = $derived(def.los ? "L.O.S." : def.adjacent ? "ADJACENT" : "");
|
||||
// The text box is fixed; the type shrinks until every word fits. Longest
|
||||
// card in the set is ~550 chars (Firefly Stick).
|
||||
const textSize = $derived.by(() => {
|
||||
const n = def.text?.length ?? 0;
|
||||
if (n <= 130) return "0.6rem";
|
||||
if (n <= 200) return "0.56rem";
|
||||
if (n <= 280) return "0.5rem";
|
||||
if (n <= 370) return "0.45rem";
|
||||
if (n <= 470) return "0.41rem";
|
||||
return "0.375rem";
|
||||
});
|
||||
const textLeading = $derived((def.text?.length ?? 0) > 280 ? "1.18" : "1.28");
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="card"
|
||||
class:selected
|
||||
class:attached
|
||||
class:glowing
|
||||
class:marked
|
||||
class:number={isNumber}
|
||||
{onclick}
|
||||
@@ -54,11 +73,18 @@
|
||||
{#if leftRail}<span class="rail left">{leftRail}</span>{/if}
|
||||
{#if rightRail}<span class="rail right">{rightRail}</span>{/if}
|
||||
<span class="name">{def.name}</span>
|
||||
{#if def.text}<span class="text">{def.text}</span>{/if}
|
||||
{#if def.text}<span class="text" style:font-size={textSize} style:line-height={textLeading}>{def.text}</span>{/if}
|
||||
{/if}
|
||||
{#if displayed}
|
||||
<span class="badge">DISPLAYED{charges != null ? ` · ${charges}` : ""}</span>
|
||||
{/if}
|
||||
{#if onfaq && def.faqRulings.length > 0}
|
||||
<span
|
||||
class="faq-seal" role="button" tabindex="-1"
|
||||
onclick={(ev) => { ev.stopPropagation(); onfaq?.(card.cardId); }}
|
||||
onkeydown={() => {}}
|
||||
>FAQ</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
@@ -138,13 +164,7 @@
|
||||
}
|
||||
.text {
|
||||
font-family: "Archivo Narrow", sans-serif;
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.28;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 9;
|
||||
line-clamp: 9;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* Number cards: a big numeral in a bordered plate, like the originals. */
|
||||
@@ -192,7 +212,31 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.faq-seal {
|
||||
position: absolute;
|
||||
bottom: 0.15rem;
|
||||
right: 0.2rem;
|
||||
background: #2b2218;
|
||||
color: #e8dfc6;
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.42rem;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.faq-seal:hover { opacity: 1; }
|
||||
.card.glowing {
|
||||
box-shadow: 0 0 0 2px #c9a72a, 0 0 14px 3px rgba(201, 167, 42, 0.55);
|
||||
animation: card-glow 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes card-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 2px #c9a72a, 0 0 14px 3px rgba(201, 167, 42, 0.55); }
|
||||
50% { box-shadow: 0 0 0 2px #c9a72a, 0 0 6px 1px rgba(201, 167, 42, 0.3); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card.glowing { animation: none; }
|
||||
.card { transition: none; }
|
||||
.card:hover, .card.selected, .card.attached { transform: none; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
|
||||
let { cardId, onclose }: { cardId: string; onclose: () => void } = $props();
|
||||
const def = $derived(cardDef(cardId));
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
|
||||
<div
|
||||
class="faq-book"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="official FAQ ruling"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<header class="faq-head">
|
||||
<span class="faq-title">{def.name} — Official FAQ</span>
|
||||
<button class="close" onclick={onclose} aria-label="close">×</button>
|
||||
</header>
|
||||
<div class="faq-body">
|
||||
{#each def.faqRulings as ruling, i (i)}
|
||||
<p>{ruling}</p>
|
||||
{/each}
|
||||
<p class="colophon">
|
||||
From Tom Jolly's official Wiz-War Rules FAQ (wizwar.com, September 2002).
|
||||
Rulings occasionally reference other editions; the cards overrule the rules,
|
||||
and the designer overrules the table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 12, 16, 0.72);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 60;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
.faq-book {
|
||||
background: #efe8d4;
|
||||
color: #43331f;
|
||||
width: min(34rem, 100%);
|
||||
max-height: calc(100vh - 4rem);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b3a687;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.faq-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 2px solid #43331f;
|
||||
}
|
||||
.faq-title {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.3rem;
|
||||
color: #6b5a41;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.close:hover { color: #b3372b; }
|
||||
.faq-body {
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem 1.2rem 1.1rem;
|
||||
font-family: "Archivo Narrow", sans-serif;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.faq-body p { margin: 0 0 0.6rem; }
|
||||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.8rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
// The flourish workshop: every effect sprite on a loop, labeled, against a
|
||||
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
|
||||
// file hot-reload here mid-animation. Not linked from anywhere; harmless.
|
||||
import { FX_SPRITES } from "./fx-sprites";
|
||||
import type { BoardFx } from "./fx";
|
||||
|
||||
let tick = $state(0);
|
||||
$effect(() => {
|
||||
const t = setInterval(() => (tick += 1), 2200);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
|
||||
// One representative of every kind, staged on a 3x3 (sectors on a 5x5).
|
||||
const mid = { x: 1, y: 1 };
|
||||
const SAMPLES: BoardFx[] = ([
|
||||
{ kind: "fireball", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
|
||||
{ kind: "waterbolt", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
|
||||
{ kind: "bolt", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } },
|
||||
{ kind: "burst", at: mid },
|
||||
{ kind: "splash", at: mid },
|
||||
{ kind: "shimmer", at: mid },
|
||||
{ kind: "shield", at: mid },
|
||||
{ kind: "sparkle", at: mid },
|
||||
{ kind: "whiff", at: mid },
|
||||
{ kind: "hit", at: mid },
|
||||
{ kind: "pow", at: mid },
|
||||
{ kind: "claw", at: mid },
|
||||
{ kind: "absorb", at: mid },
|
||||
{ kind: "portal", cell: mid, side: "E" },
|
||||
{ kind: "portal-cell", at: mid },
|
||||
{ kind: "streak", from: { x: 0, y: 2 }, to: { x: 2, y: 0 } },
|
||||
{ kind: "soul", at: { x: 1, y: 2 } },
|
||||
{ kind: "fireworks", at: mid },
|
||||
{ kind: "chaos-swirl", at: mid },
|
||||
{ kind: "pit-fall", at: mid },
|
||||
{ kind: "ooze-slip", at: mid },
|
||||
{ kind: "tacks-ow", at: mid },
|
||||
{ kind: "thorn-snap", at: mid },
|
||||
{ kind: "slime-stuck", at: mid },
|
||||
{ kind: "dust-puff", at: mid },
|
||||
{ kind: "edge-dust", cell: mid, side: "S" },
|
||||
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
|
||||
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
|
||||
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
|
||||
|
||||
const isSector = (k: string) => k.startsWith("sector-");
|
||||
</script>
|
||||
|
||||
<div class="gallery">
|
||||
<h1>The Flourish Workshop</h1>
|
||||
<p class="sub">
|
||||
Every effect replays each beat. Edit a file in <code>src/fx-sprites/</code>
|
||||
and it hot-reloads here.
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each SAMPLES as fx (fx.kind)}
|
||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
||||
<figure>
|
||||
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
|
||||
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
|
||||
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cx) => cx) as cx (cx)}
|
||||
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
|
||||
{/each}
|
||||
{/each}
|
||||
{#key tick}
|
||||
<g class="fx-layer"><Sprite fx={fx as never} /></g>
|
||||
{/key}
|
||||
</svg>
|
||||
<figcaption>{fx.kind}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gallery {
|
||||
min-height: 100vh;
|
||||
background: #171a20;
|
||||
color: #d8d2c0;
|
||||
font-family: "Archivo Narrow", system-ui, sans-serif;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: "Oswald", sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 1.2rem;
|
||||
color: #e9e1cb;
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
.sub { color: #8d8672; margin: 0 0 1.2rem; }
|
||||
.sub code { color: #c9a72a; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
background: #efe8d4;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
svg { width: 100%; display: block; }
|
||||
.cell {
|
||||
fill: #efe8d4;
|
||||
stroke: rgba(95, 74, 51, 0.25);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.fx-layer { pointer-events: none; }
|
||||
figcaption {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #43331f;
|
||||
text-align: center;
|
||||
padding-top: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { allCardDefs } from "@wizwar/engine";
|
||||
import Card from "./Card.svelte";
|
||||
import Faq from "./Faq.svelte";
|
||||
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
|
||||
import { FAQ_GENERAL } from "./faq-general";
|
||||
|
||||
let {
|
||||
onclose,
|
||||
@@ -17,6 +19,8 @@
|
||||
|
||||
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
|
||||
let tab = $state<"play" | "rules" | "cards" | "about" | "tally">(initialTab);
|
||||
let faqCardId = $state<string | null>(null);
|
||||
let libPeek = $state<string | null>(null);
|
||||
|
||||
function openTally() {
|
||||
tab = "tally";
|
||||
@@ -48,6 +52,18 @@
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
{#if libPeek}
|
||||
<div class="lib-peek-scrim" role="button" tabindex="-1" onclick={() => (libPeek = null)} onkeydown={() => {}}>
|
||||
<div class="lib-peek">
|
||||
<Card card={{ instanceId: `peek-${libPeek}`, cardId: libPeek }} onfaq={(id) => (faqCardId = id)} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if faqCardId}
|
||||
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
|
||||
{/if}
|
||||
|
||||
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
|
||||
<div
|
||||
class="booklet"
|
||||
@@ -81,9 +97,10 @@
|
||||
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd>
|
||||
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd>
|
||||
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd>
|
||||
<dt>{stats.longestGameCommands}</dt><dd>moves in the longest game yet played</dd>
|
||||
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
|
||||
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
|
||||
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
|
||||
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
|
||||
</dl>
|
||||
{#if stats.firstGameAt}
|
||||
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
|
||||
@@ -157,6 +174,16 @@
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">Official FAQ — general rulings</h2>
|
||||
<p class="colophon">
|
||||
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings
|
||||
occasionally reference other editions. Card-specific rulings sit on the
|
||||
cards themselves — look for the FAQ seal.
|
||||
</p>
|
||||
{#each FAQ_GENERAL as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="search-row">
|
||||
<input
|
||||
@@ -168,7 +195,11 @@
|
||||
<div class="card-grid">
|
||||
{#each filtered as def (def.id)}
|
||||
<div class="card-slot">
|
||||
<Card card={{ instanceId: `lib-${def.id}`, cardId: def.id }} />
|
||||
<Card
|
||||
card={{ instanceId: `lib-${def.id}`, cardId: def.id }}
|
||||
onclick={() => (libPeek = def.id)}
|
||||
onfaq={(id) => (faqCardId = id)}
|
||||
/>
|
||||
<span class="card-meta">
|
||||
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
|
||||
· {def.set === "basic" ? "base" : "exp 1"}
|
||||
@@ -254,6 +285,15 @@
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.faq-divider {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border-top: 3px double #43331f;
|
||||
padding-top: 0.8rem;
|
||||
margin: 1.6rem 0 0.2rem;
|
||||
}
|
||||
.booklet-body h3 {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.85rem;
|
||||
@@ -296,6 +336,20 @@
|
||||
.card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
|
||||
.card-slot :global(.card) { cursor: default; }
|
||||
.card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
|
||||
.lib-peek-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 12, 16, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 55;
|
||||
}
|
||||
.lib-peek :global(.card) {
|
||||
transform: scale(2.1);
|
||||
cursor: default;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.lib-peek :global(.card:hover) { transform: scale(2.1); }
|
||||
.card-meta {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.68rem;
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Board from "./Board.svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
steps,
|
||||
onclose,
|
||||
}: {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let idx = $state(0);
|
||||
let playing = $state(true);
|
||||
let speed = $state(1);
|
||||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||
const lines = $derived(
|
||||
step.events.map(humanize).filter((l): l is string => l !== null),
|
||||
);
|
||||
const atEnd = $derived(idx >= steps.length - 1);
|
||||
|
||||
/** Each step's spells flare on the reel exactly as they did at the table. */
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
$effect(() => {
|
||||
const step = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!step) return;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
|
||||
timers.push(setTimeout(() => {
|
||||
boardFx = [...boardFx, fx];
|
||||
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
|
||||
}, delay));
|
||||
}
|
||||
return () => { timers.forEach(clearTimeout); boardFx = []; };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!playing) return;
|
||||
const t = setInterval(() => {
|
||||
if (idx < steps.length - 1) idx += 1;
|
||||
else playing = false;
|
||||
}, 1500);
|
||||
}, 1500 / speed);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
|
||||
@@ -41,17 +58,20 @@
|
||||
<div class="replay-scrim">
|
||||
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
||||
<header class="replay-head">
|
||||
<span class="replay-title">While you were away</span>
|
||||
<span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||
</header>
|
||||
<div class="replay-board">
|
||||
<Board view={step.view} />
|
||||
<Board view={step.view} effects={boardFx} />
|
||||
</div>
|
||||
<div class="replay-caption">
|
||||
<strong>{step.actor}</strong>
|
||||
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
||||
{#if lines.length === 0}<div>…considers the maze.</div>{/if}
|
||||
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
||||
{#each step.chat ?? [] as c, i (i)}
|
||||
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="replay-controls">
|
||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||
@@ -59,6 +79,9 @@
|
||||
{playing ? "❚❚" : "▶"}
|
||||
</button>
|
||||
<button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move">▶</button>
|
||||
{#each [1, 2, 4] as x (x)}
|
||||
<button class="speed" class:current={speed === x} onclick={() => (speed = x)}>{x}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +141,7 @@
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.reel-talk { font-style: italic; opacity: 0.85; }
|
||||
.replay-caption {
|
||||
background: #efe8d4;
|
||||
color: #3a2f1f;
|
||||
@@ -129,6 +153,11 @@
|
||||
line-height: 1.45;
|
||||
min-height: 3.4rem;
|
||||
}
|
||||
.replay-controls .speed {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
|
||||
.replay-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Tom Jolly's official Rules FAQ (wizwar.com, September 2002): the
|
||||
// general-topic rulings, verbatim. Card-specific rulings live on the
|
||||
// cards themselves (cards.json faqRulings).
|
||||
|
||||
export const FAQ_GENERAL: { title: string; body: string[] }[] = [
|
||||
{
|
||||
title: "Attack-Neutral Combos",
|
||||
body: [
|
||||
"Some cards have mutiple spell designations in the corner (ATTACK, NEUTRAL, COUNTERACTION) with some combination of spell types shown. Since the rules stipulate that you can cast a COUNTERACTION out of turn or during your normal turn, the COUNTERACTION-NEUTRAL is redundant (and to be eliminated in later editions). All the cards with \"SELF\" marked on them were changed to \"LOS\" in later editions, primarily because you are obviously in LOS with yourself, and the card specifies that you cast the spell on yourself. These will be changed back to \"SELF\" in later additions, primarily due to the fact that it misleads people into believing that a \"SELF\" type spell can be cast on anyone. In the 8th edition, \"who\" the spell can be cast on will be very specifically defined. The cards that DO say ATTACK-NEUTRAL on them refer to the fact that they are a Neutral spell if cast on yourself and an Attack when cast on others.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Combat",
|
||||
body: [
|
||||
"You can't attack yourself. This is mentioned in the 6th edition rules, but no earlier editions. This is a very important rule! Also, the rule should read \"No ATTACKS can be used during the first round of turns\", not \"No COMBAT during the first round of turns\". It's perfectly okay to use a DESTROY WALL next to a person, thus damaging him on the first turn, since it is not an attack.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Creating Objects",
|
||||
body: [
|
||||
"More notes on creating objects on one's home base; the basic rules state that you may NOT create an object on a player's home base. This rule appears to get broken by such a card as TRADER, and some TELEPORTATIONS. This is alright as long as \"immobile objects\", such as Solid Stone or Thornbush, are not placed on the home base. The 8th edition clarifies this, since it limits what can and cannot be teleported on the cards. TRADER, for example, only works on \"Mobile Objects\".",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Death",
|
||||
body: [
|
||||
"The second-hand killing of a Wizard, say by falling walls or controlled MONSTERS, does not get you his cards, nor does a MONSTER get cards by killing a player!",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Dumping Cards and Physical Objects",
|
||||
body: [
|
||||
"This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Line of Sight",
|
||||
body: [
|
||||
"If you \"barely nick a corner\" of a wall, this is still NOT line-of-sight. A VISIONSTONE could see through the corner, but if you \"nicked two corners\", the VISIONSTONE would not work. The new Wiz-War set, 6th edition, has no dots in the center of the squares with which to measure line-of-sight, even though the rules say to use these non-existent dots. Our advice is to eyeball the center of the square, or put your own dots on the playing board. It might also be worthwhile to take a magic marker and draw the walls brown, as they do not stand out very well as it is.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lost Turns",
|
||||
body: [
|
||||
"These will accumulate if you get hit by a \"lost turns\" type spell while under the effect of another \"lost turns\" spell. Also, if you lose a turn while under a duration spell, this still uses up a turn of the duration spell. If you happen to get hit by a reflected attack (yours), and you \"lose a turn\", this would be THAT turn, not your next turn. This could happen with Lightning Bolt or Hypnosis, for example. As the rules say, if you Lose a Turn, you can't do anything but play Counteractions until your next turn. This also means you can't draw cards!",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Magic Sticks",
|
||||
body: [
|
||||
"These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Magic Stones",
|
||||
body: [
|
||||
"They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Monsters",
|
||||
body: [
|
||||
"These are creatures that do damage, generally. This means that ALTER EGO, SHADOW, FAMILIAR, and KILLER OOZE are considered MONSTERS, while the SLIME, ANIMATE OBJECT, DECOY and BUCK are not, even though they are mobile. Though some Monsters can carry objects, they cannot use them. You can't attack your own monsters. This is in line with the rule that you can't attack yourself, and prevents some rule conflicts. Monsters that can cast spells can cast \"SELF\" spells, too. A monster that changes itself into a different monster (like a Werewolf), assumes the attributes of that monster, as described on the card, but retains any other attributes not mentioned.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Picking Up Objects",
|
||||
body: [
|
||||
"No, you can't pick up 2 objects at the same time, even if they're in the same space.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Players as Objects",
|
||||
body: [
|
||||
"There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Spell Priority",
|
||||
body: [
|
||||
"There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Throwing Objects",
|
||||
body: [
|
||||
"The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Treasures",
|
||||
body: [
|
||||
"If a player is eliminated, any treasure left on his home base does NOT count toward eliminating the player to whom that treasure belongs. Also, the 7th edition rules are rather badly worded and imply that you can carry 2 treasures as long as one of them is your own and the other belongs to another player. This is incorrect. You can carry only ONE treasure, and it can be EITHER one of your own or one of the opponent's.",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"absorb"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="absorb"
|
||||
style={`transform-origin: ${c.x}px ${c.y}px`} />
|
||||
|
||||
<style>
|
||||
.absorb {
|
||||
fill: #f6f0df;
|
||||
stroke: #43331f;
|
||||
stroke-width: 1.5;
|
||||
animation: swallow 0.65s ease-in forwards;
|
||||
}
|
||||
@keyframes swallow {
|
||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"bolt"> } = $props();
|
||||
/** A fresh jagged path every strike. */
|
||||
const points = $derived.by(() => {
|
||||
const a = center(fx.from), b = center(fx.to);
|
||||
const segs = 6;
|
||||
const pts: string[] = [`${a.x},${a.y}`];
|
||||
for (let i = 1; i < segs; i++) {
|
||||
const t = i / segs;
|
||||
pts.push(`${(a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16).toFixed(1)},${(a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16).toFixed(1)}`);
|
||||
}
|
||||
pts.push(`${b.x},${b.y}`);
|
||||
return pts.join(" ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<polyline {points} class="bolt" />
|
||||
|
||||
<style>
|
||||
.bolt {
|
||||
fill: none;
|
||||
stroke: #ffe94d;
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
|
||||
animation: flicker 0.5s steps(2, jump-none) forwards;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
60% { opacity: 1; } 100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"burst"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="4" class="burst" />
|
||||
<circle cx={c.x} cy={c.y} r="4" class="burst late" />
|
||||
|
||||
<style>
|
||||
.burst {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #ff8c1a;
|
||||
stroke-width: 4;
|
||||
animation: ring 0.5s ease-out 0.05s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.burst.late { stroke: #ffd27a; animation-delay: 0.16s; }
|
||||
@keyframes ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"chaos-swirl"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<circle cx={c.x} cy={c.y} r="30" />
|
||||
<circle cx={c.x} cy={c.y} r="55" class="mid" />
|
||||
<circle cx={c.x} cy={c.y} r="80" class="outer" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.chaos circle {
|
||||
fill: none;
|
||||
stroke: #b48ae0;
|
||||
stroke-width: 4;
|
||||
stroke-dasharray: 30 22;
|
||||
}
|
||||
.chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
|
||||
.chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
|
||||
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
|
||||
@keyframes chaos-spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
|
||||
20% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"claw"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="claw">
|
||||
<line x1={c.x - 10} y1={c.y - 14} x2={c.x - 2} y2={c.y + 12} />
|
||||
<line x1={c.x - 2} y1={c.y - 16} x2={c.x + 6} y2={c.y + 10} />
|
||||
<line x1={c.x + 6} y1={c.y - 14} x2={c.x + 14} y2={c.y + 12} />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.claw line {
|
||||
stroke: #b3372b;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.claw { animation: rake 0.55s ease-out forwards; }
|
||||
@keyframes rake {
|
||||
0% { opacity: 0; transform: translateY(-6px); }
|
||||
20% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { CELL, center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
|
||||
const c = $derived.by(() => {
|
||||
if (fx.kind === "dust-puff") return center(fx.at);
|
||||
const mid = center(fx.cell);
|
||||
if (fx.side === "N") return { x: mid.x, y: fx.cell.y * CELL };
|
||||
if (fx.side === "S") return { x: mid.x, y: (fx.cell.y + 1) * CELL };
|
||||
if (fx.side === "W") return { x: fx.cell.x * CELL, y: mid.y };
|
||||
return { x: (fx.cell.x + 1) * CELL, y: mid.y };
|
||||
});
|
||||
</script>
|
||||
|
||||
<circle cx={c.x - 6} cy={c.y} r="4" class="dust" />
|
||||
<circle cx={c.x + 5} cy={c.y - 3} r="3" class="dust late" />
|
||||
<circle cx={c.x} cy={c.y + 4} r="3.5" class="dust later" />
|
||||
|
||||
<style>
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
|
||||
.dust.late { animation-delay: 0.09s; }
|
||||
.dust.later { animation-delay: 0.17s; }
|
||||
@keyframes drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"fireball"> } = $props();
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
</script>
|
||||
|
||||
<circle r="7" class="fireball" cx="0" cy="0">
|
||||
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
|
||||
</circle>
|
||||
|
||||
<style>
|
||||
.fireball {
|
||||
fill: #ff8c1a;
|
||||
stroke: #ffd27a;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 120, 20, 0.9));
|
||||
animation: fade 0.55s ease-in forwards;
|
||||
}
|
||||
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"fireworks"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
const COLORS = ["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"];
|
||||
</script>
|
||||
|
||||
<g class="fireworks" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
{#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
|
||||
<circle
|
||||
cx={c.x + 18 * Math.cos((deg * Math.PI) / 180)}
|
||||
cy={c.y + 18 * Math.sin((deg * Math.PI) / 180)}
|
||||
r="3" fill={COLORS[(deg / 45) % 4]}
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.fireworks { animation: boom 1.1s ease-out forwards; }
|
||||
@keyframes boom {
|
||||
0% { opacity: 0; transform: scale(0.1); }
|
||||
25% { opacity: 1; }
|
||||
100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"hit"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="10" class="hit" />
|
||||
|
||||
<style>
|
||||
.hit {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #c0392b;
|
||||
stroke-width: 3.5;
|
||||
animation: ring-small 0.45s ease-out forwards;
|
||||
}
|
||||
@keyframes ring-small {
|
||||
0% { opacity: 0.9; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(2.6); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="ooze" />
|
||||
|
||||
<style>
|
||||
.ooze {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(110, 160, 60, 0.55);
|
||||
animation: wobble 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes wobble {
|
||||
0% { opacity: 0.9; transform: scaleX(0.6); }
|
||||
35% { transform: scaleX(1.25); }
|
||||
65% { transform: scaleX(0.9); }
|
||||
100% { opacity: 0; transform: scaleX(1.1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="10" class="pitfall" />
|
||||
<circle cx={c.x - 9} cy={c.y - 4} r="3" class="dust" />
|
||||
<circle cx={c.x + 9} cy={c.y - 4} r="3" class="dust late" />
|
||||
|
||||
<style>
|
||||
.pitfall {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(30, 24, 16, 0.8);
|
||||
animation: swallow 0.7s ease-in forwards;
|
||||
}
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
|
||||
.dust.late { animation-delay: 0.09s; }
|
||||
@keyframes swallow {
|
||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { CELL } from "./geom";
|
||||
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
|
||||
</script>
|
||||
|
||||
{#if fx.kind === "portal"}
|
||||
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
|
||||
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
|
||||
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
|
||||
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
|
||||
<line {x1} {y1} {x2} {y2} class="portal glow" />
|
||||
<line {x1} {y1} {x2} {y2} class="portal" />
|
||||
{:else}
|
||||
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
|
||||
width={CELL - 6} height={CELL - 6} rx="6" class="veil" />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.portal {
|
||||
stroke: #9be3e0;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 6 5;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
|
||||
animation: shimmer 0.95s ease-in-out forwards;
|
||||
}
|
||||
.portal.glow {
|
||||
stroke: rgba(180, 138, 224, 0.5);
|
||||
stroke-width: 9;
|
||||
stroke-dasharray: none;
|
||||
filter: blur(2px);
|
||||
animation: breathe 0.95s ease-in-out forwards;
|
||||
}
|
||||
.veil {
|
||||
fill: rgba(155, 227, 224, 0.12);
|
||||
stroke: #9be3e0;
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 7 5;
|
||||
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
|
||||
animation: shimmer 0.95s ease-in-out forwards;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { opacity: 0; stroke-dashoffset: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 0.85; }
|
||||
100% { opacity: 0; stroke-dashoffset: 34; }
|
||||
}
|
||||
@keyframes breathe {
|
||||
0% { opacity: 0; }
|
||||
30% { opacity: 0.8; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"pow"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<path d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`} />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.pow path {
|
||||
fill: #ffe94d;
|
||||
stroke: #b3372b;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.pow { animation: pow-hit 0.5s ease-out forwards; }
|
||||
@keyframes pow-hit {
|
||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||
55% { transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { CELL, SECTOR } from "./geom";
|
||||
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
|
||||
</script>
|
||||
|
||||
{#if fx.kind === "sector-spin"}
|
||||
{@const ox = fx.origin.x * CELL}
|
||||
{@const oy = fx.origin.y * CELL}
|
||||
<rect x={ox + 2} y={oy + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
||||
class={`spin ${fx.clockwise ? "cw" : "ccw"}`}
|
||||
style={`transform-origin: ${ox + (SECTOR * CELL) / 2}px ${oy + (SECTOR * CELL) / 2}px`} />
|
||||
{:else}
|
||||
{@const fxp = fx.from.x * CELL}
|
||||
{@const fyp = fx.from.y * CELL}
|
||||
<rect x={fxp + 2} y={fyp + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
||||
class="slide"
|
||||
style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spin, .slide {
|
||||
fill: rgba(233, 225, 203, 0.25);
|
||||
stroke: #d3852b;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 12 7;
|
||||
}
|
||||
.spin { animation: grind-cw 1.2s ease-in-out forwards; }
|
||||
.spin.ccw { animation-name: grind-ccw; }
|
||||
.slide { animation: slide-home 1.2s ease-in-out forwards; }
|
||||
@keyframes grind-cw {
|
||||
0% { opacity: 0; transform: rotate(-90deg); }
|
||||
15% { opacity: 1; }
|
||||
85% { opacity: 1; transform: rotate(0deg); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes grind-ccw {
|
||||
0% { opacity: 0; transform: rotate(90deg); }
|
||||
15% { opacity: 1; }
|
||||
85% { opacity: 1; transform: rotate(0deg); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes slide-home {
|
||||
0% { opacity: 0; transform: translate(0, 0); }
|
||||
15% { opacity: 1; }
|
||||
85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
|
||||
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"shield"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="16" class="shield" />
|
||||
|
||||
<style>
|
||||
.shield {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 3.5;
|
||||
filter: drop-shadow(0 0 6px rgba(201, 167, 42, 0.8));
|
||||
animation: pulse 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0; transform: scale(0.6); }
|
||||
30% { opacity: 1; transform: scale(1.1); }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { opacity: 0; transform: scale(1.15); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"shimmer"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="6" class="shimmer" />
|
||||
<circle cx={c.x} cy={c.y} r="6" class="shimmer late" />
|
||||
|
||||
<style>
|
||||
.shimmer {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #b48ae0;
|
||||
stroke-width: 2.5;
|
||||
animation: ring 0.6s ease-out forwards;
|
||||
}
|
||||
.shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
|
||||
@keyframes ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"slime-stuck"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="12" class="slime" />
|
||||
|
||||
<style>
|
||||
.slime {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(140, 200, 60, 0.5);
|
||||
stroke: #7a9e2e;
|
||||
stroke-width: 3;
|
||||
animation: sink 0.9s ease-out forwards;
|
||||
}
|
||||
@keyframes sink {
|
||||
0% { opacity: 0.9; transform: scale(1.3); }
|
||||
100% { opacity: 0; transform: scale(0.7); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"soul"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="soul">
|
||||
<circle cx={c.x} cy={c.y - 4} r="8" />
|
||||
<circle cx={c.x - 5} cy={c.y + 3} r="4" />
|
||||
<circle cx={c.x + 5} cy={c.y + 3} r="4" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.soul circle {
|
||||
fill: rgba(233, 228, 245, 0.8);
|
||||
stroke: rgba(160, 150, 200, 0.6);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.soul { animation: ascend 1.3s ease-out forwards; }
|
||||
@keyframes ascend {
|
||||
0% { opacity: 0; transform: translateY(0); }
|
||||
25% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translateY(-34px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"sparkle"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="sparkle" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.sparkle path {
|
||||
fill: #e8dfc6;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 1;
|
||||
animation: fade 0.8s ease-in forwards;
|
||||
}
|
||||
.sparkle { animation: spin 0.8s linear forwards; }
|
||||
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@keyframes spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||
30% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"splash"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="4" class="splash" />
|
||||
<circle cx={c.x - 10} cy={c.y - 6} r="2.5" class="droplet" />
|
||||
<circle cx={c.x + 9} cy={c.y - 8} r="2" class="droplet late" />
|
||||
<circle cx={c.x + 3} cy={c.y - 12} r="1.8" class="droplet later" />
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #4fa3e3;
|
||||
stroke-width: 3.5;
|
||||
animation: ring 0.55s ease-out forwards;
|
||||
}
|
||||
.droplet { fill: #7cc0ee; animation: drop 0.6s ease-out forwards; }
|
||||
.droplet.late { animation-delay: 0.08s; }
|
||||
.droplet.later { animation-delay: 0.15s; }
|
||||
@keyframes ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
@keyframes drop {
|
||||
0% { opacity: 0.9; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-14px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"streak"> } = $props();
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
</script>
|
||||
|
||||
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="streak" />
|
||||
|
||||
<style>
|
||||
.streak {
|
||||
stroke: rgba(233, 225, 203, 0.85);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 14 9;
|
||||
animation: streak-fade 0.55s ease-out forwards;
|
||||
}
|
||||
@keyframes streak-fade {
|
||||
0% { opacity: 0.9; stroke-dashoffset: 46; }
|
||||
100% { opacity: 0; stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"tacks-ow"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="tacks">
|
||||
<text x={c.x - 10} y={c.y - 4}>✱</text>
|
||||
<text x={c.x + 4} y={c.y - 10} class="late">✱</text>
|
||||
<text x={c.x - 2} y={c.y + 6} class="later">✱</text>
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.tacks text {
|
||||
font-size: 13px;
|
||||
fill: #b3372b;
|
||||
animation: hop 0.6s ease-out forwards;
|
||||
}
|
||||
.tacks .late { animation-delay: 0.1s; opacity: 0; }
|
||||
.tacks .later { animation-delay: 0.2s; opacity: 0; }
|
||||
@keyframes hop {
|
||||
0% { opacity: 0; transform: translateY(2px); }
|
||||
30% { opacity: 1; transform: translateY(-5px); }
|
||||
60% { transform: translateY(-1px); }
|
||||
100% { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"thorn-snap"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<path class="thorn"
|
||||
d={`M ${c.x - 12} ${c.y} l 6 -4 2 -8 4 6 8 -4 -3 8 7 5 -9 1 -2 9 -5 -7 -8 2 z`} />
|
||||
|
||||
<style>
|
||||
.thorn {
|
||||
fill: rgba(70, 120, 50, 0.7);
|
||||
stroke: #2f5423;
|
||||
stroke-width: 1.5;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: snap 0.55s ease-out forwards;
|
||||
}
|
||||
@keyframes snap {
|
||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||
55% { transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
</script>
|
||||
|
||||
<circle r="6" class="waterbolt" cx="0" cy="0">
|
||||
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
|
||||
</circle>
|
||||
|
||||
<style>
|
||||
.waterbolt {
|
||||
fill: #3b8dd6;
|
||||
stroke: #b9e2ff;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 5px rgba(80, 160, 230, 0.9));
|
||||
animation: fade 0.55s ease-in forwards;
|
||||
}
|
||||
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"whiff"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="8" class="whiff" />
|
||||
|
||||
<style>
|
||||
.whiff {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(180, 180, 180, 0.4);
|
||||
animation: drift 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
// Shared geometry for effect sprites. CELL must match Board.svelte's grid.
|
||||
export const CELL = 48;
|
||||
export const SECTOR = 5;
|
||||
export const center = (c: { x: number; y: number }) => ({
|
||||
x: c.x * CELL + CELL / 2,
|
||||
y: c.y * CELL + CELL / 2,
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// One sprite component per effect. To change an effect's look, edit its
|
||||
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
|
||||
// register it here.
|
||||
import type { Component } from "svelte";
|
||||
import type { BoardFx } from "../fx";
|
||||
import Absorb from "./Absorb.svelte";
|
||||
import Bolt from "./Bolt.svelte";
|
||||
import Burst from "./Burst.svelte";
|
||||
import ChaosSwirl from "./ChaosSwirl.svelte";
|
||||
import Claw from "./Claw.svelte";
|
||||
import DustPuff from "./DustPuff.svelte";
|
||||
import Fireball from "./Fireball.svelte";
|
||||
import Fireworks from "./Fireworks.svelte";
|
||||
import Hit from "./Hit.svelte";
|
||||
import OozeSlip from "./OozeSlip.svelte";
|
||||
import PitFall from "./PitFall.svelte";
|
||||
import Portal from "./Portal.svelte";
|
||||
import Pow from "./Pow.svelte";
|
||||
import SectorGrind from "./SectorGrind.svelte";
|
||||
import Shield from "./Shield.svelte";
|
||||
import Shimmer from "./Shimmer.svelte";
|
||||
import SlimeStuck from "./SlimeStuck.svelte";
|
||||
import Soul from "./Soul.svelte";
|
||||
import Sparkle from "./Sparkle.svelte";
|
||||
import Splash from "./Splash.svelte";
|
||||
import Streak from "./Streak.svelte";
|
||||
import TacksOw from "./TacksOw.svelte";
|
||||
import ThornSnap from "./ThornSnap.svelte";
|
||||
import Waterbolt from "./Waterbolt.svelte";
|
||||
import Whiff from "./Whiff.svelte";
|
||||
|
||||
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
||||
fireball: Fireball,
|
||||
waterbolt: Waterbolt,
|
||||
bolt: Bolt,
|
||||
burst: Burst,
|
||||
splash: Splash,
|
||||
shimmer: Shimmer,
|
||||
shield: Shield,
|
||||
sparkle: Sparkle,
|
||||
whiff: Whiff,
|
||||
hit: Hit,
|
||||
pow: Pow,
|
||||
claw: Claw,
|
||||
absorb: Absorb,
|
||||
portal: Portal,
|
||||
"portal-cell": Portal,
|
||||
streak: Streak,
|
||||
soul: Soul,
|
||||
fireworks: Fireworks,
|
||||
"chaos-swirl": ChaosSwirl,
|
||||
"pit-fall": PitFall,
|
||||
"ooze-slip": OozeSlip,
|
||||
"tacks-ow": TacksOw,
|
||||
"thorn-snap": ThornSnap,
|
||||
"slime-stuck": SlimeStuck,
|
||||
"dust-puff": DustPuff,
|
||||
"edge-dust": DustPuff,
|
||||
"sector-spin": SectorGrind,
|
||||
"sector-slide": SectorGrind,
|
||||
} as never;
|
||||
@@ -0,0 +1,238 @@
|
||||
// Whimsical flourishes: game events become short-lived board effects.
|
||||
// Purely cosmetic — nothing here touches game state, and reduced-motion
|
||||
// hides the whole layer.
|
||||
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
type Cell = { x: number; y: number };
|
||||
type Side = "N" | "E" | "S" | "W";
|
||||
|
||||
type FxShape =
|
||||
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
|
||||
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
|
||||
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
|
||||
| "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell }
|
||||
| { kind: "portal"; cell: Cell; side: Side }
|
||||
| { kind: "sector-spin"; origin: Cell; clockwise: boolean }
|
||||
| { kind: "sector-slide"; from: Cell; to: Cell }
|
||||
| { kind: "edge-dust"; cell: Cell; side: Side };
|
||||
export type BoardFx = FxShape & { id: number };
|
||||
/** The narrow type of one effect kind (for sprite components). */
|
||||
export type FxOf<K extends BoardFx["kind"]> = BoardFx & { kind: K };
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
/** How long each kind stays mounted (animation length + a little grace). */
|
||||
export function fxTtl(kind: BoardFx["kind"]): number {
|
||||
switch (kind) {
|
||||
case "fireball": case "waterbolt": return 700;
|
||||
case "bolt": case "streak": return 600;
|
||||
case "portal": case "portal-cell":
|
||||
case "soul": case "fireworks": case "chaos-swirl":
|
||||
case "sector-spin": case "sector-slide": return 1500;
|
||||
default: return 900;
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECTILES: Record<string, "fireball" | "bolt" | "waterbolt"> = {
|
||||
fireball: "fireball",
|
||||
"sudden-death": "fireball",
|
||||
"blaster-wand": "fireball",
|
||||
"lightning-blast": "bolt",
|
||||
"power-drain": "bolt",
|
||||
waterbolt: "waterbolt",
|
||||
};
|
||||
|
||||
/** Map one command's events to effects, each with a start delay in ms. */
|
||||
export function fxForEvents(
|
||||
events: GameEvent[], view: GameView,
|
||||
): { fx: BoardFx; delay: number }[] {
|
||||
const out: { fx: BoardFx; delay: number }[] = [];
|
||||
const posOf = (playerId: string | null): Cell | null => {
|
||||
if (!playerId) return null;
|
||||
const p = view.players.find((p) => p.id === playerId);
|
||||
return p ? { ...p.position } : null;
|
||||
};
|
||||
let beat = 0; // successive visuals from one command stagger slightly
|
||||
const push = (fx: FxShape, extraDelay = 0) => {
|
||||
out.push({ fx: { ...fx, id: nextId++ }, delay: beat * 220 + extraDelay });
|
||||
};
|
||||
|
||||
for (const e of events) {
|
||||
switch (e.type) {
|
||||
case "spellCast": {
|
||||
if (e.cardId === "chaos") {
|
||||
push({ kind: "chaos-swirl", at: { x: (view.board.width - 1) / 2, y: (view.board.height - 1) / 2 } });
|
||||
beat++;
|
||||
break;
|
||||
}
|
||||
const to = e.targetCell ?? posOf(e.target);
|
||||
const projectile = PROJECTILES[e.cardId];
|
||||
if (projectile && to) {
|
||||
push({ kind: projectile, from: e.from, to });
|
||||
if (projectile === "fireball") push({ kind: "burst", at: to }, 380);
|
||||
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
|
||||
beat++;
|
||||
} else if (to && e.target) {
|
||||
push({ kind: "sparkle", at: to });
|
||||
beat++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "punched":
|
||||
push({ kind: "pow", at: e.at });
|
||||
beat++;
|
||||
break;
|
||||
case "creatureAttacked": {
|
||||
// The target may be a wizard or a fellow creature.
|
||||
const at = posOf(typeof e.target === "string" ? e.target : null) ??
|
||||
(() => {
|
||||
const c = view.creatures.find((c) => c.id === e.target);
|
||||
return c ? { ...c.position } : null;
|
||||
})();
|
||||
if (at) { push({ kind: "claw", at }); beat++; }
|
||||
break;
|
||||
}
|
||||
case "creatureTouched": {
|
||||
const at = posOf(e.player);
|
||||
if (at) { push({ kind: "claw", at }); beat++; }
|
||||
break;
|
||||
}
|
||||
case "attackAbsorbedIntoHand": {
|
||||
const at = posOf(e.player);
|
||||
if (at) { push({ kind: "absorb", at }); beat++; }
|
||||
break;
|
||||
}
|
||||
case "damaged": {
|
||||
const at = posOf(e.player);
|
||||
if (at) { push({ kind: "hit", at }); beat++; }
|
||||
break;
|
||||
}
|
||||
case "attackMissed": {
|
||||
const at = posOf(e.defender);
|
||||
if (at) { push({ kind: "whiff", at }); beat++; }
|
||||
break;
|
||||
}
|
||||
case "attackResolved": {
|
||||
const back = posOf(e.attacker);
|
||||
const stand = posOf(e.defender);
|
||||
if ((e.redirected || e.reflectedDamage > 0) && back && stand) {
|
||||
// The spell turns in the air and goes home.
|
||||
const kind = (e.attackCardId && PROJECTILES[e.attackCardId]) || "bolt";
|
||||
push({ kind, from: stand, to: back });
|
||||
push({ kind: "hit", at: back }, 380);
|
||||
beat++;
|
||||
} else if (e.fullyStopped && stand) {
|
||||
push({ kind: "shield", at: stand });
|
||||
beat++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "teleported":
|
||||
push({ kind: "shimmer", at: e.from });
|
||||
push({ kind: "shimmer", at: e.to }, 200);
|
||||
beat++;
|
||||
break;
|
||||
case "moved":
|
||||
if (e.via === "warp") {
|
||||
// The mouths are edges: shimmer the opening lines on both boards.
|
||||
const w = view.board.warps.find((w) =>
|
||||
w.from.cell.x === e.from.x && w.from.cell.y === e.from.y && w.from.side === e.direction);
|
||||
if (w) {
|
||||
push({ kind: "portal", cell: w.from.cell, side: w.from.side });
|
||||
push({ kind: "portal", cell: w.to.cell, side: w.to.side }, 150);
|
||||
} else {
|
||||
push({ kind: "portal-cell", at: e.from });
|
||||
push({ kind: "portal-cell", at: e.to }, 150);
|
||||
}
|
||||
beat++;
|
||||
}
|
||||
break;
|
||||
case "warpStepped":
|
||||
// Dimensional warp tokens fill their squares; the veil wraps them.
|
||||
push({ kind: "portal-cell", at: e.from });
|
||||
push({ kind: "portal-cell", at: e.to }, 150);
|
||||
beat++;
|
||||
break;
|
||||
case "wallCreated":
|
||||
case "wallDestroyed":
|
||||
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
|
||||
beat++;
|
||||
break;
|
||||
case "died": {
|
||||
const at = posOf(e.player);
|
||||
if (at) { push({ kind: "soul", at }, 250); beat++; }
|
||||
break;
|
||||
}
|
||||
case "gameWon": {
|
||||
const home = view.players.find((p) => p.id === e.player)?.home;
|
||||
if (home) {
|
||||
push({ kind: "fireworks", at: home });
|
||||
push({ kind: "fireworks", at: home }, 350);
|
||||
push({ kind: "fireworks", at: home }, 700);
|
||||
beat++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "knockedBack":
|
||||
case "shoved":
|
||||
push({ kind: "streak", from: e.from, to: e.to });
|
||||
beat++;
|
||||
break;
|
||||
case "objectDragged":
|
||||
push({ kind: "streak", from: e.from, to: e.to });
|
||||
beat++;
|
||||
break;
|
||||
case "jumpedPit":
|
||||
push({ kind: "streak", from: e.from, to: e.to });
|
||||
beat++;
|
||||
break;
|
||||
case "fellInPit":
|
||||
push({ kind: "pit-fall", at: e.at });
|
||||
beat++;
|
||||
break;
|
||||
case "climbedFromPit": {
|
||||
if (e.success) {
|
||||
const at = posOf(e.player);
|
||||
if (at) { push({ kind: "dust-puff", at }); beat++; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "slippedInOoze":
|
||||
push({ kind: "ooze-slip", at: e.at });
|
||||
beat++;
|
||||
break;
|
||||
case "steppedOnTacks":
|
||||
push({ kind: "tacks-ow", at: e.at });
|
||||
beat++;
|
||||
break;
|
||||
case "enteredThornbush":
|
||||
push({ kind: "thorn-snap", at: e.at });
|
||||
beat++;
|
||||
break;
|
||||
case "stuckInSlime":
|
||||
push({ kind: "slime-stuck", at: e.at });
|
||||
beat++;
|
||||
break;
|
||||
case "sectorRotated": {
|
||||
const origin = view.board.placements[e.sectorIndex]?.origin;
|
||||
if (origin) push({ kind: "sector-spin", origin: { ...origin }, clockwise: e.clockwise });
|
||||
// Everything after the grind points at pre-move ground; stop here.
|
||||
return out;
|
||||
}
|
||||
case "sectorRelocated": {
|
||||
// The event records pre-normalization origins; the view holds the
|
||||
// truth. Shift the recorded start by the same correction.
|
||||
const trueTo = view.board.placements[e.sectorIndex]?.origin;
|
||||
if (trueTo) {
|
||||
const dx = trueTo.x - e.to.x, dy = trueTo.y - e.to.y;
|
||||
push({ kind: "sector-slide", from: { x: e.from.x + dx, y: e.from.y + dy }, to: { ...trueTo } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type GameState,
|
||||
type GameView,
|
||||
type PlayerId,
|
||||
type GameEvent,
|
||||
redactEvent,
|
||||
} from "@wizwar/engine";
|
||||
import { humanize, net } from "./net.svelte";
|
||||
|
||||
@@ -46,6 +48,12 @@ class LocalGame {
|
||||
/** Set while the device should be handed to the named player. */
|
||||
handoffTo = $state<PlayerId | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
/** The finished game as a reel, each step from its actor's own seat. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate command results. */
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
||||
view = $derived(
|
||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||
);
|
||||
@@ -56,6 +64,33 @@ class LocalGame {
|
||||
private activeMs = 0;
|
||||
private lastMoveAt = 0;
|
||||
|
||||
/** The tabletop D4 for house calls: chronicle only, no game state. */
|
||||
rollTableDie(): void {
|
||||
if (!this.viewerId) return;
|
||||
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
|
||||
this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`];
|
||||
}
|
||||
|
||||
/** Rebuild the whole game as replay steps (finished games only). */
|
||||
buildReplay(): void {
|
||||
if (!this.config || this.gameState?.phase !== "finished") return;
|
||||
const { state } = createGame(this.config);
|
||||
let current = state;
|
||||
const steps: { seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] = [];
|
||||
this.commands.forEach((c, i) => {
|
||||
const r = applyCommand(current, c.playerId, c.command);
|
||||
if (!r.ok) return;
|
||||
current = r.state;
|
||||
steps.push({
|
||||
seq: i,
|
||||
actor: c.playerId,
|
||||
events: r.events.map((e) => redactEvent(e, c.playerId)).filter((e): e is GameEvent => e !== null),
|
||||
view: viewFor(current, c.playerId),
|
||||
});
|
||||
});
|
||||
this.replaySteps = steps;
|
||||
}
|
||||
|
||||
hasSave(): boolean {
|
||||
return localStorage.getItem(SAVE_KEY) !== null;
|
||||
}
|
||||
@@ -101,9 +136,14 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 2,
|
||||
deckRev: 14,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
for (const e of events) {
|
||||
if (e.type === "gameStarted") {
|
||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||
}
|
||||
}
|
||||
this.config = config;
|
||||
this.commands = [];
|
||||
this.gameState = state;
|
||||
@@ -118,6 +158,9 @@ class LocalGame {
|
||||
this.tallyId = crypto.randomUUID();
|
||||
this.activeMs = 0;
|
||||
this.lastMoveAt = Date.now();
|
||||
// iOS evicts unprotected origin storage under pressure; ask for durability
|
||||
// so a saved hotseat game survives more than a forced reload.
|
||||
navigator.storage?.persist?.().catch(() => {});
|
||||
net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length });
|
||||
this.persist();
|
||||
return null;
|
||||
@@ -152,6 +195,7 @@ class LocalGame {
|
||||
this.gameState = current;
|
||||
this.active = true;
|
||||
this.viewerId = null;
|
||||
navigator.storage?.persist?.().catch(() => {});
|
||||
this.handoffTo = actorId(current);
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -178,6 +222,7 @@ class LocalGame {
|
||||
this.log = [...this.log, `— ${result.error} —`];
|
||||
return;
|
||||
}
|
||||
this.onFx?.(result.events);
|
||||
this.gameState = result.state;
|
||||
this.commands = [...this.commands, { playerId: this.viewerId, command }];
|
||||
const now = Date.now();
|
||||
|
||||
+122
-16
@@ -5,13 +5,32 @@ import { cardDef } from "@wizwar/engine";
|
||||
|
||||
const SERVER_URL =
|
||||
import.meta.env.VITE_WIZWAR_SERVER ??
|
||||
(location.hostname === "localhost"
|
||||
? "ws://localhost:8787"
|
||||
(location.port === "5173" // the vite dev server; anything else serves its own socket
|
||||
? `ws://${location.hostname}:8787`
|
||||
: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
||||
|
||||
/** Sustained-effect ids that are engine constructs, not printed cards. */
|
||||
const SYNTHETIC_SPELL_NAMES: Record<string, string> = { "sticky-web": "Sticky Wand webs" };
|
||||
|
||||
/** Card name lookup that survives synthetic ids — render code must never throw. */
|
||||
export function spellName(cardId: string): string {
|
||||
const synthetic = SYNTHETIC_SPELL_NAMES[cardId];
|
||||
if (synthetic) return synthetic;
|
||||
try {
|
||||
return cardDef(cardId).name;
|
||||
} catch {
|
||||
return cardId;
|
||||
}
|
||||
}
|
||||
|
||||
export function humanize(e: GameEvent): string | null {
|
||||
switch (e.type) {
|
||||
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
|
||||
case "gameStarted": {
|
||||
const rolloff = e.players
|
||||
.map((p) => `${p} 🎲 ${(e.dieRolls[p] ?? []).join(", ")}`)
|
||||
.join(" · ");
|
||||
return `Game started. The roll-off: ${rolloff} — ${e.firstPlayer} goes first.`;
|
||||
}
|
||||
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
|
||||
case "turnSkipped": return `${e.player} loses a turn.`;
|
||||
case "extraTurnStarted": return `${e.player} takes an extra turn!`;
|
||||
@@ -43,8 +62,8 @@ export function humanize(e: GameEvent): string | null {
|
||||
: `${e.defender} is too small to hit — the attack misses!`;
|
||||
case "damageImmune": return `${e.player} is stone — the damage has no effect.`;
|
||||
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
||||
case "spellSustained": return `${cardDef(e.cardId).name} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||
case "spellExpired": return `${cardDef(e.cardId).name} wears off ${e.target}.`;
|
||||
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
|
||||
case "teleported": return e.by === e.player
|
||||
? `${e.player} teleports across the maze!`
|
||||
: `${e.player} is teleported away by ${e.by}!`;
|
||||
@@ -74,9 +93,9 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
|
||||
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
|
||||
case "creatureMoved": return null;
|
||||
case "creatureAttacked": return e.dieRoll != null ? `The troll swings (rolled ${e.dieRoll})!` : `The creature strikes!`;
|
||||
case "creatureTouched": return `The creature falls upon ${e.player}!`;
|
||||
case "creatureDamaged": return e.amount > 0 ? `The ${e.creatureId} takes ${e.amount} damage.` : `The attack has no effect on it.`;
|
||||
case "creatureAttacked": return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
|
||||
case "creatureTouched": return `The ${spellName(e.kind)} falls upon ${e.player}!`;
|
||||
case "creatureDamaged": return e.amount > 0 ? `The ${spellName(e.kind)} takes ${e.amount} damage.` : `The attack has no effect on the ${spellName(e.kind)}.`;
|
||||
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
|
||||
case "trollRegenerated": return `The troll's stony hide knits itself back together.`;
|
||||
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
|
||||
@@ -129,13 +148,24 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
|
||||
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
|
||||
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
|
||||
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
|
||||
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
|
||||
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
|
||||
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} — ${e.purpose}.`;
|
||||
case "pushed": return e.player
|
||||
? `${e.by} shoves ${e.player} down the corridor!`
|
||||
: `${e.by} shoves the beast down the corridor!`;
|
||||
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime — it sticks, waiting.`;
|
||||
case "slimeTrapSprung": return `The slime releases its ${spellName(e.cardId)} at ${e.victim}!`;
|
||||
case "slimeWashed": return `The wave washes the slime away.`;
|
||||
case "wallDamaged": {
|
||||
const what = e.needed === 15 ? "door" : "wall";
|
||||
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name} — ${e.total}/${e.needed}.`;
|
||||
}
|
||||
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
|
||||
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
|
||||
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`;
|
||||
case "cardsDiscarded":
|
||||
return `${e.player} discards ${e.cards.map((c) => spellName(c.cardId)).join(", ")}.`;
|
||||
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
|
||||
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
|
||||
case "turnEnded": return null;
|
||||
@@ -146,6 +176,12 @@ export function humanize(e: GameEvent): string | null {
|
||||
|
||||
const SEAT_KEY = "wizwar-seat";
|
||||
const SEATS_KEY = "wizwar-seats";
|
||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||
|
||||
function loadChatSeen(): Record<string, number> {
|
||||
try { return JSON.parse(localStorage.getItem(CHAT_SEEN_KEY) ?? "{}"); }
|
||||
catch { return {}; }
|
||||
}
|
||||
const SEEN_KEY = "wizwar-seen";
|
||||
|
||||
function loadSeen(): Record<string, number> {
|
||||
@@ -166,6 +202,7 @@ export interface GameSummary {
|
||||
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
|
||||
round: number | null;
|
||||
lastMoveAt: string | null;
|
||||
chatCount: number;
|
||||
}
|
||||
|
||||
export function attentionLabel(a: GameSummary["attention"]): string {
|
||||
@@ -193,6 +230,7 @@ class Net {
|
||||
started = $state(false);
|
||||
/** Lobby standee choices, by player name. */
|
||||
roomColors = $state<Record<string, number>>({});
|
||||
roomBots = $state<Record<string, string>>({});
|
||||
you = $state<string | null>(null);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
@@ -201,6 +239,9 @@ class Net {
|
||||
seats = $state<Seat[]>(loadSeats());
|
||||
/** Lobby ledger: one summary per live seat. */
|
||||
stats = $state<Record<string, number | string | null> | null>(null);
|
||||
chatSeen = $state<Record<string, number>>(loadChatSeen());
|
||||
/** Messages in the current room this session (history + live). */
|
||||
chatCount = 0;
|
||||
games = $state<GameSummary[]>([]);
|
||||
notificationsEnabled = $state(
|
||||
typeof Notification !== "undefined" && Notification.permission === "granted",
|
||||
@@ -210,8 +251,15 @@ class Net {
|
||||
/** Moves you haven't watched yet in the current room. */
|
||||
missedMoves = $state(0);
|
||||
/** A catch-up reel delivered by the server. */
|
||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView }[] | null>(null);
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate live event batches. */
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||
private seen: Record<string, number> = loadSeen();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
* after the first mark themselves seen while the tab is visible. */
|
||||
private watching: string | null = null;
|
||||
private currentSeq = 0;
|
||||
private lastYourTurn = new Map<string, boolean>();
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -225,6 +273,7 @@ class Net {
|
||||
const ws = new WebSocket(SERVER_URL);
|
||||
this.ws = ws;
|
||||
ws.onopen = () => {
|
||||
this.watching = null; // the first state after (re)connecting may carry a gap
|
||||
this.status = "connected";
|
||||
// Mid-game reconnects (the socket dropped, not the page) walk straight
|
||||
// back to the table; otherwise the lobby ledger is the front door.
|
||||
@@ -260,6 +309,7 @@ class Net {
|
||||
case "room":
|
||||
this.roomId = msg.roomId;
|
||||
this.roomColors = msg.colors ?? {};
|
||||
this.roomBots = msg.bots ?? {};
|
||||
if (this.you && this.token) {
|
||||
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||
@@ -273,25 +323,42 @@ class Net {
|
||||
this.view = msg.view;
|
||||
if (typeof msg.seq === "number" && this.roomId) {
|
||||
this.currentSeq = msg.seq;
|
||||
// Only the FIRST state after arriving carries a gap worth
|
||||
// announcing. Later states were watched live: a caught-up
|
||||
// watcher stays caught up, and an announced gap stays FROZEN
|
||||
// (not grown, not wiped) until watched or skipped. A hidden
|
||||
// tab accumulates its gap honestly.
|
||||
if (this.watching === this.roomId && document.visibilityState === "visible") {
|
||||
if (this.missedMoves === 0) this.markSeen();
|
||||
} else {
|
||||
const last = this.seen[this.roomId] ?? 0;
|
||||
this.missedMoves = Math.max(0, msg.seq - last);
|
||||
// Watching live counts as seeing; only a fresh arrival has a gap.
|
||||
if (this.missedMoves === 0 || document.visibilityState === "visible") {
|
||||
// A live update while present marks itself seen.
|
||||
if (last >= msg.seq - 1) this.markSeen();
|
||||
if (this.missedMoves === 0) this.markSeen();
|
||||
}
|
||||
if (document.visibilityState === "visible") this.watching = this.roomId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "catchUp":
|
||||
this.catchUp = msg.steps;
|
||||
break;
|
||||
case "events":
|
||||
case "events": {
|
||||
let talk = 0;
|
||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||
for (const e of msg.events as GameEvent[]) {
|
||||
if (e.type === "tableTalk") talk++;
|
||||
if (e.type === "gameStarted" && !msg.replayed) {
|
||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||
}
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
}
|
||||
if (talk > 0) {
|
||||
this.chatCount += talk;
|
||||
if (this.roomId) this.markChatSeen();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "transferCode":
|
||||
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
||||
break;
|
||||
@@ -302,6 +369,12 @@ class Net {
|
||||
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
||||
break;
|
||||
}
|
||||
case "chat": {
|
||||
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
|
||||
this.chatCount += 1;
|
||||
if (this.roomId) this.markChatSeen();
|
||||
break;
|
||||
}
|
||||
case "stats": {
|
||||
this.stats = msg.stats;
|
||||
break;
|
||||
@@ -322,6 +395,8 @@ class Net {
|
||||
this.roomIdPending = null;
|
||||
}
|
||||
this.error = msg.message;
|
||||
// The toast fades; the chronicle remembers why nothing happened.
|
||||
this.log = [...this.log, `— ${msg.message} —`];
|
||||
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
||||
break;
|
||||
}
|
||||
@@ -375,7 +450,32 @@ class Net {
|
||||
this.send({ type: "claimTransfer", code });
|
||||
}
|
||||
|
||||
/** Ask the server how all our games are doing. */
|
||||
addBot(style?: string, tier?: string): void {
|
||||
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
|
||||
}
|
||||
|
||||
rollTableDie(): void {
|
||||
this.send({ type: "rollDie" });
|
||||
}
|
||||
|
||||
sendChat(text: string): void {
|
||||
this.send({ type: "chat", text });
|
||||
}
|
||||
|
||||
/** Watching the table counts as reading the talk. */
|
||||
markChatSeen(): void {
|
||||
if (!this.roomId) return;
|
||||
const games = this.games.find((g) => g.roomId === this.roomId);
|
||||
const count = Math.max(this.chatCount, games?.chatCount ?? 0);
|
||||
if ((this.chatSeen[this.roomId] ?? 0) >= count) return;
|
||||
this.chatSeen = { ...this.chatSeen, [this.roomId]: count };
|
||||
localStorage.setItem(CHAT_SEEN_KEY, JSON.stringify(this.chatSeen));
|
||||
}
|
||||
|
||||
unreadChat(roomId: string, chatCount: number): number {
|
||||
return Math.max(0, chatCount - (this.chatSeen[roomId] ?? 0));
|
||||
}
|
||||
|
||||
requestStats(): void {
|
||||
this.send({ type: "stats" });
|
||||
}
|
||||
@@ -388,6 +488,7 @@ class Net {
|
||||
this.send({ type: "hotseatReport", ...report });
|
||||
}
|
||||
|
||||
/** Ask the server how all our games are doing. */
|
||||
refreshGames(): void {
|
||||
if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) });
|
||||
}
|
||||
@@ -443,6 +544,11 @@ class Net {
|
||||
this.send({ type: "pickColor", color });
|
||||
}
|
||||
|
||||
/** The whole game from the deal, once it is finished. */
|
||||
requestFullReplay(): void {
|
||||
this.send({ type: "catchUp", sinceSeq: 0, full: true });
|
||||
}
|
||||
|
||||
/** Ask for the reel of everything since we last watched. */
|
||||
requestCatchUp(): void {
|
||||
if (!this.roomId) return;
|
||||
|
||||
Reference in New Issue
Block a user