@conduction/docusaurus-preset 3.42.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/MonsterRun/MonsterRun.jsx +233 -0
- package/src/components/MonsterRun/MonsterRun.module.css +231 -0
- package/src/components/MonsterRun/__tests__/engine.test.js +218 -0
- package/src/components/MonsterRun/engine.js +194 -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 +4 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <Reconcile />
|
|
3
|
+
*
|
|
4
|
+
* Shillinq's game. The bank statement is in, the invoices are open,
|
|
5
|
+
* and the month is closing. Match each payment to the invoice it
|
|
6
|
+
* settles. One line on the statement settles nothing at all: flag that
|
|
7
|
+
* one instead.
|
|
8
|
+
*
|
|
9
|
+
* Three moves, not two, and that is the point. Paying the odd line out
|
|
10
|
+
* is how money leaves quietly. Flagging a genuine payment costs too,
|
|
11
|
+
* because a bookkeeper who cries wolf at every line is one nobody
|
|
12
|
+
* listens to.
|
|
13
|
+
*
|
|
14
|
+
* The rules live in ./engine.js with no DOM and no clock.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
*
|
|
18
|
+
* <Reconcile />
|
|
19
|
+
*
|
|
20
|
+
* Fires the shared `connext:gameend` event on game over, and listens
|
|
21
|
+
* for `connext:gamereplay`.
|
|
22
|
+
*
|
|
23
|
+
* Accessibility: a payment is picked up with a button and dropped on
|
|
24
|
+
* an invoice with a button, so nothing needs a drag. Every amount and
|
|
25
|
+
* reference is read out, and the countdown is announced.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
29
|
+
import {translate} from '@docusaurus/Translate';
|
|
30
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
31
|
+
import {createGame, match, flag, step, remaining, summarise} from './engine';
|
|
32
|
+
import styles from './Reconcile.module.css';
|
|
33
|
+
|
|
34
|
+
const GAME_ID = 'reconcile';
|
|
35
|
+
const TICK_MS = 100;
|
|
36
|
+
|
|
37
|
+
export default function Reconcile({className}) {
|
|
38
|
+
const {i18n} = useDocusaurusContext();
|
|
39
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
40
|
+
|
|
41
|
+
const [game, setGame] = useState(null);
|
|
42
|
+
const [held, setHeld] = useState(null);
|
|
43
|
+
const [left, setLeft] = useState(1);
|
|
44
|
+
const gameRef = useRef(null);
|
|
45
|
+
const startedAtRef = useRef(0);
|
|
46
|
+
const endedRef = useRef(false);
|
|
47
|
+
|
|
48
|
+
const running = Boolean(game) && !game.over;
|
|
49
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
|
|
50
|
+
const money = (amount) => new Intl.NumberFormat(locale, {style: 'currency', currency: 'EUR', maximumFractionDigits: 0}).format(amount);
|
|
51
|
+
|
|
52
|
+
const begin = useCallback(() => {
|
|
53
|
+
endedRef.current = false;
|
|
54
|
+
startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
55
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
|
|
56
|
+
gameRef.current = fresh;
|
|
57
|
+
setGame(fresh);
|
|
58
|
+
setHeld(null);
|
|
59
|
+
setLeft(1);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!running) return undefined;
|
|
64
|
+
const id = setInterval(() => {
|
|
65
|
+
const t = now();
|
|
66
|
+
const next = step(gameRef.current, t);
|
|
67
|
+
gameRef.current = next;
|
|
68
|
+
setGame(next);
|
|
69
|
+
setLeft(remaining(next, t));
|
|
70
|
+
}, TICK_MS);
|
|
71
|
+
return () => clearInterval(id);
|
|
72
|
+
}, [running]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!game || !game.over || endedRef.current) return;
|
|
76
|
+
endedRef.current = true;
|
|
77
|
+
if (typeof window === 'undefined') return;
|
|
78
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
79
|
+
detail: {
|
|
80
|
+
id: GAME_ID,
|
|
81
|
+
won: false,
|
|
82
|
+
score: game.score,
|
|
83
|
+
summary: summarise(game, locale),
|
|
84
|
+
title: translate({id: 'preset.reconcile.over.title', message: 'The books do not balance.', description: 'Headline on the game-over dialog after a reconciliation run'}),
|
|
85
|
+
subtitle: translate({id: 'preset.reconcile.over.subtitle', message: 'Three of those, and somebody finds out in April.', description: 'Subtitle on the game-over dialog after a reconciliation run'}),
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
}, [game, locale]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (typeof window === 'undefined') return undefined;
|
|
92
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
93
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
94
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
95
|
+
}, [begin]);
|
|
96
|
+
|
|
97
|
+
const drop = useCallback((invoiceId) => {
|
|
98
|
+
if (!gameRef.current || gameRef.current.over || !held) return;
|
|
99
|
+
const t = now();
|
|
100
|
+
const next = match(gameRef.current, held, invoiceId, t);
|
|
101
|
+
gameRef.current = next;
|
|
102
|
+
setGame(next);
|
|
103
|
+
setHeld(null);
|
|
104
|
+
setLeft(remaining(next, t));
|
|
105
|
+
}, [held]);
|
|
106
|
+
|
|
107
|
+
const raise = useCallback((paymentId) => {
|
|
108
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
109
|
+
const t = now();
|
|
110
|
+
const next = flag(gameRef.current, paymentId, t);
|
|
111
|
+
gameRef.current = next;
|
|
112
|
+
setGame(next);
|
|
113
|
+
setHeld(null);
|
|
114
|
+
setLeft(remaining(next, t));
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const sheet = game ? game.sheet : null;
|
|
118
|
+
const last = game ? game.last : null;
|
|
119
|
+
const pct = Math.round(left * 100);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<section className={[styles.rc, className].filter(Boolean).join(' ')} aria-labelledby="reconcile-title">
|
|
123
|
+
<header className={styles.head}>
|
|
124
|
+
<div>
|
|
125
|
+
<p className={styles.eyebrow}>
|
|
126
|
+
{translate({id: 'preset.reconcile.eyebrow', message: 'Mini-game', description: 'Eyebrow above the reconciliation game on a product page'})}
|
|
127
|
+
</p>
|
|
128
|
+
<h3 className={styles.title} id="reconcile-title">
|
|
129
|
+
{translate({id: 'preset.reconcile.title', message: 'Match the bank', description: 'Name of the Shillinq mini-game'})}
|
|
130
|
+
</h3>
|
|
131
|
+
<p className={styles.lede}>
|
|
132
|
+
{translate({id: 'preset.reconcile.lede', message: 'Every payment belongs to an invoice, except the one that belongs to nobody. Match what fits and flag what does not, before the month closes.', description: 'One-line explanation of the reconciliation rules'})}
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
136
|
+
<span className={styles.hudPill}>
|
|
137
|
+
{translate({id: 'preset.reconcile.hud.score', message: 'Score {score}', description: 'Score readout on the reconciliation HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
138
|
+
</span>
|
|
139
|
+
<span className={styles.hudPill}>
|
|
140
|
+
{translate({id: 'preset.reconcile.hud.lives', message: 'Corrections left {lives}', description: 'Remaining-lives readout on the reconciliation HUD'}, {lives: game ? game.lives : 3})}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
</header>
|
|
144
|
+
|
|
145
|
+
{sheet ? (
|
|
146
|
+
<>
|
|
147
|
+
<div className={styles.sheet}>
|
|
148
|
+
<div className={styles.column}>
|
|
149
|
+
<h4 className={styles.columnHead}>
|
|
150
|
+
{translate({id: 'preset.reconcile.statement', message: 'On the statement', description: 'Heading above the bank payments'})}
|
|
151
|
+
</h4>
|
|
152
|
+
<ul className={styles.list}>
|
|
153
|
+
{sheet.payments.map((payment) => (
|
|
154
|
+
<li key={payment.id} className={payment.done ? styles.rowDone : styles.row}>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
className={[styles.pick, held === payment.id && styles.picked].filter(Boolean).join(' ')}
|
|
158
|
+
onClick={() => setHeld(held === payment.id ? null : payment.id)}
|
|
159
|
+
disabled={!running || payment.done}
|
|
160
|
+
aria-pressed={held === payment.id}>
|
|
161
|
+
<span className={styles.amount}>{money(payment.amount)}</span>
|
|
162
|
+
<span className={styles.ref}>{payment.reference}</span>
|
|
163
|
+
</button>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
className={styles.flag}
|
|
167
|
+
onClick={() => raise(payment.id)}
|
|
168
|
+
disabled={!running || payment.done}
|
|
169
|
+
aria-label={translate(
|
|
170
|
+
{id: 'preset.reconcile.flagOne', message: 'Flag {amount}, reference {reference}, as belonging to nobody', description: 'Accessible label for the flag button on one payment'},
|
|
171
|
+
{amount: money(payment.amount), reference: payment.reference},
|
|
172
|
+
)}>
|
|
173
|
+
{translate({id: 'preset.reconcile.flag', message: 'Flag', description: 'Short label on the button that flags a payment as fraudulent'})}
|
|
174
|
+
</button>
|
|
175
|
+
</li>
|
|
176
|
+
))}
|
|
177
|
+
</ul>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className={styles.column}>
|
|
181
|
+
<h4 className={styles.columnHead}>
|
|
182
|
+
{translate({id: 'preset.reconcile.invoices', message: 'Open invoices', description: 'Heading above the open invoices'})}
|
|
183
|
+
</h4>
|
|
184
|
+
<ul className={styles.list}>
|
|
185
|
+
{sheet.invoices.map((invoice) => (
|
|
186
|
+
<li key={invoice.id} className={invoice.settled ? styles.rowDone : styles.row}>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
className={styles.drop}
|
|
190
|
+
onClick={() => drop(invoice.id)}
|
|
191
|
+
disabled={!running || invoice.settled || !held}
|
|
192
|
+
aria-label={translate(
|
|
193
|
+
{id: 'preset.reconcile.settle', message: 'Settle invoice {reference} for {amount} with the payment you picked up', description: 'Accessible label for an invoice button'},
|
|
194
|
+
{reference: invoice.reference, amount: money(invoice.amount)},
|
|
195
|
+
)}>
|
|
196
|
+
<span className={styles.amount}>{money(invoice.amount)}</span>
|
|
197
|
+
<span className={styles.ref}>{invoice.reference}</span>
|
|
198
|
+
</button>
|
|
199
|
+
</li>
|
|
200
|
+
))}
|
|
201
|
+
</ul>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div
|
|
206
|
+
className={styles.clock}
|
|
207
|
+
role="progressbar"
|
|
208
|
+
aria-valuemin={0}
|
|
209
|
+
aria-valuemax={100}
|
|
210
|
+
aria-valuenow={pct}
|
|
211
|
+
aria-label={translate({id: 'preset.reconcile.clock', message: 'Time before the month closes', description: 'Accessible name of the reconciliation countdown'})}>
|
|
212
|
+
<div className={[styles.clockFill, left < 0.3 && styles.clockLow].filter(Boolean).join(' ')} style={{width: `${pct}%`}} />
|
|
213
|
+
</div>
|
|
214
|
+
</>
|
|
215
|
+
) : (
|
|
216
|
+
<p className={styles.idle}>
|
|
217
|
+
{translate({id: 'preset.reconcile.idle', message: 'A statement, a stack of invoices, and one line that fits neither.', description: 'Placeholder before the reconciliation game starts'})}
|
|
218
|
+
</p>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
<footer className={styles.foot}>
|
|
222
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
223
|
+
{game
|
|
224
|
+
? translate({id: 'preset.reconcile.restart', message: 'Restart', description: 'Button that restarts the reconciliation game'})
|
|
225
|
+
: translate({id: 'preset.reconcile.start', message: 'Open the statement', description: 'Button that starts the reconciliation game'})}
|
|
226
|
+
</button>
|
|
227
|
+
<p className={styles.hint} role="status" aria-live="polite">
|
|
228
|
+
{last && last.result === 'matched' && translate({id: 'preset.reconcile.feedback.matched', message: 'Settled.', description: 'Feedback after a correct match'})}
|
|
229
|
+
{last && last.result === 'caught' && translate({id: 'preset.reconcile.feedback.caught', message: 'That is the one. It was never going anywhere.', description: 'Feedback after catching the fraudulent line'})}
|
|
230
|
+
{last && last.result === 'mismatch' && translate({id: 'preset.reconcile.feedback.mismatch', message: 'That payment is not for that invoice.', description: 'Feedback after matching the wrong invoice'})}
|
|
231
|
+
{last && last.result === 'paidFraud' && translate({id: 'preset.reconcile.feedback.paidFraud', message: 'You just paid the line that belongs to nobody.', description: 'Feedback after matching the fraudulent payment to an invoice'})}
|
|
232
|
+
{last && last.result === 'flaggedGood' && translate({id: 'preset.reconcile.feedback.flaggedGood', message: 'That one was real. Flag everything and nobody reads your flags.', description: 'Feedback after flagging a genuine payment'})}
|
|
233
|
+
{last && last.result === 'monthClosed' && translate({id: 'preset.reconcile.feedback.closed', message: 'The month closed with lines still open.', description: 'Feedback after the clock runs out'})}
|
|
234
|
+
{last && last.result === 'sheet' && translate({id: 'preset.reconcile.feedback.sheet', message: 'Statement clear. Here comes the next one.', description: 'Feedback after clearing a whole sheet'})}
|
|
235
|
+
{!last && translate({id: 'preset.reconcile.hint', message: 'Pick up a payment, then click the invoice it settles. The one that fits nothing gets flagged.', description: 'Hint under the reconciliation sheet'})}
|
|
236
|
+
</p>
|
|
237
|
+
</footer>
|
|
238
|
+
</section>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <Reconcile /> styles.
|
|
3
|
+
*
|
|
4
|
+
* Two columns that read like a statement beside a ledger. Tokens only,
|
|
5
|
+
* one accent on the clock when the month is nearly up. A picked-up
|
|
6
|
+
* payment says so in its pressed state as well as its border.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.rc {
|
|
10
|
+
border: 1px solid var(--c-cobalt-100);
|
|
11
|
+
border-radius: var(--radius-lg);
|
|
12
|
+
background: white;
|
|
13
|
+
padding: clamp(16px, 3vw, 28px);
|
|
14
|
+
font-family: var(--conduction-typography-font-family-body);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.head {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
gap: 16px;
|
|
21
|
+
align-items: flex-start;
|
|
22
|
+
justify-content: space-between;
|
|
23
|
+
margin-bottom: 16px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.eyebrow {
|
|
27
|
+
margin: 0 0 4px;
|
|
28
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
29
|
+
font-size: 11px;
|
|
30
|
+
letter-spacing: 0.12em;
|
|
31
|
+
text-transform: uppercase;
|
|
32
|
+
color: var(--c-orange-knvb);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.title {
|
|
36
|
+
margin: 0 0 6px;
|
|
37
|
+
font-size: 20px;
|
|
38
|
+
font-weight: 700;
|
|
39
|
+
color: var(--c-cobalt-900);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.lede {
|
|
43
|
+
margin: 0;
|
|
44
|
+
max-width: 60ch;
|
|
45
|
+
font-size: 14px;
|
|
46
|
+
line-height: 1.5;
|
|
47
|
+
color: var(--c-cobalt-700);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.hud {
|
|
51
|
+
display: flex;
|
|
52
|
+
gap: 8px;
|
|
53
|
+
flex-wrap: wrap;
|
|
54
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
55
|
+
font-size: 12px;
|
|
56
|
+
font-variant-numeric: tabular-nums;
|
|
57
|
+
}
|
|
58
|
+
.hudPill {
|
|
59
|
+
padding: 6px 10px;
|
|
60
|
+
border-radius: var(--radius-pill);
|
|
61
|
+
background: var(--c-cobalt-50);
|
|
62
|
+
color: var(--c-cobalt-900);
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.sheet {
|
|
67
|
+
display: grid;
|
|
68
|
+
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
|
69
|
+
gap: 16px;
|
|
70
|
+
max-width: 720px;
|
|
71
|
+
}
|
|
72
|
+
@media (max-width: 700px) {
|
|
73
|
+
.sheet { grid-template-columns: 1fr; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.column {
|
|
77
|
+
padding: 12px;
|
|
78
|
+
border-radius: var(--radius-md);
|
|
79
|
+
background: var(--c-cobalt-50);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.columnHead {
|
|
83
|
+
margin: 0 0 10px;
|
|
84
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
85
|
+
font-size: 11px;
|
|
86
|
+
letter-spacing: 0.08em;
|
|
87
|
+
text-transform: uppercase;
|
|
88
|
+
color: var(--c-cobalt-400);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.list {
|
|
92
|
+
list-style: none;
|
|
93
|
+
margin: 0;
|
|
94
|
+
padding: 0;
|
|
95
|
+
display: flex;
|
|
96
|
+
flex-direction: column;
|
|
97
|
+
gap: 6px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.row { display: flex; gap: 6px; }
|
|
101
|
+
.rowDone {
|
|
102
|
+
display: flex;
|
|
103
|
+
gap: 6px;
|
|
104
|
+
opacity: 0.4;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.pick,
|
|
108
|
+
.drop {
|
|
109
|
+
flex: 1;
|
|
110
|
+
display: flex;
|
|
111
|
+
align-items: baseline;
|
|
112
|
+
justify-content: space-between;
|
|
113
|
+
gap: 10px;
|
|
114
|
+
padding: 9px 12px;
|
|
115
|
+
border: 1px solid var(--c-cobalt-200);
|
|
116
|
+
border-radius: var(--radius-md);
|
|
117
|
+
background: white;
|
|
118
|
+
font-family: inherit;
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
text-align: left;
|
|
121
|
+
transition: border-color 120ms ease, background 120ms ease;
|
|
122
|
+
}
|
|
123
|
+
.pick:hover:not(:disabled),
|
|
124
|
+
.drop:hover:not(:disabled) { border-color: var(--c-blue-cobalt); }
|
|
125
|
+
.pick:disabled,
|
|
126
|
+
.drop:disabled { cursor: default; }
|
|
127
|
+
.pick:focus-visible,
|
|
128
|
+
.drop:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
129
|
+
|
|
130
|
+
/* The payment in hand. */
|
|
131
|
+
.picked {
|
|
132
|
+
border-color: var(--c-blue-cobalt);
|
|
133
|
+
background: var(--c-cobalt-50);
|
|
134
|
+
box-shadow: inset 0 0 0 1px var(--c-blue-cobalt);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.amount {
|
|
138
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
139
|
+
font-size: 13px;
|
|
140
|
+
font-weight: 600;
|
|
141
|
+
color: var(--c-cobalt-900);
|
|
142
|
+
font-variant-numeric: tabular-nums;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.ref {
|
|
146
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
147
|
+
font-size: 11px;
|
|
148
|
+
color: var(--c-cobalt-400);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.flag {
|
|
152
|
+
padding: 9px 10px;
|
|
153
|
+
border: 1px solid var(--c-cobalt-200);
|
|
154
|
+
border-radius: var(--radius-md);
|
|
155
|
+
background: white;
|
|
156
|
+
font-family: inherit;
|
|
157
|
+
font-size: 12px;
|
|
158
|
+
color: var(--c-cobalt-700);
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
transition: border-color 120ms ease, color 120ms ease;
|
|
161
|
+
}
|
|
162
|
+
.flag:hover:not(:disabled) { border-color: var(--c-orange-knvb); color: var(--c-orange-knvb); }
|
|
163
|
+
.flag:disabled { cursor: default; opacity: 0.6; }
|
|
164
|
+
.flag:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
165
|
+
|
|
166
|
+
.clock {
|
|
167
|
+
height: 6px;
|
|
168
|
+
border-radius: var(--radius-pill);
|
|
169
|
+
background: var(--c-cobalt-200);
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
margin-top: 12px;
|
|
172
|
+
max-width: 720px;
|
|
173
|
+
}
|
|
174
|
+
.clockFill {
|
|
175
|
+
height: 100%;
|
|
176
|
+
background: var(--c-mint-500);
|
|
177
|
+
transition: width 100ms linear;
|
|
178
|
+
}
|
|
179
|
+
.clockLow { background: var(--c-orange-knvb); }
|
|
180
|
+
|
|
181
|
+
.idle {
|
|
182
|
+
margin: 0;
|
|
183
|
+
font-size: 14px;
|
|
184
|
+
color: var(--c-cobalt-400);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.foot {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-wrap: wrap;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 10px;
|
|
192
|
+
margin-top: 14px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.start {
|
|
196
|
+
background: var(--c-blue-cobalt);
|
|
197
|
+
color: white;
|
|
198
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
199
|
+
padding: 10px 18px;
|
|
200
|
+
border-radius: var(--radius-md);
|
|
201
|
+
font-family: inherit;
|
|
202
|
+
font-weight: 500;
|
|
203
|
+
font-size: 14px;
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
transition: background 120ms ease;
|
|
206
|
+
}
|
|
207
|
+
.start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
208
|
+
.start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
209
|
+
|
|
210
|
+
.hint {
|
|
211
|
+
margin: 0;
|
|
212
|
+
flex-basis: 100%;
|
|
213
|
+
font-size: 12px;
|
|
214
|
+
color: var(--c-cobalt-400);
|
|
215
|
+
max-width: 60ch;
|
|
216
|
+
min-height: 1.4em;
|
|
217
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.test.js — the reconciliation rules.
|
|
3
|
+
*
|
|
4
|
+
* Two properties carry this one. Every sheet must be solvable: each
|
|
5
|
+
* genuine payment matches exactly one invoice, and the odd line out
|
|
6
|
+
* matches none, or the right answer is unknowable and the player is
|
|
7
|
+
* being asked to guess. And crying wolf must cost, because a game that
|
|
8
|
+
* only punished missing the fraud would teach you to flag every line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const test = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
createGame, match, flag, step, clockMs, remaining, sheetDone, summarise, DEFAULTS,
|
|
18
|
+
} = require('../engine.js');
|
|
19
|
+
|
|
20
|
+
const fraudOf = (s) => s.sheet.payments.find((p) => p.fraud);
|
|
21
|
+
const genuine = (s) => s.sheet.payments.filter((p) => !p.fraud);
|
|
22
|
+
const invoiceFor = (s, payment) => s.sheet.invoices.find((i) => i.amount === payment.amount);
|
|
23
|
+
|
|
24
|
+
/** Clear a sheet properly: match everything, flag the odd one out. */
|
|
25
|
+
function clearSheet(state, now = 0) {
|
|
26
|
+
let s = state;
|
|
27
|
+
for (const payment of genuine(s)) {
|
|
28
|
+
s = match(s, payment.id, invoiceFor(s, payment).id, now);
|
|
29
|
+
}
|
|
30
|
+
const odd = fraudOf(s);
|
|
31
|
+
return odd ? flag(s, odd.id, now) : s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('a sheet always has one payment too many, and it is the fraud', () => {
|
|
35
|
+
for (let seed = 1; seed <= 30; seed++) {
|
|
36
|
+
const s = createGame({seed, now: 0});
|
|
37
|
+
assert.equal(s.sheet.invoices.length, DEFAULTS.invoices);
|
|
38
|
+
assert.equal(s.sheet.payments.length, DEFAULTS.invoices + 1);
|
|
39
|
+
assert.equal(s.sheet.payments.filter((p) => p.fraud).length, 1, `seed ${seed}: not exactly one fraud line`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('every genuine payment settles exactly one invoice, and the fraud settles none', () => {
|
|
44
|
+
for (let seed = 1; seed <= 30; seed++) {
|
|
45
|
+
const s = createGame({seed, now: 0});
|
|
46
|
+
for (const payment of genuine(s)) {
|
|
47
|
+
const hits = s.sheet.invoices.filter((i) => i.amount === payment.amount);
|
|
48
|
+
assert.equal(hits.length, 1, `seed ${seed}: a payment matched ${hits.length} invoices`);
|
|
49
|
+
}
|
|
50
|
+
const odd = fraudOf(s);
|
|
51
|
+
assert.equal(
|
|
52
|
+
s.sheet.invoices.filter((i) => i.amount === odd.amount).length,
|
|
53
|
+
0,
|
|
54
|
+
`seed ${seed}: the fraud line matches a real invoice, so it cannot be told apart`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('matching a payment to its invoice pays and settles both sides', () => {
|
|
60
|
+
let s = createGame({seed: 2, now: 0});
|
|
61
|
+
const payment = genuine(s)[0];
|
|
62
|
+
const invoice = invoiceFor(s, payment);
|
|
63
|
+
s = match(s, payment.id, invoice.id, 10);
|
|
64
|
+
assert.equal(s.matched, 1);
|
|
65
|
+
assert.equal(s.score, DEFAULTS.pointsPerMatch);
|
|
66
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
67
|
+
assert.equal(s.sheet.payments.find((p) => p.id === payment.id).done, true);
|
|
68
|
+
assert.equal(s.sheet.invoices.find((i) => i.id === invoice.id).settled, true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('sending a payment to the wrong invoice costs a life', () => {
|
|
72
|
+
let s = createGame({seed: 3, now: 0});
|
|
73
|
+
const payment = genuine(s)[0];
|
|
74
|
+
const wrong = s.sheet.invoices.find((i) => i.amount !== payment.amount);
|
|
75
|
+
s = match(s, payment.id, wrong.id, 20);
|
|
76
|
+
assert.equal(s.lives, DEFAULTS.lives - 1);
|
|
77
|
+
assert.equal(s.last.result, 'mismatch');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('paying the fraud line costs a life wherever it is sent', () => {
|
|
81
|
+
let s = createGame({seed: 4, now: 0});
|
|
82
|
+
const odd = fraudOf(s);
|
|
83
|
+
s = match(s, odd.id, s.sheet.invoices[0].id, 20);
|
|
84
|
+
assert.equal(s.lives, DEFAULTS.lives - 1);
|
|
85
|
+
assert.equal(s.last.result, 'paidFraud');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('catching the fraud pays more than a match', () => {
|
|
89
|
+
let s = createGame({seed: 5, now: 0});
|
|
90
|
+
s = flag(s, fraudOf(s).id, 30);
|
|
91
|
+
assert.equal(s.caught, 1);
|
|
92
|
+
assert.equal(s.score, DEFAULTS.pointsPerFraud);
|
|
93
|
+
assert.ok(DEFAULTS.pointsPerFraud > DEFAULTS.pointsPerMatch);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('crying wolf at a genuine payment costs a life', () => {
|
|
97
|
+
let s = createGame({seed: 6, now: 0});
|
|
98
|
+
s = flag(s, genuine(s)[0].id, 30);
|
|
99
|
+
assert.equal(s.lives, DEFAULTS.lives - 1);
|
|
100
|
+
assert.equal(s.last.result, 'flaggedGood');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a handled line cannot be handled twice', () => {
|
|
104
|
+
let s = createGame({seed: 7, now: 0});
|
|
105
|
+
const payment = genuine(s)[0];
|
|
106
|
+
s = match(s, payment.id, invoiceFor(s, payment).id, 10);
|
|
107
|
+
const score = s.score;
|
|
108
|
+
s = match(s, payment.id, invoiceFor(s, payment).id, 20);
|
|
109
|
+
assert.equal(s.score, score, 'the same line paid twice');
|
|
110
|
+
assert.equal(s.lives, DEFAULTS.lives, 'a duplicate click was punished');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('clearing a sheet pays a bonus and deals the next one', () => {
|
|
114
|
+
let s = createGame({seed: 8, now: 0});
|
|
115
|
+
s = clearSheet(s, 0);
|
|
116
|
+
assert.equal(s.sheets, 1);
|
|
117
|
+
assert.ok(s.sheet, 'no next sheet arrived');
|
|
118
|
+
assert.equal(sheetDone(s), false, 'the next sheet arrived already finished');
|
|
119
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the month closing on an unfinished sheet costs a life', () => {
|
|
123
|
+
let s = createGame({seed: 9, now: 0});
|
|
124
|
+
const closes = s.sheet.expiresAt;
|
|
125
|
+
s = step(s, closes + 1);
|
|
126
|
+
assert.equal(s.lives, DEFAULTS.lives - 1);
|
|
127
|
+
assert.equal(s.last.result, 'monthClosed');
|
|
128
|
+
assert.ok(s.sheet, 'no fresh sheet after the month closed');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('three mistakes end it, and nothing moves afterwards', () => {
|
|
132
|
+
let s = createGame({seed: 10, now: 0});
|
|
133
|
+
for (let i = 0; i < DEFAULTS.lives; i++) {
|
|
134
|
+
const good = genuine(s).find((p) => !p.done);
|
|
135
|
+
s = flag(s, good.id, 10 * i);
|
|
136
|
+
}
|
|
137
|
+
assert.equal(s.over, true);
|
|
138
|
+
assert.equal(s.lives, 0);
|
|
139
|
+
const frozen = s.sheet ? match(s, genuine(s)[0].id, s.sheet.invoices[0].id, 99) : s;
|
|
140
|
+
assert.equal(frozen.score, s.score);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the clock tightens with the score, down to a readable floor', () => {
|
|
144
|
+
const fresh = createGame({seed: 11, now: 0});
|
|
145
|
+
assert.equal(clockMs(fresh), DEFAULTS.clockStartMs);
|
|
146
|
+
assert.ok(clockMs({...fresh, score: 60}) < DEFAULTS.clockStartMs);
|
|
147
|
+
assert.equal(clockMs({...fresh, score: 9000}), DEFAULTS.clockFloorMs);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the countdown runs full to empty and no further', () => {
|
|
151
|
+
const s = createGame({seed: 12, now: 0});
|
|
152
|
+
assert.equal(remaining(s, 0), 1);
|
|
153
|
+
assert.equal(remaining(s, DEFAULTS.clockStartMs * 3), 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a careful bookkeeper clears sheet after sheet without losing a life', () => {
|
|
157
|
+
let s = createGame({seed: 13, now: 0});
|
|
158
|
+
for (let i = 0; i < 8 && !s.over; i++) s = clearSheet(s, i * 100);
|
|
159
|
+
assert.equal(s.lives, DEFAULTS.lives);
|
|
160
|
+
assert.equal(s.sheets, 8);
|
|
161
|
+
assert.ok(s.score > 0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('the summary reads as a sentence in both locales', () => {
|
|
165
|
+
const s = {matched: 12, caught: 3};
|
|
166
|
+
assert.match(summarise(s, 'en'), /12 lines matched · 3 caught out/);
|
|
167
|
+
assert.match(summarise(s, 'nl'), /12 regels gematcht · 3 keer fraude gevonden/);
|
|
168
|
+
});
|