@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile — the rules, with no DOM and no clock of its own.
|
|
3
|
+
*
|
|
4
|
+
* Shillinq's game. The bank statement is in and the invoices are open.
|
|
5
|
+
* Match each payment to the invoice it settles, before the month
|
|
6
|
+
* closes. One of the lines on the statement matches nothing at all,
|
|
7
|
+
* and sending that one to an invoice is how money goes missing
|
|
8
|
+
* quietly.
|
|
9
|
+
*
|
|
10
|
+
* So there are three moves, not two: match it, or flag it as the one
|
|
11
|
+
* that does not belong. Flagging a genuine payment is a mistake too,
|
|
12
|
+
* because a bookkeeper who cries wolf at every line is a bookkeeper
|
|
13
|
+
* nobody listens to.
|
|
14
|
+
*
|
|
15
|
+
* Time and randomness are injected; the component owns the clock.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/* Round amounts in whole euros: the game is about matching, not about
|
|
19
|
+
arithmetic with cents. */
|
|
20
|
+
const AMOUNTS = [120, 240, 360, 480, 750, 900, 1240, 1600, 2100, 3400];
|
|
21
|
+
const REFERENCES = ['2026-014', '2026-027', '2026-031', '2026-048', '2026-052', '2026-066'];
|
|
22
|
+
|
|
23
|
+
export const DEFAULTS = {
|
|
24
|
+
lives: 3,
|
|
25
|
+
invoices: 4,
|
|
26
|
+
clockStartMs: 24000,
|
|
27
|
+
clockFloorMs: 9000,
|
|
28
|
+
rampPerPoint: 90,
|
|
29
|
+
pointsPerMatch: 8,
|
|
30
|
+
pointsPerFraud: 20,
|
|
31
|
+
pointsPerSheet: 12,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function mulberry32(seed) {
|
|
35
|
+
let a = seed >>> 0;
|
|
36
|
+
return function random() {
|
|
37
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
38
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
39
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
40
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function shuffled(list, random) {
|
|
45
|
+
const out = [...list];
|
|
46
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
47
|
+
const j = Math.floor(random() * (i + 1));
|
|
48
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clockMs(state) {
|
|
54
|
+
return Math.max(state.cfg.clockFloorMs, state.cfg.clockStartMs - state.score * state.cfg.rampPerPoint);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Deal a sheet: N invoices, N payments that settle them, and one more
|
|
59
|
+
* payment that settles nothing.
|
|
60
|
+
*
|
|
61
|
+
* The odd one out is built from an amount no invoice carries, because
|
|
62
|
+
* a fraud line that happens to equal a real invoice would make the
|
|
63
|
+
* right answer unknowable.
|
|
64
|
+
*/
|
|
65
|
+
function deal(state, now) {
|
|
66
|
+
const amounts = shuffled(AMOUNTS, state.random).slice(0, state.cfg.invoices);
|
|
67
|
+
const references = shuffled(REFERENCES, state.random);
|
|
68
|
+
|
|
69
|
+
const invoices = amounts.map((amount, i) => ({
|
|
70
|
+
id: `inv-${i}`,
|
|
71
|
+
reference: references[i % references.length],
|
|
72
|
+
amount,
|
|
73
|
+
settled: false,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const payments = invoices.map((invoice, i) => ({
|
|
77
|
+
id: `pay-${i}`,
|
|
78
|
+
amount: invoice.amount,
|
|
79
|
+
reference: invoice.reference,
|
|
80
|
+
fraud: false,
|
|
81
|
+
done: false,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
const spare = AMOUNTS.filter((a) => !amounts.includes(a));
|
|
85
|
+
payments.push({
|
|
86
|
+
id: 'pay-odd',
|
|
87
|
+
amount: spare[Math.floor(state.random() * spare.length)],
|
|
88
|
+
/* A reference nobody issued: close enough to look filed, wrong
|
|
89
|
+
enough to be findable. */
|
|
90
|
+
reference: `2026-${900 + Math.floor(state.random() * 90)}`,
|
|
91
|
+
fraud: true,
|
|
92
|
+
done: false,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...state,
|
|
97
|
+
sheet: {
|
|
98
|
+
invoices,
|
|
99
|
+
payments: shuffled(payments, state.random),
|
|
100
|
+
startedAt: now,
|
|
101
|
+
expiresAt: now + clockMs(state),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
|
|
107
|
+
const cfg = {...DEFAULTS, ...config};
|
|
108
|
+
const base = {
|
|
109
|
+
cfg,
|
|
110
|
+
random: mulberry32(seed),
|
|
111
|
+
sheet: null,
|
|
112
|
+
score: 0,
|
|
113
|
+
lives: cfg.lives,
|
|
114
|
+
matched: 0,
|
|
115
|
+
caught: 0,
|
|
116
|
+
mistakes: 0,
|
|
117
|
+
sheets: 0,
|
|
118
|
+
last: null,
|
|
119
|
+
over: false,
|
|
120
|
+
};
|
|
121
|
+
return deal(base, now);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function remaining(state, now) {
|
|
125
|
+
if (!state.sheet) return 0;
|
|
126
|
+
const total = state.sheet.expiresAt - state.sheet.startedAt;
|
|
127
|
+
if (total <= 0) return 0;
|
|
128
|
+
return Math.min(1, Math.max(0, (state.sheet.expiresAt - now) / total));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Everything on this sheet handled? */
|
|
132
|
+
export function sheetDone(state) {
|
|
133
|
+
return Boolean(state.sheet) && state.sheet.payments.every((p) => p.done);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loseLife(state, reason, now) {
|
|
137
|
+
const lives = state.lives - 1;
|
|
138
|
+
return {
|
|
139
|
+
...state,
|
|
140
|
+
lives,
|
|
141
|
+
mistakes: state.mistakes + 1,
|
|
142
|
+
last: {result: reason, at: now},
|
|
143
|
+
over: lives <= 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function advance(state, now) {
|
|
148
|
+
if (!sheetDone(state)) return state;
|
|
149
|
+
const cleared = {
|
|
150
|
+
...state,
|
|
151
|
+
score: state.score + state.cfg.pointsPerSheet,
|
|
152
|
+
sheets: state.sheets + 1,
|
|
153
|
+
last: {result: 'sheet', at: now},
|
|
154
|
+
};
|
|
155
|
+
return deal(cleared, now);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Send a payment to an invoice.
|
|
160
|
+
*
|
|
161
|
+
* Right pays, wrong costs a life, and the fraud line costs a life
|
|
162
|
+
* wherever it is sent: that is the mistake the app exists to prevent.
|
|
163
|
+
*/
|
|
164
|
+
export function match(state, paymentId, invoiceId, now) {
|
|
165
|
+
if (state.over || !state.sheet) return state;
|
|
166
|
+
const payment = state.sheet.payments.find((p) => p.id === paymentId);
|
|
167
|
+
const invoice = state.sheet.invoices.find((i) => i.id === invoiceId);
|
|
168
|
+
if (!payment || !invoice || payment.done || invoice.settled) return state;
|
|
169
|
+
|
|
170
|
+
if (payment.fraud || payment.amount !== invoice.amount) {
|
|
171
|
+
return loseLife(state, payment.fraud ? 'paidFraud' : 'mismatch', now);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const next = {
|
|
175
|
+
...state,
|
|
176
|
+
sheet: {
|
|
177
|
+
...state.sheet,
|
|
178
|
+
payments: state.sheet.payments.map((p) => (p.id === paymentId ? {...p, done: true} : p)),
|
|
179
|
+
invoices: state.sheet.invoices.map((i) => (i.id === invoiceId ? {...i, settled: true} : i)),
|
|
180
|
+
},
|
|
181
|
+
score: state.score + state.cfg.pointsPerMatch,
|
|
182
|
+
matched: state.matched + 1,
|
|
183
|
+
last: {result: 'matched', at: now},
|
|
184
|
+
};
|
|
185
|
+
return advance(next, now);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Flag a payment as the one that belongs to nobody.
|
|
190
|
+
*
|
|
191
|
+
* Crying wolf at a genuine payment costs a life too. A game that only
|
|
192
|
+
* punished missing the fraud would teach you to flag everything.
|
|
193
|
+
*/
|
|
194
|
+
export function flag(state, paymentId, now) {
|
|
195
|
+
if (state.over || !state.sheet) return state;
|
|
196
|
+
const payment = state.sheet.payments.find((p) => p.id === paymentId);
|
|
197
|
+
if (!payment || payment.done) return state;
|
|
198
|
+
|
|
199
|
+
if (!payment.fraud) return loseLife(state, 'flaggedGood', now);
|
|
200
|
+
|
|
201
|
+
const next = {
|
|
202
|
+
...state,
|
|
203
|
+
sheet: {
|
|
204
|
+
...state.sheet,
|
|
205
|
+
payments: state.sheet.payments.map((p) => (p.id === paymentId ? {...p, done: true} : p)),
|
|
206
|
+
},
|
|
207
|
+
score: state.score + state.cfg.pointsPerFraud,
|
|
208
|
+
caught: state.caught + 1,
|
|
209
|
+
last: {result: 'caught', at: now},
|
|
210
|
+
};
|
|
211
|
+
return advance(next, now);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The month closes on its own, and an unfinished sheet costs a life. */
|
|
215
|
+
export function step(state, now) {
|
|
216
|
+
if (state.over || !state.sheet) return state;
|
|
217
|
+
if (now < state.sheet.expiresAt) return state;
|
|
218
|
+
const hit = loseLife(state, 'monthClosed', now);
|
|
219
|
+
return hit.over ? {...hit, sheet: null} : deal(hit, now);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The line that goes on the game-over card and into the post. */
|
|
223
|
+
export function summarise(state, locale = 'en') {
|
|
224
|
+
const n = (v) => Number(v || 0).toLocaleString(locale);
|
|
225
|
+
return locale === 'nl'
|
|
226
|
+
? `${n(state.matched)} regels gematcht · ${n(state.caught)} keer fraude gevonden`
|
|
227
|
+
: `${n(state.matched)} lines matched · ${n(state.caught)} caught out`;
|
|
228
|
+
}
|
package/src/components/index.js
CHANGED
|
@@ -66,6 +66,9 @@ export {default as BlueprintRush} from './BlueprintRush/BlueprintRush.jsx';
|
|
|
66
66
|
export {default as RecordRun} from './RecordRun/RecordRun.jsx';
|
|
67
67
|
export {default as HiddenGame} from './HiddenGame/HiddenGame.jsx';
|
|
68
68
|
export {default as MonsterRun} from './MonsterRun/MonsterRun.jsx';
|
|
69
|
+
export {default as DiceDuel} from './DiceDuel/DiceDuel.jsx';
|
|
70
|
+
export {default as Reconcile} from './Reconcile/Reconcile.jsx';
|
|
71
|
+
export {default as PipeFit} from './PipeFit/PipeFit.jsx';
|
|
69
72
|
export {default as LockPick} from './LockPick/LockPick.jsx';
|
|
70
73
|
export {default as PaintByTokens} from './PaintByTokens/PaintByTokens.jsx';
|
|
71
74
|
export {default as Redaction} from './Redaction/Redaction.jsx';
|