@slugbugblue/trax 0.18.0 → 0.20.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/.dockerignore +11 -0
- package/.gitlab-ci.yml +90 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +14 -0
- package/CONTRIBUTING.md +113 -0
- package/Dockerfile +14 -5
- package/LICENSE +1 -1
- package/README.md +29 -10
- package/docs/analyst.md +13 -2
- package/docs/lru.md +151 -0
- package/package.json +15 -4
- package/src/analyst.js +68 -34
- package/src/cmds/import-export.js +9 -2
- package/src/cmds/notes.js +3 -0
- package/src/cmds/play-try.js +8 -2
- package/src/cmds/puzzles.js +26 -7
- package/src/cmds/suggest.js +5 -2
- package/src/engine.js +1 -1
- package/src/lru.js +153 -0
- package/src/point.js +1 -1
- package/src/puzzles.js +285 -16
- package/src/threats.js +3 -3
- package/src/types.d.ts +4 -2
- package/src/utils.js +22 -0
- package/src/version.js +1 -1
- package/benchmark/benchmarks.json +0 -42
- package/benchmark/puzzles.bench.js +0 -190
package/src/analyst.js
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import { Point } from '@slugbugblue/trax/point'
|
|
8
8
|
import { Trax } from '@slugbugblue/trax'
|
|
9
|
+
import { LRU } from '@slugbugblue/trax/lru'
|
|
9
10
|
|
|
10
11
|
import { threats } from '@slugbugblue/trax/threats'
|
|
11
12
|
|
|
13
|
+
const cache = new LRU(2750)
|
|
14
|
+
|
|
12
15
|
/** Helper function to generate a unique id for the analysis. */
|
|
13
16
|
const id = () => {
|
|
14
17
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
@@ -201,7 +204,7 @@ const label = (rawEdge) => {
|
|
|
201
204
|
const length = (array) => array?.length || 0
|
|
202
205
|
|
|
203
206
|
/** Calculate the points for the number of threats at a particular level.
|
|
204
|
-
* @arg {
|
|
207
|
+
* @arg {ColorThreats} from - the threats found for a certain player
|
|
205
208
|
* @arg {number} level - the level to calculate the points for
|
|
206
209
|
* @arg {number} [as] - if included, the level the points should be calculated as
|
|
207
210
|
* @return {number} the number of points for the level
|
|
@@ -214,16 +217,23 @@ const attackScore = (from, level, as) => {
|
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
/** Shortcut to get the number of attacks.
|
|
217
|
-
* @arg {
|
|
220
|
+
* @arg {ColorThreats} from - the threats found for a certain player
|
|
218
221
|
* @returns {number} the number of attacks (win on next move) present
|
|
219
222
|
*/
|
|
220
223
|
const attackCount = (from) => length(from['1'])
|
|
221
224
|
|
|
225
|
+
/** Shortcut to get the number of threats at a certain level.
|
|
226
|
+
* @arg {ColorThreats} from - the threats found for a player
|
|
227
|
+
* @arg {number} level - the level for the threats to look for
|
|
228
|
+
* @returns {number} the number of threats at the given level
|
|
229
|
+
*/
|
|
230
|
+
const threatCount = (from, level) => length(from[level])
|
|
231
|
+
|
|
222
232
|
/** Shortcut to get the number of passive threats
|
|
223
|
-
* @arg {
|
|
233
|
+
* @arg {ColorThreats} from - the threats found for a player
|
|
224
234
|
* @returns {number} the number of passive threats (win in 2 or more moves)
|
|
225
235
|
*/
|
|
226
|
-
const
|
|
236
|
+
const passiveCount = (from) => {
|
|
227
237
|
let count = 0
|
|
228
238
|
for (const [depth, array] of Object.entries(from)) {
|
|
229
239
|
count += int(depth) > 1 ? length(array) : 0
|
|
@@ -233,26 +243,32 @@ const threatCount = (from) => {
|
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
/** Shortcut to determine if we have an active attack.
|
|
236
|
-
* @arg {
|
|
246
|
+
* @arg {ColorThreats} from - the threats found for a player
|
|
237
247
|
* @returns {boolean}
|
|
238
248
|
*/
|
|
239
249
|
const activeAttack = (from) =>
|
|
240
|
-
attackCount(from) > 1 || (attackCount(from) === 1 &&
|
|
250
|
+
attackCount(from) > 1 || (attackCount(from) === 1 && passiveCount(from) > 0)
|
|
241
251
|
|
|
242
252
|
/** Shortcut to determine if we have a passive attack.
|
|
243
|
-
* @arg {
|
|
253
|
+
* @arg {ColorThreats} from - the threats found for a player
|
|
244
254
|
* @returns {boolean}
|
|
245
255
|
*/
|
|
246
|
-
const passiveAttack = (from) =>
|
|
256
|
+
const passiveAttack = (from) => passiveCount(from) > 1
|
|
257
|
+
|
|
258
|
+
// /** Shortcut to determine if we are under attack.
|
|
259
|
+
// * @arg {ColorThreats} from - the threats found for a player
|
|
260
|
+
// * @returns {boolean}
|
|
261
|
+
// */
|
|
262
|
+
// const underAttack = (from) => attackCount(from) > 0
|
|
247
263
|
|
|
248
|
-
/** Shortcut to determine if we are under
|
|
249
|
-
* @arg {
|
|
264
|
+
/** Shortcut to determine if we are under threat.
|
|
265
|
+
* @arg {ColorThreats} from - the threats found for a player
|
|
250
266
|
* @returns {boolean}
|
|
251
267
|
*/
|
|
252
|
-
const
|
|
268
|
+
const underThreat = (from) => attackCount(from) + passiveCount(from) > 0
|
|
253
269
|
|
|
254
270
|
// Analysis class
|
|
255
|
-
export
|
|
271
|
+
export class Analysis {
|
|
256
272
|
/** @type {TraxVariant} */
|
|
257
273
|
#rules = 'trax'
|
|
258
274
|
#id = 'analysis'
|
|
@@ -279,6 +295,10 @@ export const Analysis = class {
|
|
|
279
295
|
b: (level) => this.threats.b[level]?.length || 0,
|
|
280
296
|
}
|
|
281
297
|
this.#partial = Boolean(partial)
|
|
298
|
+
if (this.#moves.length === 0 || game.over) {
|
|
299
|
+
this.#edge = { b: '', w: '', rawEdge: [] }
|
|
300
|
+
this.#threats = { b: {}, w: {} }
|
|
301
|
+
}
|
|
282
302
|
}
|
|
283
303
|
|
|
284
304
|
get game() {
|
|
@@ -380,7 +400,7 @@ export const Analysis = class {
|
|
|
380
400
|
const attacking = activeAttack(inactive)
|
|
381
401
|
|
|
382
402
|
// Was the inactive player unable to clear all threats?
|
|
383
|
-
const defending = !attacking &&
|
|
403
|
+
const defending = !attacking && underThreat(active)
|
|
384
404
|
|
|
385
405
|
// Does the inactive player have multiple threats
|
|
386
406
|
const threatening = !attacking && passiveAttack(inactive)
|
|
@@ -554,17 +574,19 @@ export const Analysis = class {
|
|
|
554
574
|
/**
|
|
555
575
|
* @arg {FoundThreat} threat - a threat
|
|
556
576
|
* @arg {string} color - the player color
|
|
557
|
-
* @returns {
|
|
577
|
+
* @returns {Point[]} - locations relevant to the threat
|
|
558
578
|
*/
|
|
559
|
-
|
|
579
|
+
threatLocs(threat, color) {
|
|
560
580
|
const { edge } = this.edge
|
|
561
581
|
const max = edge.length
|
|
562
582
|
let index = threat.at
|
|
583
|
+
let count = 0
|
|
563
584
|
const locs = new Set([index])
|
|
564
|
-
while (threat.match.length
|
|
585
|
+
while (count < threat.match.length) {
|
|
565
586
|
index += 1
|
|
566
587
|
if (index >= max) index -= max
|
|
567
588
|
locs.add(index)
|
|
589
|
+
count += 1
|
|
568
590
|
// Check to see if we have a connectable pair
|
|
569
591
|
if (edge[index]['p' + color]) {
|
|
570
592
|
const line = 'n' + color
|
|
@@ -580,16 +602,15 @@ export const Analysis = class {
|
|
|
580
602
|
}
|
|
581
603
|
}
|
|
582
604
|
|
|
583
|
-
|
|
584
|
-
return value
|
|
605
|
+
return [...locs].map((i) => new Point(edge[i] || { x: 0, y: 0 }))
|
|
585
606
|
}
|
|
586
607
|
|
|
587
608
|
/** Apply the threats to a color's regex-ready edge.
|
|
588
609
|
* @arg {Color} color - the color to find threats for
|
|
589
|
-
* @returns {
|
|
610
|
+
* @returns {ColorThreats}
|
|
590
611
|
*/
|
|
591
612
|
#applyThreats(color) {
|
|
592
|
-
/** @type {
|
|
613
|
+
/** @type {ColorThreats} */
|
|
593
614
|
const matches = {}
|
|
594
615
|
const { length } = this.edge[color]
|
|
595
616
|
const edge = this.edge[color].repeat(2)
|
|
@@ -613,7 +634,7 @@ export const Analysis = class {
|
|
|
613
634
|
/** Clean up the list of matches, removing any false positives. */
|
|
614
635
|
#validateThreats() {
|
|
615
636
|
for (const bw of ['w', 'b']) {
|
|
616
|
-
/** @type
|
|
637
|
+
/** @type ColorThreats */
|
|
617
638
|
const matches = this.#threats[bw]
|
|
618
639
|
for (const key of Object.keys(matches)) {
|
|
619
640
|
const level = Number(key)
|
|
@@ -645,10 +666,12 @@ export const Analysis = class {
|
|
|
645
666
|
* @returns {boolean}
|
|
646
667
|
*/
|
|
647
668
|
#canActivateThreat(threat, color, level) {
|
|
648
|
-
const attacks = this.#threats[color]
|
|
649
|
-
const
|
|
650
|
-
const
|
|
651
|
-
|
|
669
|
+
const attacks = attackCount(this.#threats[color])
|
|
670
|
+
const player = Trax.playerNumber(color)
|
|
671
|
+
const other = Trax.other(color)
|
|
672
|
+
const otherAttacks = attackCount(this.#threats[other])
|
|
673
|
+
const higher = threatCount(this.#threats[color], level - 1)
|
|
674
|
+
for (const loc of this.threatLocs(threat, color)) {
|
|
652
675
|
for (const type of this.game.possibleTiles(loc)) {
|
|
653
676
|
const game = new Trax(this.game.rules, this.game.moves)
|
|
654
677
|
const result = game.dropTile(type, loc)
|
|
@@ -656,20 +679,23 @@ export const Analysis = class {
|
|
|
656
679
|
|
|
657
680
|
// This might be a bad shortcut, but if we already have an attack here
|
|
658
681
|
// we just assume that this threat is also valid ...
|
|
659
|
-
if (game.over && game.turn ===
|
|
682
|
+
if (game.over && game.turn === player) return true
|
|
660
683
|
|
|
661
|
-
|
|
684
|
+
// Otherwise, we cannot evaluate an ended game
|
|
685
|
+
if (game.over) continue
|
|
686
|
+
|
|
687
|
+
const { threats } = analyze(game, true)
|
|
662
688
|
|
|
663
689
|
// If we give our opponent an attack, this is a faulty move
|
|
664
|
-
const faulty = (threats[
|
|
690
|
+
const faulty = attackCount(threats[other]) - otherAttacks
|
|
665
691
|
if (faulty > 0) continue
|
|
666
692
|
|
|
667
693
|
// Activating the threat means we get an attack and a threat
|
|
668
|
-
const newAttacks = (threats[color]
|
|
694
|
+
const newAttacks = attackCount(threats[color]) - attacks
|
|
669
695
|
if (level === 2) {
|
|
670
696
|
if (newAttacks > 1) return true
|
|
671
697
|
} else {
|
|
672
|
-
const newThreats = (threats[color]
|
|
698
|
+
const newThreats = threatCount(threats[color], level - 1) - higher
|
|
673
699
|
if (newAttacks > 0 && newThreats > 0) return true
|
|
674
700
|
}
|
|
675
701
|
}
|
|
@@ -712,11 +738,19 @@ export const Analysis = class {
|
|
|
712
738
|
|
|
713
739
|
/** Analyze a game position.
|
|
714
740
|
* @arg {Trax} game - the position to analyze
|
|
741
|
+
* @arg {boolean} partial - if the position should only be partially analyzed
|
|
715
742
|
* @returns {Analysis}
|
|
716
743
|
*/
|
|
717
|
-
export const analyze = (game) =>
|
|
744
|
+
export const analyze = (game, partial = false) => {
|
|
745
|
+
const id = (partial ? 'y' : 'n') + game.rules + game.normalized
|
|
746
|
+
const cached = cache.get(id)
|
|
747
|
+
if (cached) return cached
|
|
748
|
+
const analysis = new Analysis(game, partial)
|
|
749
|
+
cache.set(id, analysis)
|
|
750
|
+
return analysis
|
|
751
|
+
}
|
|
718
752
|
|
|
719
|
-
export
|
|
753
|
+
export class Suggestion {
|
|
720
754
|
/** @type {PositionScore[]} */
|
|
721
755
|
all = []
|
|
722
756
|
/** @type {PositionScore[]} */
|
|
@@ -755,7 +789,7 @@ export const Suggestion = class {
|
|
|
755
789
|
}
|
|
756
790
|
}
|
|
757
791
|
|
|
758
|
-
export const suggest = (game) => {
|
|
792
|
+
export const suggest = (game, partial = false) => {
|
|
759
793
|
const start = Date.now()
|
|
760
794
|
const analyzed = {}
|
|
761
795
|
const { color, moves, rules } = game
|
|
@@ -764,7 +798,7 @@ export const suggest = (game) => {
|
|
|
764
798
|
trax.play(move)
|
|
765
799
|
const unique = trax.normalized
|
|
766
800
|
if (!analyzed[unique]) {
|
|
767
|
-
const analysis = analyze(trax)
|
|
801
|
+
const analysis = analyze(trax, partial)
|
|
768
802
|
analyzed[unique] = { move, score: analysis.scores[color], analysis }
|
|
769
803
|
}
|
|
770
804
|
}
|
|
@@ -248,7 +248,7 @@ const gameNotes = (move, notes) => {
|
|
|
248
248
|
if (moveNotes.length === 0) return ''
|
|
249
249
|
return (
|
|
250
250
|
'; ' +
|
|
251
|
-
moveNotes.map((n) => n.note.
|
|
251
|
+
moveNotes.map((n) => n.note.replace(/\n/g, '\n; ')).join('\n; ') +
|
|
252
252
|
'\n'
|
|
253
253
|
)
|
|
254
254
|
}
|
|
@@ -328,8 +328,15 @@ exportCmd.fn = (CLI, id, filename) => {
|
|
|
328
328
|
const source = sources[puzzle.src]
|
|
329
329
|
content += '; Puzzle ' + game.puzzle
|
|
330
330
|
if (source) {
|
|
331
|
-
content +=
|
|
331
|
+
content += source.copyright
|
|
332
|
+
? ' ©' + source.copyright + ' by '
|
|
333
|
+
: ' provided courtesy of '
|
|
334
|
+
content += source.name
|
|
332
335
|
if (source.url) content += '\n; ' + source.url
|
|
336
|
+
if (source.license) {
|
|
337
|
+
content += '\n; Licensed under ' + source.license
|
|
338
|
+
content += source.licenseUrl ? ' ' + source.licenseUrl : ''
|
|
339
|
+
}
|
|
333
340
|
}
|
|
334
341
|
|
|
335
342
|
content += '\n'
|
package/src/cmds/notes.js
CHANGED
|
@@ -25,6 +25,9 @@ notesCmd.fn = (CLI, id, ...note) => {
|
|
|
25
25
|
if (id.startsWith('#')) id = id.slice(1)
|
|
26
26
|
|
|
27
27
|
const game = CLI.GAMES[id]
|
|
28
|
+
|
|
29
|
+
if (!game) return CLI.error('Game not found.')
|
|
30
|
+
|
|
28
31
|
const notes = game.notes || []
|
|
29
32
|
const move = game.moves.length > 0 ? game.moves.split(' ').length : 0
|
|
30
33
|
|
package/src/cmds/play-try.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Copyright 2022 Chad Transtrum
|
|
1
|
+
/* Copyright 2022-2023 Chad Transtrum
|
|
2
2
|
*
|
|
3
3
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
4
|
* you may not use this file except in compliance with the License.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// CLI play/try commands
|
|
17
17
|
|
|
18
18
|
import { suggest } from '@slugbugblue/trax/analyst'
|
|
19
|
+
import { timeString } from '@slugbugblue/trax/utils'
|
|
19
20
|
|
|
20
21
|
const moveRx = /^\d+\.?$/
|
|
21
22
|
const notationRx = /^[@a-z]+\d+[bps/\\+]$/i
|
|
@@ -70,9 +71,14 @@ const bot = (CLI) => {
|
|
|
70
71
|
|
|
71
72
|
CLI.do('view')
|
|
72
73
|
CLI.out(CLI.bubble(trax.color + 'd', `${name} thinking...`))
|
|
74
|
+
const start = Date.now()
|
|
73
75
|
const move = suggest(trax)?.pick?.move
|
|
76
|
+
const ms = Date.now() - start
|
|
74
77
|
if (move) {
|
|
75
|
-
CLI.out(
|
|
78
|
+
CLI.out(
|
|
79
|
+
CLI.bubble(trax.color + 'd', `${name} chooses ${move}`) +
|
|
80
|
+
CLI.color.black(' in ' + timeString(ms)),
|
|
81
|
+
)
|
|
76
82
|
trax.play(move)
|
|
77
83
|
}
|
|
78
84
|
|
package/src/cmds/puzzles.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @file Puzzles CLI
|
|
2
|
-
* @copyright 2022
|
|
2
|
+
* @copyright 2022-2023
|
|
3
3
|
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
4
|
* @license Apache-2.0
|
|
5
5
|
*/
|
|
@@ -34,8 +34,14 @@ const an = (word = '') => {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const showPuzzle = (CLI, id) => {
|
|
37
|
-
|
|
37
|
+
let puzzle = puzzles.find((p) => p.id === id)
|
|
38
|
+
if (!puzzle) {
|
|
39
|
+
const possibles = puzzles.filter((p) => p.id.includes(id))
|
|
40
|
+
if (possibles.length === 1) puzzle = possibles[0]
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
if (!puzzle) return CLI.error(`Puzzle ${id} not found`)
|
|
44
|
+
|
|
39
45
|
const trax = new Trax(puzzle.game, puzzle.notation, 'cli')
|
|
40
46
|
const players = ['white', 'black']
|
|
41
47
|
CLI.display(trax, players)
|
|
@@ -45,14 +51,19 @@ const showPuzzle = (CLI, id) => {
|
|
|
45
51
|
if (puzzle.desc) out += CLI.color.help(puzzle.desc) + '\n'
|
|
46
52
|
out += CLI.color(players[puzzle.player - 1] + ' to win by move ' + puzzle.max)
|
|
47
53
|
const src = sources[puzzle.src]
|
|
54
|
+
|
|
48
55
|
if (src) {
|
|
49
56
|
let source = '\nPuzzle '
|
|
50
57
|
source += src.copyright
|
|
51
58
|
? 'copyright ' + src.copyright + ' by'
|
|
52
59
|
: 'courtesy of'
|
|
53
60
|
source += ' ' + src.name
|
|
54
|
-
const url = src.url ? ' ' + CLI.color.
|
|
55
|
-
out += CLI.color.
|
|
61
|
+
const url = src.url ? ' ' + CLI.color.help(src.url) : ''
|
|
62
|
+
out += CLI.color.white(source) + url
|
|
63
|
+
if (src.license) {
|
|
64
|
+
out += '\n' + CLI.color.optional('License: ' + src.license)
|
|
65
|
+
if (src.licenseUrl) out += CLI.color.black(' ' + src.licenseUrl)
|
|
66
|
+
}
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
CLI.out(out)
|
|
@@ -144,14 +155,22 @@ puzzlesCmd.fn = (CLI, action, id) => {
|
|
|
144
155
|
return startNextPuzzle(CLI, levels.indexOf(id))
|
|
145
156
|
}
|
|
146
157
|
|
|
147
|
-
|
|
158
|
+
const possibles = puzzles.filter((p) => p.id.includes(id))
|
|
159
|
+
if (possibles.length === 1) {
|
|
160
|
+
return startPuzzle(CLI, possibles[0])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return CLI.error('Could not find puzzle ' + id)
|
|
148
164
|
}
|
|
149
165
|
|
|
150
166
|
if (action) {
|
|
151
167
|
const x = id || action
|
|
152
168
|
if (validLevels.includes(x)) return listLevel(CLI, x)
|
|
153
|
-
if (
|
|
154
|
-
|
|
169
|
+
if ('list'.startsWith(x) || 'ls'.startsWith(x)) {
|
|
170
|
+
return listPuzzles(CLI)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return showPuzzle(CLI, x)
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
listPuzzles(CLI)
|
package/src/cmds/suggest.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Copyright 2022 Chad Transtrum
|
|
1
|
+
/* Copyright 2022-2023 Chad Transtrum
|
|
2
2
|
*
|
|
3
3
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
4
|
* you may not use this file except in compliance with the License.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// CLI suggest command
|
|
17
17
|
|
|
18
18
|
import { suggest } from '@slugbugblue/trax/analyst'
|
|
19
|
+
import { timeString } from '@slugbugblue/trax/utils'
|
|
19
20
|
|
|
20
21
|
export const suggestCmd = {
|
|
21
22
|
name: 'suggest',
|
|
@@ -47,6 +48,8 @@ suggestCmd.fn = (CLI, debug) => {
|
|
|
47
48
|
m.push(move.move + ' ' + move.score)
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
CLI.out(
|
|
51
|
+
CLI.out(
|
|
52
|
+
'[' + m.join(', ') + '] ' + CLI.color.black(timeString(suggestion.ms)),
|
|
53
|
+
)
|
|
51
54
|
}
|
|
52
55
|
}
|
package/src/engine.js
CHANGED
|
@@ -127,7 +127,7 @@ const codeRowLength = (row) =>
|
|
|
127
127
|
|
|
128
128
|
// This is where the magic happens
|
|
129
129
|
/** A digital representation of a Trax game. */
|
|
130
|
-
export
|
|
130
|
+
export class Trax {
|
|
131
131
|
// Static class properties
|
|
132
132
|
|
|
133
133
|
/** @readonly @type {Record<TraxVariant, string>} */
|
package/src/lru.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Least Recently Used cache
|
|
2
|
+
* @copyright 2023
|
|
3
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
|
+
* @license Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** LRU cache: a class for remembering things. It uses a Map internally and
|
|
8
|
+
* exposes most of the same interface.
|
|
9
|
+
*/
|
|
10
|
+
export class LRU {
|
|
11
|
+
/** @type Map */
|
|
12
|
+
#cache
|
|
13
|
+
#capacity = 0
|
|
14
|
+
#hits = 0
|
|
15
|
+
#misses = 0
|
|
16
|
+
#expired = 0
|
|
17
|
+
|
|
18
|
+
/** Instantiate this class with the max number of cached items
|
|
19
|
+
* @arg {number} capacity - the maximum number of items to cache
|
|
20
|
+
*/
|
|
21
|
+
constructor(capacity = 1000) {
|
|
22
|
+
this.#cache = new Map()
|
|
23
|
+
if (typeof capacity === 'number' && capacity >= 1) {
|
|
24
|
+
this.#capacity = Math.floor(capacity)
|
|
25
|
+
} else {
|
|
26
|
+
throw new SyntaxError('capacity must be a positive integer')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The maximum capacity. */
|
|
31
|
+
get capacity() {
|
|
32
|
+
return this.#capacity
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The current size. */
|
|
36
|
+
get size() {
|
|
37
|
+
return this.#cache.size
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Number of times we have had a cache hit. */
|
|
41
|
+
get hits() {
|
|
42
|
+
return this.#hits
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Number of times we have had a cache miss. */
|
|
46
|
+
get misses() {
|
|
47
|
+
return this.#misses
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Number of times we have expired a cache entry. */
|
|
51
|
+
get expired() {
|
|
52
|
+
return this.#expired
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Retrieve an item from the cache.
|
|
56
|
+
* @arg {any} key - the key of the cached item
|
|
57
|
+
* @returns {any} - the cached item or undefined
|
|
58
|
+
*/
|
|
59
|
+
get(key) {
|
|
60
|
+
if (this.#cache.has(key)) {
|
|
61
|
+
// Update the least recently used order
|
|
62
|
+
const value = this.#cache.get(key)
|
|
63
|
+
this.#cache.delete(key)
|
|
64
|
+
this.#cache.set(key, value)
|
|
65
|
+
this.#hits++
|
|
66
|
+
} else {
|
|
67
|
+
this.#misses++
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return this.#cache.get(key)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Retrieve an item from the cache without triggering any LRU handling.
|
|
74
|
+
* @arg {any} key - the key of the cached item to retrieve
|
|
75
|
+
* @returns {any} - the cached item or undefined
|
|
76
|
+
*/
|
|
77
|
+
peek(key) {
|
|
78
|
+
return this.#cache.get(key)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Store an item in the cache.
|
|
82
|
+
* @arg {any} key - the key of the cached item
|
|
83
|
+
* @arg {any} value - the value to store
|
|
84
|
+
*/
|
|
85
|
+
set(key, value) {
|
|
86
|
+
// To update an item, remove and re-add it to track LRU status
|
|
87
|
+
this.#cache.delete(key)
|
|
88
|
+
|
|
89
|
+
// If there are too many items, expire one before adding more
|
|
90
|
+
if (this.#cache.size >= this.#capacity) {
|
|
91
|
+
this.#expired++
|
|
92
|
+
const [expire] = this.#cache[Symbol.iterator]().next().value
|
|
93
|
+
this.#cache.delete(expire)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.#cache.set(key, value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Delete an item from the cache.
|
|
100
|
+
* @arg {any} key - the key of the cached item
|
|
101
|
+
* @returns {boolean} true if the item was in the cache
|
|
102
|
+
*/
|
|
103
|
+
delete(key) {
|
|
104
|
+
return this.#cache.delete(key)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Clear the cache and all its statistics. */
|
|
108
|
+
clear() {
|
|
109
|
+
this.#cache.clear()
|
|
110
|
+
this.#hits = 0
|
|
111
|
+
this.#misses = 0
|
|
112
|
+
this.#expired = 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Get an iterator to return [key, value] pairs from the cache. Does not
|
|
116
|
+
* update any LRU information. */
|
|
117
|
+
entries() {
|
|
118
|
+
return this.#cache.entries()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Determine if a key is already in the cache. Does not trigger hit/miss.
|
|
122
|
+
* @arg {any} key - the key to check
|
|
123
|
+
* @returns {boolean} true if the key is in the cache
|
|
124
|
+
*/
|
|
125
|
+
has(key) {
|
|
126
|
+
return this.#cache.has(key)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Get an iterator for the keys stored in cache, in order from least to most
|
|
130
|
+
* recent. Does not update any LRU information. */
|
|
131
|
+
keys() {
|
|
132
|
+
return this.#cache.keys()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Get an iterator for the values stored in cache, in order from least to
|
|
136
|
+
* most recent. Does not update any LRU information. */
|
|
137
|
+
values() {
|
|
138
|
+
return this.#cache.values()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Play nicely with the rest of the world
|
|
142
|
+
toString() {
|
|
143
|
+
return `LRU(${this.#cache.size} of ${this.#capacity})`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
[Symbol.for('nodejs.util.inspect.custom')](_, options) {
|
|
147
|
+
const [y, s, n] = [options.stylize, 'special', 'number']
|
|
148
|
+
return `${y('LRU', s)}(${y(this.#cache.size, n)} of ${y(
|
|
149
|
+
this.#capacity,
|
|
150
|
+
n,
|
|
151
|
+
)})`
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/point.js
CHANGED