@conduction/docusaurus-preset 3.43.0 → 3.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/DiceDuel/DiceDuel.jsx +190 -0
- package/src/components/DiceDuel/DiceDuel.module.css +191 -0
- package/src/components/DiceDuel/__tests__/engine.test.js +159 -0
- package/src/components/DiceDuel/engine.js +182 -0
- package/src/components/PipeFit/PipeFit.jsx +221 -0
- package/src/components/PipeFit/PipeFit.module.css +203 -0
- package/src/components/PipeFit/__tests__/engine.test.js +176 -0
- package/src/components/PipeFit/engine.js +178 -0
- package/src/components/Reconcile/Reconcile.jsx +240 -0
- package/src/components/Reconcile/Reconcile.module.css +217 -0
- package/src/components/Reconcile/__tests__/engine.test.js +168 -0
- package/src/components/Reconcile/engine.js +228 -0
- package/src/components/index.js +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <DiceDuel />
|
|
3
|
+
*
|
|
4
|
+
* Larpinq's game, and the one app where a game needs no excuse. A
|
|
5
|
+
* monster blocks the path. You have four dice and one decision a
|
|
6
|
+
* round: swing with what you rolled, or push your luck and throw the
|
|
7
|
+
* weak ones again.
|
|
8
|
+
*
|
|
9
|
+
* The push is the game. A swing is safe and usually small; a reroll is
|
|
10
|
+
* the only road to a big hit and the only way to be bitten. Nothing is
|
|
11
|
+
* timed, because pushing your luck is not a thing to be hurried
|
|
12
|
+
* through, which also makes it the one game here you can play while
|
|
13
|
+
* thinking.
|
|
14
|
+
*
|
|
15
|
+
* The rules live in ./engine.js with no DOM and no clock.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
*
|
|
19
|
+
* <DiceDuel />
|
|
20
|
+
*
|
|
21
|
+
* Fires the shared `connext:gameend` event on game over, and listens
|
|
22
|
+
* for `connext:gamereplay`.
|
|
23
|
+
*
|
|
24
|
+
* Accessibility: every die is a toggle button that says its face and
|
|
25
|
+
* whether it is held, and the monster's state is a sentence. Nothing
|
|
26
|
+
* depends on seeing pips.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
30
|
+
import {translate} from '@docusaurus/Translate';
|
|
31
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
32
|
+
import {createGame, strike, reroll, toggleKeep, damageOf, summarise} from './engine';
|
|
33
|
+
import styles from './DiceDuel.module.css';
|
|
34
|
+
|
|
35
|
+
const GAME_ID = 'dice-duel';
|
|
36
|
+
|
|
37
|
+
function monsterName(key) {
|
|
38
|
+
switch (key) {
|
|
39
|
+
case 'goblin':
|
|
40
|
+
return translate({id: 'preset.diceDuel.monster.goblin', message: 'a goblin', description: 'The first monster in the dice duel'});
|
|
41
|
+
case 'troll':
|
|
42
|
+
return translate({id: 'preset.diceDuel.monster.troll', message: 'a troll', description: 'The second monster in the dice duel'});
|
|
43
|
+
case 'wyrm':
|
|
44
|
+
return translate({id: 'preset.diceDuel.monster.wyrm', message: 'a wyrm', description: 'The third monster in the dice duel'});
|
|
45
|
+
default:
|
|
46
|
+
return translate({id: 'preset.diceDuel.monster.lich', message: 'a lich', description: 'The fourth monster in the dice duel'});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function DiceDuel({className}) {
|
|
51
|
+
const {i18n} = useDocusaurusContext();
|
|
52
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
53
|
+
|
|
54
|
+
const [game, setGame] = useState(null);
|
|
55
|
+
const gameRef = useRef(null);
|
|
56
|
+
const endedRef = useRef(false);
|
|
57
|
+
|
|
58
|
+
const running = Boolean(game) && !game.over;
|
|
59
|
+
|
|
60
|
+
const begin = useCallback(() => {
|
|
61
|
+
endedRef.current = false;
|
|
62
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31)});
|
|
63
|
+
gameRef.current = fresh;
|
|
64
|
+
setGame(fresh);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!game || !game.over || endedRef.current) return;
|
|
69
|
+
endedRef.current = true;
|
|
70
|
+
if (typeof window === 'undefined') return;
|
|
71
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
72
|
+
detail: {
|
|
73
|
+
id: GAME_ID,
|
|
74
|
+
won: false,
|
|
75
|
+
score: game.score,
|
|
76
|
+
summary: summarise(game, locale),
|
|
77
|
+
title: translate({id: 'preset.diceDuel.over.title', message: 'It got you in the end.', description: 'Headline on the game-over dialog after a dice duel'}),
|
|
78
|
+
subtitle: translate({id: 'preset.diceDuel.over.subtitle', message: 'Three bad pushes. The dice were fine; the greed was the problem.', description: 'Subtitle on the game-over dialog after a dice duel'}),
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
}, [game, locale]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (typeof window === 'undefined') return undefined;
|
|
85
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
86
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
87
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
88
|
+
}, [begin]);
|
|
89
|
+
|
|
90
|
+
const act = useCallback((fn) => {
|
|
91
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
92
|
+
const next = fn(gameRef.current);
|
|
93
|
+
gameRef.current = next;
|
|
94
|
+
setGame(next);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const dice = game ? game.dice : [];
|
|
98
|
+
const monster = game ? game.monster : null;
|
|
99
|
+
const last = game ? game.last : null;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<section className={[styles.dd, className].filter(Boolean).join(' ')} aria-labelledby="dice-duel-title">
|
|
103
|
+
<header className={styles.head}>
|
|
104
|
+
<div>
|
|
105
|
+
<p className={styles.eyebrow}>
|
|
106
|
+
{translate({id: 'preset.diceDuel.eyebrow', message: 'Mini-game', description: 'Eyebrow above the dice duel on a product page'})}
|
|
107
|
+
</p>
|
|
108
|
+
<h3 className={styles.title} id="dice-duel-title">
|
|
109
|
+
{translate({id: 'preset.diceDuel.title', message: 'Dice duel', description: 'Name of the Larpinq mini-game'})}
|
|
110
|
+
</h3>
|
|
111
|
+
<p className={styles.lede}>
|
|
112
|
+
{translate({id: 'preset.diceDuel.lede', message: 'Something is blocking the path. Swing with what you rolled, or hold the good dice and throw the rest again. Roll worse than you had and it bites.', description: 'One-line explanation of the dice-duel rules'})}
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
116
|
+
<span className={styles.hudPill}>
|
|
117
|
+
{translate({id: 'preset.diceDuel.hud.score', message: 'Score {score}', description: 'Score readout on the dice-duel HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
118
|
+
</span>
|
|
119
|
+
<span className={styles.hudPill}>
|
|
120
|
+
{translate({id: 'preset.diceDuel.hud.hearts', message: 'Hearts {hearts}', description: 'Remaining-lives readout on the dice-duel HUD'}, {hearts: game ? game.hearts : 3})}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
</header>
|
|
124
|
+
|
|
125
|
+
<div className={styles.field}>
|
|
126
|
+
<p className={styles.monster}>
|
|
127
|
+
{monster
|
|
128
|
+
? translate(
|
|
129
|
+
{id: 'preset.diceDuel.monsterLine', message: '{what}, {hp} of {max} left', description: 'The monster and how much fight it has left'},
|
|
130
|
+
{what: monsterName(monster.key), hp: monster.hp, max: monster.maxHp},
|
|
131
|
+
)
|
|
132
|
+
: translate({id: 'preset.diceDuel.idle', message: 'Something is waiting up the path.', description: 'Placeholder before the dice duel starts'})}
|
|
133
|
+
</p>
|
|
134
|
+
{monster && (
|
|
135
|
+
<span className={styles.wounds} role="progressbar" aria-valuemin={0} aria-valuemax={monster.maxHp} aria-valuenow={monster.hp}
|
|
136
|
+
aria-label={translate({id: 'preset.diceDuel.wounds', message: 'What the monster has left', description: 'Accessible name of the monster health bar'})}>
|
|
137
|
+
<span className={styles.woundsFill} style={{width: `${Math.max(0, (monster.hp / monster.maxHp) * 100)}%`}} />
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
<div className={styles.dice} role="group" aria-label={translate({id: 'preset.diceDuel.diceLabel', message: 'Your dice', description: 'Accessible name for the row of dice'})}>
|
|
142
|
+
{dice.map((die, i) => (
|
|
143
|
+
<button
|
|
144
|
+
key={i}
|
|
145
|
+
type="button"
|
|
146
|
+
className={[styles.die, game && game.kept[i] && styles.dieKept].filter(Boolean).join(' ')}
|
|
147
|
+
onClick={() => act((s) => toggleKeep(s, i))}
|
|
148
|
+
disabled={!running}
|
|
149
|
+
aria-pressed={Boolean(game && game.kept[i])}
|
|
150
|
+
aria-label={translate(
|
|
151
|
+
{id: 'preset.diceDuel.die', message: 'Die showing {face}{held}', description: 'Accessible label for one die. {face} is the number, {held} says whether it is held.'},
|
|
152
|
+
{face: die, held: game && game.kept[i]
|
|
153
|
+
? translate({id: 'preset.diceDuel.die.held', message: ', held', description: 'Appended to a die that is being kept'})
|
|
154
|
+
: ''},
|
|
155
|
+
)}>
|
|
156
|
+
{die}
|
|
157
|
+
</button>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<p className={styles.worth}>
|
|
162
|
+
{game
|
|
163
|
+
? translate({id: 'preset.diceDuel.worth', message: 'That swing is worth {damage}', description: 'How much the current dice are worth'}, {damage: damageOf(dice)})
|
|
164
|
+
: ''}
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<footer className={styles.foot}>
|
|
169
|
+
<button type="button" className={styles.strike} onClick={() => act(strike)} disabled={!running}>
|
|
170
|
+
{translate({id: 'preset.diceDuel.strike', message: 'Swing', description: 'Button that attacks with the current dice'})}
|
|
171
|
+
</button>
|
|
172
|
+
<button type="button" className={styles.push} onClick={() => act(reroll)} disabled={!running || !game || game.rerollsLeft <= 0}>
|
|
173
|
+
{translate({id: 'preset.diceDuel.reroll', message: 'Push your luck ({left})', description: 'Button that rerolls the unheld dice. {left} is how many rerolls remain.'}, {left: game ? game.rerollsLeft : 0})}
|
|
174
|
+
</button>
|
|
175
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
176
|
+
{game
|
|
177
|
+
? translate({id: 'preset.diceDuel.restart', message: 'New duel', description: 'Button that restarts the dice duel'})
|
|
178
|
+
: translate({id: 'preset.diceDuel.start', message: 'Draw steel', description: 'Button that starts the dice duel'})}
|
|
179
|
+
</button>
|
|
180
|
+
<p className={styles.hint} role="status" aria-live="polite">
|
|
181
|
+
{last && last.result === 'felled' && translate({id: 'preset.diceDuel.feedback.felled', message: 'It falls. Something worse is already coming.', description: 'Feedback after felling a monster'})}
|
|
182
|
+
{last && last.result === 'hit' && translate({id: 'preset.diceDuel.feedback.hit', message: 'You hit for {damage}. It has {left} left.', description: 'Feedback after a swing that did not fell the monster'}, {damage: last.damage, left: last.left})}
|
|
183
|
+
{last && last.result === 'bitten' && translate({id: 'preset.diceDuel.feedback.bitten', message: 'Worse than you had, and it bites. {from} became {to}.', description: 'Feedback after a reroll that came up worse'}, {from: last.from, to: last.to})}
|
|
184
|
+
{last && last.result === 'rerollUp' && translate({id: 'preset.diceDuel.feedback.better', message: 'Better. {from} became {to}.', description: 'Feedback after a reroll that improved the hand'}, {from: last.from, to: last.to})}
|
|
185
|
+
{!last && translate({id: 'preset.diceDuel.hint', message: 'Hold the dice you like, then push. Sixes are worth more than six.', description: 'Hint under the dice duel'})}
|
|
186
|
+
</p>
|
|
187
|
+
</footer>
|
|
188
|
+
</section>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <DiceDuel /> styles.
|
|
3
|
+
*
|
|
4
|
+
* Tokens only, one accent on the thing you are fighting. A held die is
|
|
5
|
+
* marked by its border and its label, never by colour alone.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.dd {
|
|
9
|
+
border: 1px solid var(--c-cobalt-100);
|
|
10
|
+
border-radius: var(--radius-lg);
|
|
11
|
+
background: white;
|
|
12
|
+
padding: clamp(16px, 3vw, 28px);
|
|
13
|
+
font-family: var(--conduction-typography-font-family-body);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.head {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
gap: 16px;
|
|
20
|
+
align-items: flex-start;
|
|
21
|
+
justify-content: space-between;
|
|
22
|
+
margin-bottom: 16px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.eyebrow {
|
|
26
|
+
margin: 0 0 4px;
|
|
27
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
28
|
+
font-size: 11px;
|
|
29
|
+
letter-spacing: 0.12em;
|
|
30
|
+
text-transform: uppercase;
|
|
31
|
+
color: var(--c-orange-knvb);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.title {
|
|
35
|
+
margin: 0 0 6px;
|
|
36
|
+
font-size: 20px;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
color: var(--c-cobalt-900);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.lede {
|
|
42
|
+
margin: 0;
|
|
43
|
+
max-width: 58ch;
|
|
44
|
+
font-size: 14px;
|
|
45
|
+
line-height: 1.5;
|
|
46
|
+
color: var(--c-cobalt-700);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hud {
|
|
50
|
+
display: flex;
|
|
51
|
+
gap: 8px;
|
|
52
|
+
flex-wrap: wrap;
|
|
53
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
54
|
+
font-size: 12px;
|
|
55
|
+
font-variant-numeric: tabular-nums;
|
|
56
|
+
}
|
|
57
|
+
.hudPill {
|
|
58
|
+
padding: 6px 10px;
|
|
59
|
+
border-radius: var(--radius-pill);
|
|
60
|
+
background: var(--c-cobalt-50);
|
|
61
|
+
color: var(--c-cobalt-900);
|
|
62
|
+
white-space: nowrap;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.field {
|
|
66
|
+
padding: 16px;
|
|
67
|
+
border-radius: var(--radius-md);
|
|
68
|
+
background: var(--c-cobalt-50);
|
|
69
|
+
max-width: 560px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.monster {
|
|
73
|
+
margin: 0 0 8px;
|
|
74
|
+
font-size: 15px;
|
|
75
|
+
color: var(--c-cobalt-900);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.wounds {
|
|
79
|
+
display: block;
|
|
80
|
+
height: 6px;
|
|
81
|
+
border-radius: var(--radius-pill);
|
|
82
|
+
background: var(--c-cobalt-200);
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
margin-bottom: 16px;
|
|
85
|
+
}
|
|
86
|
+
.woundsFill {
|
|
87
|
+
display: block;
|
|
88
|
+
height: 100%;
|
|
89
|
+
background: var(--c-orange-knvb);
|
|
90
|
+
transition: width 200ms ease-out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.dice {
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-wrap: wrap;
|
|
96
|
+
gap: 10px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.die {
|
|
100
|
+
width: 52px;
|
|
101
|
+
height: 52px;
|
|
102
|
+
border: 2px solid var(--c-cobalt-200);
|
|
103
|
+
border-radius: var(--radius-md);
|
|
104
|
+
background: white;
|
|
105
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
106
|
+
font-size: 20px;
|
|
107
|
+
font-weight: 700;
|
|
108
|
+
color: var(--c-cobalt-900);
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
transition: border-color 120ms ease, transform 120ms ease;
|
|
111
|
+
}
|
|
112
|
+
.die:hover:not(:disabled) { border-color: var(--c-blue-cobalt); }
|
|
113
|
+
.die:disabled { opacity: 0.55; cursor: default; }
|
|
114
|
+
.die:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
115
|
+
|
|
116
|
+
/* A held die sits forward and says so in its label. */
|
|
117
|
+
.dieKept {
|
|
118
|
+
border-color: var(--c-mint-500);
|
|
119
|
+
background: var(--c-mint-300);
|
|
120
|
+
transform: translateY(-3px);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.worth {
|
|
124
|
+
margin: 12px 0 0;
|
|
125
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
126
|
+
font-size: 12px;
|
|
127
|
+
color: var(--c-cobalt-700);
|
|
128
|
+
min-height: 1.2em;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.foot {
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-wrap: wrap;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 10px;
|
|
136
|
+
margin-top: 14px;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.strike {
|
|
140
|
+
background: var(--c-blue-cobalt);
|
|
141
|
+
color: white;
|
|
142
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
143
|
+
padding: 10px 18px;
|
|
144
|
+
border-radius: var(--radius-md);
|
|
145
|
+
font-family: inherit;
|
|
146
|
+
font-weight: 500;
|
|
147
|
+
font-size: 14px;
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
transition: background 120ms ease;
|
|
150
|
+
}
|
|
151
|
+
.strike:hover:not(:disabled) { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
152
|
+
.strike:disabled { opacity: 0.5; cursor: default; }
|
|
153
|
+
.strike:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
154
|
+
|
|
155
|
+
.push {
|
|
156
|
+
background: white;
|
|
157
|
+
color: var(--c-cobalt-700);
|
|
158
|
+
border: 1px solid var(--c-cobalt-200);
|
|
159
|
+
padding: 10px 18px;
|
|
160
|
+
border-radius: var(--radius-md);
|
|
161
|
+
font-family: inherit;
|
|
162
|
+
font-weight: 500;
|
|
163
|
+
font-size: 14px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
transition: border-color 120ms ease;
|
|
166
|
+
}
|
|
167
|
+
.push:hover:not(:disabled) { border-color: var(--c-orange-knvb); color: var(--c-orange-knvb); }
|
|
168
|
+
.push:disabled { opacity: 0.5; cursor: default; }
|
|
169
|
+
.push:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
170
|
+
|
|
171
|
+
.start {
|
|
172
|
+
background: white;
|
|
173
|
+
color: var(--c-cobalt-700);
|
|
174
|
+
border: 1px solid var(--c-cobalt-200);
|
|
175
|
+
padding: 10px 18px;
|
|
176
|
+
border-radius: var(--radius-md);
|
|
177
|
+
font-family: inherit;
|
|
178
|
+
font-weight: 500;
|
|
179
|
+
font-size: 14px;
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
}
|
|
182
|
+
.start:hover { border-color: var(--c-blue-cobalt); color: var(--c-blue-cobalt); }
|
|
183
|
+
.start:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
184
|
+
|
|
185
|
+
.hint {
|
|
186
|
+
margin: 0;
|
|
187
|
+
flex-basis: 100%;
|
|
188
|
+
font-size: 12px;
|
|
189
|
+
color: var(--c-cobalt-400);
|
|
190
|
+
min-height: 1.4em;
|
|
191
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.test.js — the dice-duel rules.
|
|
3
|
+
*
|
|
4
|
+
* The decision is the game: a swing is safe and small, a reroll is the
|
|
5
|
+
* only road to a big hit and the only way to be hurt. Both halves are
|
|
6
|
+
* pinned here, because a version where rerolling is free has no
|
|
7
|
+
* decision in it, and a version where swinging can hurt you has no
|
|
8
|
+
* safe move at all.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const test = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
createGame, strike, reroll, toggleKeep, damageOf, summarise,
|
|
18
|
+
DICE, FACES, MONSTERS, DEFAULTS,
|
|
19
|
+
} = require('../engine.js');
|
|
20
|
+
|
|
21
|
+
/** Put known dice on the table. */
|
|
22
|
+
function withDice(state, dice) {
|
|
23
|
+
return {...state, dice, kept: dice.map(() => false)};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('a duel opens with a monster, a handful of dice and rerolls in hand', () => {
|
|
27
|
+
const s = createGame({seed: 1});
|
|
28
|
+
assert.equal(s.dice.length, DICE);
|
|
29
|
+
assert.ok(s.dice.every((d) => d >= 1 && d <= FACES), 'a die landed off its own faces');
|
|
30
|
+
assert.equal(s.hearts, DEFAULTS.hearts);
|
|
31
|
+
assert.equal(s.rerollsLeft, DEFAULTS.rerollsPerRound);
|
|
32
|
+
assert.equal(s.monster.hp, MONSTERS[0].hp);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('damage is the pips, and a six is worth more than six', () => {
|
|
36
|
+
assert.equal(damageOf([1, 2, 3, 4]), 10);
|
|
37
|
+
assert.equal(damageOf([6, 1, 1, 1]), 9 + DEFAULTS.critBonus);
|
|
38
|
+
assert.equal(damageOf([6, 6, 6, 6]), 24 + 4 * DEFAULTS.critBonus);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a swing always lands, and the wound stays on the monster', () => {
|
|
42
|
+
let s = createGame({seed: 2});
|
|
43
|
+
s = withDice(s, [2, 2, 2, 2]);
|
|
44
|
+
const before = s.monster.hp;
|
|
45
|
+
s = strike(s);
|
|
46
|
+
assert.equal(s.monster.hp, before - 8);
|
|
47
|
+
assert.equal(s.hearts, DEFAULTS.hearts, 'swinging cost a heart');
|
|
48
|
+
assert.equal(s.last.result, 'hit');
|
|
49
|
+
assert.equal(s.rerollsLeft, DEFAULTS.rerollsPerRound, 'the new round started without rerolls');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('enough damage fells the monster and brings the next one', () => {
|
|
53
|
+
let s = createGame({seed: 3});
|
|
54
|
+
s = withDice(s, [6, 6, 6, 6]);
|
|
55
|
+
const first = s.monster.key;
|
|
56
|
+
s = strike(s);
|
|
57
|
+
assert.equal(s.felled, 1);
|
|
58
|
+
assert.ok(s.score >= DEFAULTS.pointsPerKill);
|
|
59
|
+
assert.notEqual(s.monster.key, first, 'the same monster came back');
|
|
60
|
+
assert.equal(s.monster.hp, s.monster.maxHp, 'the next monster arrived already wounded');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the monsters get harder as they come', () => {
|
|
64
|
+
const hp = MONSTERS.map((m) => m.hp);
|
|
65
|
+
assert.deepEqual(hp, [...hp].sort((a, b) => a - b), 'the queue is not in rising order');
|
|
66
|
+
assert.ok(MONSTERS.length >= 3);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a reroll that improves the hand costs nothing but the reroll', () => {
|
|
70
|
+
let s = createGame({seed: 4});
|
|
71
|
+
s = withDice(s, [1, 1, 1, 1]);
|
|
72
|
+
const before = s.hearts;
|
|
73
|
+
s = reroll(s);
|
|
74
|
+
assert.equal(s.rerollsLeft, DEFAULTS.rerollsPerRound - 1);
|
|
75
|
+
/* From four ones, no roll can be worse, so this can only go up. */
|
|
76
|
+
assert.equal(s.hearts, before, 'an improving reroll cost a heart');
|
|
77
|
+
assert.equal(s.last.result, 'rerollUp');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a reroll that lands worse is what the monster punishes', () => {
|
|
81
|
+
/* Start from the best hand there is: every reroll is worse. */
|
|
82
|
+
let s = createGame({seed: 5});
|
|
83
|
+
s = withDice(s, [6, 6, 6, 6]);
|
|
84
|
+
s = reroll(s);
|
|
85
|
+
assert.equal(s.hearts, DEFAULTS.hearts - 1);
|
|
86
|
+
assert.equal(s.last.result, 'bitten');
|
|
87
|
+
assert.ok(s.last.to < s.last.from);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('held dice are not rerolled', () => {
|
|
91
|
+
let s = createGame({seed: 6});
|
|
92
|
+
s = withDice(s, [6, 1, 1, 1]);
|
|
93
|
+
s = toggleKeep(s, 0);
|
|
94
|
+
s = reroll(s);
|
|
95
|
+
assert.equal(s.dice[0], 6, 'a held die was thrown again');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('holding everything leaves nothing to reroll, and the reroll is refused', () => {
|
|
99
|
+
let s = createGame({seed: 7});
|
|
100
|
+
s = withDice(s, [3, 3, 3, 3]);
|
|
101
|
+
for (let i = 0; i < DICE; i++) s = toggleKeep(s, i);
|
|
102
|
+
const before = s.rerollsLeft;
|
|
103
|
+
s = reroll(s);
|
|
104
|
+
assert.equal(s.rerollsLeft, before, 'a reroll with nothing to reroll was spent');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('rerolls run out, and a spent reroll cannot be taken again', () => {
|
|
108
|
+
let s = createGame({seed: 8});
|
|
109
|
+
for (let i = 0; i < DEFAULTS.rerollsPerRound; i++) s = reroll(s);
|
|
110
|
+
assert.equal(s.rerollsLeft, 0);
|
|
111
|
+
const spent = s.dice.join(',');
|
|
112
|
+
s = reroll(s);
|
|
113
|
+
assert.equal(s.dice.join(','), spent, 'the dice moved with no rerolls left');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a bad die that does not exist is ignored', () => {
|
|
117
|
+
const s = createGame({seed: 9});
|
|
118
|
+
assert.equal(toggleKeep(s, 99), s);
|
|
119
|
+
assert.equal(toggleKeep(s, -1), s);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('three bites end the duel, and nothing moves afterwards', () => {
|
|
123
|
+
let s = createGame({seed: 10});
|
|
124
|
+
for (let i = 0; i < DEFAULTS.hearts; i++) {
|
|
125
|
+
s = withDice(s, [6, 6, 6, 6]);
|
|
126
|
+
s = {...s, rerollsLeft: DEFAULTS.rerollsPerRound};
|
|
127
|
+
s = reroll(s);
|
|
128
|
+
}
|
|
129
|
+
assert.equal(s.over, true);
|
|
130
|
+
assert.equal(s.hearts, 0);
|
|
131
|
+
assert.equal(strike(s).score, s.score, 'the duel kept scoring after the last heart');
|
|
132
|
+
assert.equal(reroll(s).dice.join(','), s.dice.join(','));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a player who never pushes their luck is never hurt', () => {
|
|
136
|
+
/* The whole point of the safe move: it has to stay safe forever. */
|
|
137
|
+
let s = createGame({seed: 11});
|
|
138
|
+
for (let i = 0; i < 60; i++) s = strike(s);
|
|
139
|
+
assert.equal(s.hearts, DEFAULTS.hearts);
|
|
140
|
+
assert.equal(s.over, false);
|
|
141
|
+
assert.ok(s.felled > 0, 'sixty swings felled nothing');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the same seed rolls the same duel, a different one does not', () => {
|
|
145
|
+
const run = (seed) => {
|
|
146
|
+
let s = createGame({seed});
|
|
147
|
+
const out = [s.dice.join('')];
|
|
148
|
+
for (let i = 0; i < 5; i++) { s = strike(s); out.push(s.dice.join('')); }
|
|
149
|
+
return out.join('|');
|
|
150
|
+
};
|
|
151
|
+
assert.equal(run(21), run(21));
|
|
152
|
+
assert.notEqual(run(21), run(22));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('the summary reads as a sentence in both locales', () => {
|
|
156
|
+
const s = {felled: 3, rounds: 11};
|
|
157
|
+
assert.match(summarise(s, 'en'), /3 monsters felled · 11 rounds/);
|
|
158
|
+
assert.match(summarise(s, 'nl'), /3 monsters geveld · 11 beurten/);
|
|
159
|
+
});
|