@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,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.test.js — the pipe-fit rules.
|
|
3
|
+
*
|
|
4
|
+
* The property everything rests on: every route dealt can actually be
|
|
5
|
+
* finished. The route is built from a working line and then scrambled,
|
|
6
|
+
* so a solution exists by construction, and the test proves it by
|
|
7
|
+
* finding one. An unsolvable puzzle on a clock reads as a broken game,
|
|
8
|
+
* and the player has no way to tell the difference.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const test = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
createGame, turn, step, connected, openings, clockMs, remaining, summarise,
|
|
18
|
+
PORTS, DEFAULTS,
|
|
19
|
+
} = require('../engine.js');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Solve a route by walking it: each piece has exactly one turn that
|
|
23
|
+
* meets what is carried in, so the line settles in one pass.
|
|
24
|
+
*/
|
|
25
|
+
function solve(state, now = 0) {
|
|
26
|
+
let s = state;
|
|
27
|
+
const finished = s.routes;
|
|
28
|
+
for (let i = 0; i < s.route.pieces.length; i++) {
|
|
29
|
+
/* Whatever the piece before it hands over. */
|
|
30
|
+
let carry = s.route.source;
|
|
31
|
+
for (let k = 0; k < i; k++) carry = openings(s.route.pieces[k]).right;
|
|
32
|
+
|
|
33
|
+
for (let t = 0; t < PORTS; t++) {
|
|
34
|
+
if (openings(s.route.pieces[i]).left === carry) break;
|
|
35
|
+
s = turn(s, i, now);
|
|
36
|
+
/* The last turn of a route replaces it with the next one, so
|
|
37
|
+
stop here: carrying on would solve a route nobody asked for,
|
|
38
|
+
which is how this walk first read as a failure. */
|
|
39
|
+
if (s.over || s.routes > finished) return s;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return s;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('a route is dealt, out of true, with a source and a target', () => {
|
|
46
|
+
const s = createGame({seed: 1, now: 0});
|
|
47
|
+
assert.equal(s.route.pieces.length, DEFAULTS.lengthStart);
|
|
48
|
+
assert.ok(s.route.source >= 0 && s.route.source < PORTS);
|
|
49
|
+
assert.ok(s.route.target >= 0 && s.route.target < PORTS);
|
|
50
|
+
assert.equal(connected(s.route), false, 'the route was dealt already finished');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('every route dealt can be finished', () => {
|
|
54
|
+
/* Thirty seeds, several routes each: an unsolvable deal shows up as
|
|
55
|
+
one route in dozens, not as every route. */
|
|
56
|
+
for (let seed = 1; seed <= 30; seed++) {
|
|
57
|
+
let s = createGame({seed, now: 0});
|
|
58
|
+
for (let round = 0; round < 4; round++) {
|
|
59
|
+
const before = s.routes;
|
|
60
|
+
s = solve(s, round * 10);
|
|
61
|
+
assert.equal(s.routes, before + 1, `seed ${seed}: a route could not be finished`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('turning moves both openings together, which is the whole puzzle', () => {
|
|
67
|
+
const piece = {shape: {left: 0, right: 1}, turn: 0};
|
|
68
|
+
const before = openings(piece);
|
|
69
|
+
const after = openings({...piece, turn: 1});
|
|
70
|
+
assert.equal(after.left, (before.left + 1) % PORTS);
|
|
71
|
+
assert.equal(after.right, (before.right + 1) % PORTS);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a line is only connected when every join meets and it reaches the target', () => {
|
|
75
|
+
const route = {
|
|
76
|
+
source: 0,
|
|
77
|
+
target: 2,
|
|
78
|
+
pieces: [{shape: {left: 0, right: 1}, turn: 0}, {shape: {left: 1, right: 2}, turn: 0}],
|
|
79
|
+
};
|
|
80
|
+
assert.equal(connected(route), true);
|
|
81
|
+
|
|
82
|
+
const broken = {...route, pieces: [{...route.pieces[0], turn: 1}, route.pieces[1]]};
|
|
83
|
+
assert.equal(connected(broken), false, 'a broken join read as connected');
|
|
84
|
+
|
|
85
|
+
const wrongTarget = {...route, target: 0};
|
|
86
|
+
assert.equal(connected(wrongTarget), false, 'a line that ends nowhere read as connected');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('finishing a route pays a bonus and deals the next one', () => {
|
|
90
|
+
let s = createGame({seed: 3, now: 0});
|
|
91
|
+
s = solve(s, 0);
|
|
92
|
+
assert.equal(s.routes, 1);
|
|
93
|
+
assert.ok(s.score >= DEFAULTS.pointsPerRoute);
|
|
94
|
+
assert.ok(s.route, 'no next route arrived');
|
|
95
|
+
assert.equal(connected(s.route), false);
|
|
96
|
+
assert.equal(s.lives, DEFAULTS.lives, 'finishing a route cost a life');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('routes get longer, but never longer than a person will finish', () => {
|
|
100
|
+
let s = createGame({seed: 4, now: 0});
|
|
101
|
+
const lengths = [s.route.pieces.length];
|
|
102
|
+
for (let i = 0; i < 12 && !s.over; i++) {
|
|
103
|
+
s = solve(s, i * 10);
|
|
104
|
+
lengths.push(s.route.pieces.length);
|
|
105
|
+
}
|
|
106
|
+
assert.ok(Math.max(...lengths) > DEFAULTS.lengthStart, 'the routes never grew');
|
|
107
|
+
assert.ok(Math.max(...lengths) <= DEFAULTS.lengthMax, 'a route grew past its own ceiling');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('turning is never punished, only the route that is never finished', () => {
|
|
111
|
+
let s = createGame({seed: 5, now: 0});
|
|
112
|
+
for (let i = 0; i < 20; i++) s = turn(s, i % s.route.pieces.length, 10);
|
|
113
|
+
assert.equal(s.lives, DEFAULTS.lives, 'fiddling with the connectors cost a life');
|
|
114
|
+
assert.ok(s.turns >= 20);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a connector that does not exist is ignored', () => {
|
|
118
|
+
const s = createGame({seed: 6, now: 0});
|
|
119
|
+
assert.equal(turn(s, 99, 0), s);
|
|
120
|
+
assert.equal(turn(s, -1, 0), s);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('the payload arriving on an unfinished route costs a life', () => {
|
|
124
|
+
let s = createGame({seed: 7, now: 0});
|
|
125
|
+
const arrives = s.route.expiresAt;
|
|
126
|
+
s = step(s, arrives + 1);
|
|
127
|
+
assert.equal(s.lives, DEFAULTS.lives - 1);
|
|
128
|
+
assert.equal(s.last.result, 'spilled');
|
|
129
|
+
assert.ok(s.route, 'no fresh route after the spill');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('three spills end it, and nothing turns afterwards', () => {
|
|
133
|
+
let s = createGame({seed: 8, now: 0});
|
|
134
|
+
let t = 0;
|
|
135
|
+
for (let i = 0; i < DEFAULTS.lives; i++) {
|
|
136
|
+
t = s.route.expiresAt + 1;
|
|
137
|
+
s = step(s, t);
|
|
138
|
+
}
|
|
139
|
+
assert.equal(s.over, true);
|
|
140
|
+
assert.equal(s.lives, 0);
|
|
141
|
+
assert.equal(turn(s, 0, t + 10).turns, s.turns);
|
|
142
|
+
assert.equal(step(s, t + 10000).lives, 0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('nothing spills before the payload is due', () => {
|
|
146
|
+
const s = createGame({seed: 9, now: 0});
|
|
147
|
+
assert.equal(step(s, s.route.expiresAt - 1).lives, DEFAULTS.lives);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the clock tightens with the score, down to a workable floor', () => {
|
|
151
|
+
const fresh = createGame({seed: 10, now: 0});
|
|
152
|
+
assert.equal(clockMs(fresh), DEFAULTS.clockStartMs);
|
|
153
|
+
assert.ok(clockMs({...fresh, score: 80}) < DEFAULTS.clockStartMs);
|
|
154
|
+
assert.equal(clockMs({...fresh, score: 9000}), DEFAULTS.clockFloorMs);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('the countdown runs full to empty and no further', () => {
|
|
158
|
+
const s = createGame({seed: 11, now: 0});
|
|
159
|
+
assert.equal(remaining(s, 0), 1);
|
|
160
|
+
assert.equal(remaining(s, DEFAULTS.clockStartMs * 3), 0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the same seed deals the same routes, a different one does not', () => {
|
|
164
|
+
const shape = (seed) => {
|
|
165
|
+
const s = createGame({seed, now: 0});
|
|
166
|
+
return `${s.route.source}>${s.route.pieces.map((p) => `${p.shape.left}${p.shape.right}:${p.turn}`).join(',')}>${s.route.target}`;
|
|
167
|
+
};
|
|
168
|
+
assert.equal(shape(21), shape(21));
|
|
169
|
+
assert.notEqual(shape(21), shape(22));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the summary reads as a sentence in both locales', () => {
|
|
173
|
+
const s = {routes: 5, turns: 34};
|
|
174
|
+
assert.match(summarise(s, 'en'), /5 routes connected · 34 turns/);
|
|
175
|
+
assert.match(summarise(s, 'nl'), /5 koppelingen gelegd · 34 keer gedraaid/);
|
|
176
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe fit — the rules, with no DOM and no clock of its own.
|
|
3
|
+
*
|
|
4
|
+
* Integriq's game. A record is leaving one system for another and the
|
|
5
|
+
* route between them is half built. Turn each connector until the line
|
|
6
|
+
* runs end to end, before the payload arrives and finds a gap.
|
|
7
|
+
*
|
|
8
|
+
* A route is a row of connectors, each with an opening on its left and
|
|
9
|
+
* its right. Turning one moves both openings together, so fixing the
|
|
10
|
+
* join on one side can break the join on the other: that is the whole
|
|
11
|
+
* puzzle, and it is exactly what integrating two systems feels like.
|
|
12
|
+
*
|
|
13
|
+
* Time and randomness are injected; the component owns the clock.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* The three heights a connector can open at. A join works when the
|
|
17
|
+
right-hand opening of one meets the left-hand opening of the next. */
|
|
18
|
+
export const PORTS = 3;
|
|
19
|
+
|
|
20
|
+
export const DEFAULTS = {
|
|
21
|
+
lives: 3,
|
|
22
|
+
lengthStart: 3,
|
|
23
|
+
lengthMax: 6,
|
|
24
|
+
/* A route grows every few clears rather than every one: a puzzle
|
|
25
|
+
that gets longer each time outruns the clock before it gets
|
|
26
|
+
interesting. */
|
|
27
|
+
growEvery: 2,
|
|
28
|
+
clockStartMs: 26000,
|
|
29
|
+
clockFloorMs: 11000,
|
|
30
|
+
rampPerPoint: 55,
|
|
31
|
+
pointsPerTurn: 1,
|
|
32
|
+
pointsPerRoute: 20,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function mulberry32(seed) {
|
|
36
|
+
let a = seed >>> 0;
|
|
37
|
+
return function random() {
|
|
38
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
39
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
40
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
41
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clockMs(state) {
|
|
46
|
+
return Math.max(state.cfg.clockFloorMs, state.cfg.clockStartMs - state.score * state.cfg.rampPerPoint);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A connector is `{shape, turn}`. `shape` is the pair of openings as
|
|
51
|
+
* built, `turn` is how many times it has been turned; the openings
|
|
52
|
+
* that count are the shape rotated by the turn.
|
|
53
|
+
*/
|
|
54
|
+
export function openings(piece) {
|
|
55
|
+
return {
|
|
56
|
+
left: (piece.shape.left + piece.turn) % PORTS,
|
|
57
|
+
right: (piece.shape.right + piece.turn) % PORTS,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Does the whole route line up, source to consumer? */
|
|
62
|
+
export function connected(route) {
|
|
63
|
+
if (!route || !route.pieces.length) return false;
|
|
64
|
+
let carry = route.source;
|
|
65
|
+
for (const piece of route.pieces) {
|
|
66
|
+
const {left, right} = openings(piece);
|
|
67
|
+
if (left !== carry) return false;
|
|
68
|
+
carry = right;
|
|
69
|
+
}
|
|
70
|
+
return carry === route.target;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a route that is solvable, then turn the pieces out of true.
|
|
75
|
+
*
|
|
76
|
+
* Built from a working line rather than at random: a route assembled
|
|
77
|
+
* by chance can be unsolvable, and an unsolvable puzzle on a clock
|
|
78
|
+
* reads to the player as a broken game.
|
|
79
|
+
*/
|
|
80
|
+
function deal(state, now, length) {
|
|
81
|
+
const source = Math.floor(state.random() * PORTS);
|
|
82
|
+
let carry = source;
|
|
83
|
+
const pieces = [];
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < length; i++) {
|
|
86
|
+
const right = Math.floor(state.random() * PORTS);
|
|
87
|
+
/* The shape is stored as if unturned, so that turning it back is
|
|
88
|
+
always possible. */
|
|
89
|
+
pieces.push({shape: {left: carry, right}, turn: 0});
|
|
90
|
+
carry = right;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const route = {source, target: carry, pieces, startedAt: now, expiresAt: now + clockMs(state)};
|
|
94
|
+
|
|
95
|
+
/* Now scramble. Keep scrambling until it is actually out of true,
|
|
96
|
+
or a route could be dealt already solved. */
|
|
97
|
+
let scrambled = route;
|
|
98
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
99
|
+
scrambled = {
|
|
100
|
+
...route,
|
|
101
|
+
pieces: route.pieces.map((p) => ({...p, turn: Math.floor(state.random() * PORTS)})),
|
|
102
|
+
};
|
|
103
|
+
if (!connected(scrambled)) break;
|
|
104
|
+
}
|
|
105
|
+
return {...state, route: scrambled};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
|
|
109
|
+
const cfg = {...DEFAULTS, ...config};
|
|
110
|
+
const base = {
|
|
111
|
+
cfg,
|
|
112
|
+
random: mulberry32(seed),
|
|
113
|
+
route: null,
|
|
114
|
+
score: 0,
|
|
115
|
+
lives: cfg.lives,
|
|
116
|
+
routes: 0,
|
|
117
|
+
turns: 0,
|
|
118
|
+
last: null,
|
|
119
|
+
over: false,
|
|
120
|
+
};
|
|
121
|
+
return deal(base, now, cfg.lengthStart);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function remaining(state, now) {
|
|
125
|
+
if (!state.route) return 0;
|
|
126
|
+
const total = state.route.expiresAt - state.route.startedAt;
|
|
127
|
+
if (total <= 0) return 0;
|
|
128
|
+
return Math.min(1, Math.max(0, (state.route.expiresAt - now) / total));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function nextLength(state) {
|
|
132
|
+
const grown = state.cfg.lengthStart + Math.floor((state.routes + 1) / state.cfg.growEvery);
|
|
133
|
+
return Math.min(state.cfg.lengthMax, grown);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Turn one connector.
|
|
138
|
+
*
|
|
139
|
+
* Completing the route scores and deals the next one. Turning is never
|
|
140
|
+
* punished: the mistake this game is about is the route that was never
|
|
141
|
+
* finished, not the fiddling on the way there.
|
|
142
|
+
*/
|
|
143
|
+
export function turn(state, index, now) {
|
|
144
|
+
if (state.over || !state.route) return state;
|
|
145
|
+
if (!Number.isInteger(index) || index < 0 || index >= state.route.pieces.length) return state;
|
|
146
|
+
|
|
147
|
+
const pieces = state.route.pieces.map((p, i) => (i === index ? {...p, turn: (p.turn + 1) % PORTS} : p));
|
|
148
|
+
const route = {...state.route, pieces};
|
|
149
|
+
const turned = {...state, route, turns: state.turns + 1, score: state.score + state.cfg.pointsPerTurn};
|
|
150
|
+
|
|
151
|
+
if (!connected(route)) return {...turned, last: {result: 'turned', at: now}};
|
|
152
|
+
|
|
153
|
+
const cleared = {
|
|
154
|
+
...turned,
|
|
155
|
+
score: turned.score + turned.cfg.pointsPerRoute,
|
|
156
|
+
routes: turned.routes + 1,
|
|
157
|
+
last: {result: 'connected', at: now},
|
|
158
|
+
};
|
|
159
|
+
return deal(cleared, now, nextLength(cleared));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The payload arrives; an unfinished route costs a life. */
|
|
163
|
+
export function step(state, now) {
|
|
164
|
+
if (state.over || !state.route) return state;
|
|
165
|
+
if (now < state.route.expiresAt) return state;
|
|
166
|
+
|
|
167
|
+
const lives = state.lives - 1;
|
|
168
|
+
const hit = {...state, lives, last: {result: 'spilled', at: now}, over: lives <= 0};
|
|
169
|
+
return hit.over ? {...hit, route: null} : deal(hit, now, state.route.pieces.length);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The line that goes on the game-over card and into the post. */
|
|
173
|
+
export function summarise(state, locale = 'en') {
|
|
174
|
+
const n = (v) => Number(v || 0).toLocaleString(locale);
|
|
175
|
+
return locale === 'nl'
|
|
176
|
+
? `${n(state.routes)} koppelingen gelegd · ${n(state.turns)} keer gedraaid`
|
|
177
|
+
: `${n(state.routes)} routes connected · ${n(state.turns)} turns`;
|
|
178
|
+
}
|
|
@@ -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
|
+
}
|