windsor-bot 0.1.6 → 0.1.8

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.
@@ -1,74 +1,106 @@
1
+ import { readFile, unlink, writeFile } from 'fs/promises';
2
+ import { tmpdir } from 'os';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { Jimp, JimpMime, loadFont, measureText } from 'jimp';
1
6
  import { tryExecCommandFunction } from './util.js';
2
- function generateSudoku(difficulty) {
3
- // Start with a solved grid
4
- const base = [
5
- [5, 3, 4, 6, 7, 8, 9, 1, 2],
6
- [6, 7, 2, 1, 9, 5, 3, 4, 8],
7
- [1, 9, 8, 3, 4, 2, 5, 6, 7],
8
- [8, 5, 9, 7, 6, 1, 4, 2, 3],
9
- [4, 2, 6, 8, 5, 3, 7, 9, 1],
10
- [7, 1, 3, 9, 2, 4, 8, 5, 6],
11
- [9, 6, 1, 5, 3, 7, 2, 8, 4],
12
- [2, 8, 7, 4, 1, 9, 6, 3, 5],
13
- [3, 4, 5, 2, 8, 6, 1, 7, 9],
14
- ];
15
- // Number of cells to remove based on difficulty
16
- const removes = {
17
- '': 35,
18
- kid: 20,
19
- easy: 35,
20
- medium: 45,
21
- hard: 55,
22
- };
23
- const toRemove = removes[difficulty] ?? 35;
24
- // Shuffle positions and remove
25
- const positions = Array.from({ length: 81 }, (_, i) => i);
26
- for (let i = positions.length - 1; i > 0; i--) {
27
- const j = Math.floor(Math.random() * (i + 1));
28
- const tmp = positions[i];
29
- positions[i] = positions[j];
30
- positions[j] = tmp;
31
- }
32
- const grid = base.map(row => [...row]);
33
- for (let i = 0; i < toRemove; i++) {
34
- const pos = positions[i];
35
- if (pos === undefined)
36
- break;
37
- const row = Math.floor(pos / 9);
38
- const col = pos % 9;
39
- if (grid[row])
40
- grid[row][col] = 0;
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const SUDOKU_DIR = join(__dirname, '../../assets');
9
+ const puzzleCache = new Map();
10
+ const FONT_DIR = new URL('../../node_modules/@jimp/plugin-print/dist/fonts/open-sans/', import.meta.url).pathname;
11
+ const SUDOKU_FONT = `${FONT_DIR}open-sans-32-black/open-sans-32-black.fnt`;
12
+ const IMAGE_WIDTH = 560;
13
+ const IMAGE_HEIGHT = 640;
14
+ const GRID_SIZE = 504;
15
+ const GRID_LEFT = (IMAGE_WIDTH - GRID_SIZE) / 2;
16
+ const GRID_TOP = 98;
17
+ const CELL_SIZE = GRID_SIZE / 9;
18
+ async function loadSudokus(difficulty) {
19
+ let puzzles = puzzleCache.get(difficulty);
20
+ if (!puzzles) {
21
+ puzzles = readFile(join(SUDOKU_DIR, `sudoku-${difficulty}.txt`), 'utf8').then(contents => {
22
+ const lines = contents.split(/\r?\n/).filter(Boolean);
23
+ return lines.map((puzzle, index) => {
24
+ if (puzzle.length !== 81 || /[^.1-9]/.test(puzzle)) {
25
+ throw new Error(`Invalid Sudoku puzzle at ${difficulty} asset line ${index + 1}`);
26
+ }
27
+ return Array.from({ length: 9 }, (_, row) => Array.from(puzzle.slice(row * 9, row * 9 + 9), cell => cell === '.' ? 0 : Number(cell)));
28
+ });
29
+ });
30
+ puzzleCache.set(difficulty, puzzles);
41
31
  }
42
- return grid;
32
+ return puzzles;
43
33
  }
44
- function renderSudokuGrid(grid) {
45
- const lines = [];
46
- lines.push('┌───────┬───────┬───────┐');
34
+ async function renderSudokuImage(grid, difficulty) {
35
+ const font = await loadFont(SUDOKU_FONT);
36
+ const image = new Jimp({ width: IMAGE_WIDTH, height: IMAGE_HEIGHT, color: 0xffffffff });
37
+ const title = `Sudoku - ${difficulty}`;
38
+ image.print({
39
+ font,
40
+ x: (IMAGE_WIDTH - measureText(font, title)) / 2,
41
+ y: 24,
42
+ text: title,
43
+ });
44
+ for (let row = 0; row < 10; row++) {
45
+ const thickness = row % 3 === 0 ? 4 : 1;
46
+ const y = Math.round(GRID_TOP + row * CELL_SIZE);
47
+ for (let offset = 0; offset < thickness; offset++) {
48
+ for (let x = GRID_LEFT; x <= GRID_LEFT + GRID_SIZE; x++) {
49
+ image.setPixelColor(0x000000ff, x, y + offset);
50
+ }
51
+ }
52
+ }
53
+ for (let col = 0; col < 10; col++) {
54
+ const thickness = col % 3 === 0 ? 4 : 1;
55
+ const x = Math.round(GRID_LEFT + col * CELL_SIZE);
56
+ for (let offset = 0; offset < thickness; offset++) {
57
+ for (let y = GRID_TOP; y <= GRID_TOP + GRID_SIZE; y++) {
58
+ image.setPixelColor(0x000000ff, x + offset, y);
59
+ }
60
+ }
61
+ }
47
62
  for (let row = 0; row < 9; row++) {
48
- const cells = grid[row] ?? [];
49
- const r1 = cells.slice(0, 3).map(c => c === 0 ? '·' : String(c)).join(' ');
50
- const r2 = cells.slice(3, 6).map(c => c === 0 ? '·' : String(c)).join(' ');
51
- const r3 = cells.slice(6, 9).map(c => c === 0 ? '·' : String(c)).join(' ');
52
- lines.push(`│ ${r1} ${r2} │ ${r3} │`);
53
- if (row === 2 || row === 5) {
54
- lines.push('├───────┼───────┼───────┤');
63
+ const cells = grid[row];
64
+ if (!cells)
65
+ throw new Error(`Missing Sudoku row ${row + 1}`);
66
+ for (let col = 0; col < 9; col++) {
67
+ const value = cells[col];
68
+ if (value === undefined || value === 0)
69
+ continue;
70
+ const text = String(value);
71
+ const x = GRID_LEFT + col * CELL_SIZE + (CELL_SIZE - measureText(font, text)) / 2;
72
+ image.print({
73
+ font,
74
+ x,
75
+ y: GRID_TOP + row * CELL_SIZE + 9,
76
+ text,
77
+ });
55
78
  }
56
79
  }
57
- lines.push('└───────┴───────┴───────┘');
58
- return lines;
80
+ const imagePath = join(tmpdir(), `windsor-sudoku-${process.pid}-${Date.now()}.png`);
81
+ await writeFile(imagePath, await image.getBuffer(JimpMime.png));
82
+ return imagePath;
59
83
  }
60
84
  async function printSudokuWorker(args, ctx) {
61
85
  // !TODO: Implement good command parsing
62
86
  const difficulty = args;
63
87
  const diff = difficulty || 'easy';
64
- const grid = generateSudoku(diff);
65
- const gridLines = renderSudokuGrid(grid);
66
- const job = {
67
- urls: [],
68
- header: `Sudoku (${diff})`,
69
- lines: gridLines,
70
- };
71
- await ctx.printJob(job);
88
+ const puzzles = await loadSudokus(diff);
89
+ const grid = puzzles[Math.floor(Math.random() * puzzles.length)];
90
+ if (!grid)
91
+ throw new Error(`No Sudoku puzzles available for ${diff}`);
92
+ const imagePath = await renderSudokuImage(grid, diff);
93
+ try {
94
+ const job = {
95
+ urls: [],
96
+ lines: [],
97
+ iconPath: imagePath,
98
+ };
99
+ await ctx.printJob(job);
100
+ }
101
+ finally {
102
+ await unlink(imagePath);
103
+ }
72
104
  return {
73
105
  kind: "pass"
74
106
  };
@@ -1,104 +1,126 @@
1
+ import { readdir, readFile, unlink, writeFile } from 'fs/promises';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { tmpdir } from 'os';
5
+ import { Jimp, JimpMime, loadFont, measureText } from 'jimp';
1
6
  import { tryExecCommandFunction } from './util.js';
2
- const THEMES = [
3
- {
4
- name: 'Animals',
5
- words: ['CAT', 'DOG', 'BIRD', 'FISH', 'LION', 'BEAR', 'FROG', 'DUCK', 'WOLF', 'DEER'],
6
- },
7
- {
8
- name: 'Fruits',
9
- words: ['APPLE', 'MANGO', 'GRAPE', 'PEACH', 'PLUM', 'PEAR', 'LIME', 'KIWI', 'FIG', 'DATE'],
10
- },
11
- {
12
- name: 'Colors',
13
- words: ['RED', 'BLUE', 'GREEN', 'PINK', 'GOLD', 'TEAL', 'CORAL', 'TAN', 'IVORY', 'NAVY'],
14
- },
15
- {
16
- name: 'Space',
17
- words: ['STAR', 'MOON', 'SUN', 'MARS', 'EARTH', 'VENUS', 'COMET', 'ORBIT', 'NOVA', 'RING'],
18
- },
19
- {
20
- name: 'Food',
21
- words: ['PIZZA', 'TACO', 'SOUP', 'CAKE', 'RICE', 'BREAD', 'PASTA', 'SALAD', 'CHIP', 'PIE'],
22
- },
23
- ];
24
- const SIZE = 15;
25
- function makeGrid() {
26
- return Array.from({ length: SIZE }, () => Array(SIZE).fill(''));
27
- }
28
- const DIRECTIONS = [
29
- [0, 1], [1, 0], [1, 1], [1, -1],
30
- [0, -1], [-1, 0], [-1, -1], [-1, 1],
31
- ];
32
- function placeWord(grid, word) {
33
- const shuffledDirs = [...DIRECTIONS].sort(() => Math.random() - 0.5);
34
- for (const [dr, dc] of shuffledDirs) {
35
- for (let attempt = 0; attempt < 30; attempt++) {
36
- const row = Math.floor(Math.random() * SIZE);
37
- const col = Math.floor(Math.random() * SIZE);
38
- let fits = true;
39
- for (let i = 0; i < word.length; i++) {
40
- const r = row + dr * i;
41
- const c = col + dc * i;
42
- if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) {
43
- fits = false;
44
- break;
45
- }
46
- const cell = grid[r]?.[c];
47
- if (cell && cell !== word[i]) {
48
- fits = false;
49
- break;
50
- }
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const PUZZLE_DIR = join(__dirname, '../../assets/wordsearches');
9
+ const FONT_DIR = new URL('../../node_modules/@jimp/plugin-print/dist/fonts/open-sans/', import.meta.url).pathname;
10
+ const WORDSEARCH_FONT = `${FONT_DIR}open-sans-32-black/open-sans-32-black.fnt`;
11
+ const IMAGE_WIDTH = 560;
12
+ const IMAGE_HEIGHT = 600;
13
+ const GRID_SIZE = 510;
14
+ const GRID_LEFT = (IMAGE_WIDTH - GRID_SIZE) / 2;
15
+ const GRID_TOP = 68;
16
+ const CELL_SIZE = GRID_SIZE / 15;
17
+ let puzzlesPromise;
18
+ async function loadPuzzles() {
19
+ const files = (await readdir(PUZZLE_DIR)).filter(file => file.endsWith('.txt')).sort();
20
+ const puzzles = [];
21
+ for (const file of files) {
22
+ const text = await readFile(join(PUZZLE_DIR, file), 'utf8');
23
+ const lines = text.split(/\r?\n/);
24
+ let theme = '';
25
+ let words = [];
26
+ let grid = [];
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const line = lines[i]?.trim() ?? '';
29
+ if (line.startsWith('THEME ')) {
30
+ theme = line.slice('THEME '.length).trim();
31
+ }
32
+ else if (line.startsWith('WORDS ')) {
33
+ words = line.slice('WORDS '.length).split(',').filter(Boolean);
51
34
  }
52
- if (fits) {
53
- for (let i = 0; i < word.length; i++) {
54
- const r = row + dr * i;
55
- const c = col + dc * i;
56
- if (grid[r])
57
- grid[r][c] = word[i];
35
+ else if (line === 'GRID') {
36
+ const gridLines = [];
37
+ for (let j = i + 1; j < lines.length; j++) {
38
+ const row = lines[j]?.trim() ?? '';
39
+ if (row === 'END')
40
+ break;
41
+ if (row)
42
+ gridLines.push(row);
58
43
  }
59
- return true;
44
+ grid = gridLines;
45
+ break;
60
46
  }
61
47
  }
48
+ if (!theme || words.length === 0 || grid.length === 0) {
49
+ throw new Error(`Invalid wordsearch asset: ${file}`);
50
+ }
51
+ puzzles.push({ theme, words, grid });
62
52
  }
63
- return false;
53
+ if (puzzles.length === 0)
54
+ throw new Error('No wordsearch assets found');
55
+ return puzzles;
64
56
  }
65
- const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
66
- function fillGrid(grid) {
67
- for (let r = 0; r < SIZE; r++) {
68
- for (let c = 0; c < SIZE; c++) {
69
- if (!grid[r]?.[c]) {
70
- if (grid[r])
71
- grid[r][c] = LETTERS[Math.floor(Math.random() * 26)] ?? 'A';
72
- }
57
+ async function renderWordsearchImage(puzzle) {
58
+ const font = await loadFont(WORDSEARCH_FONT);
59
+ const image = new Jimp({ width: IMAGE_WIDTH, height: IMAGE_HEIGHT, color: 0xffffffff });
60
+ const title = `Word Search - ${puzzle.theme}`;
61
+ image.print({
62
+ font,
63
+ x: (IMAGE_WIDTH - measureText(font, title)) / 2,
64
+ y: 22,
65
+ text: title,
66
+ });
67
+ const rows = puzzle.grid.map(row => row.split(/\s+/));
68
+ if (rows.length !== 15 || rows.some(row => row.length !== 15)) {
69
+ throw new Error(`Invalid ${puzzle.theme} wordsearch grid`);
70
+ }
71
+ for (let row = 0; row <= 15; row++) {
72
+ const y = Math.round(GRID_TOP + row * CELL_SIZE);
73
+ for (let x = GRID_LEFT; x <= GRID_LEFT + GRID_SIZE; x++) {
74
+ image.setPixelColor(0x000000ff, x, y);
73
75
  }
74
76
  }
75
- }
76
- function renderGrid(grid) {
77
- return grid.map(row => row.join(' '));
77
+ for (let col = 0; col <= 15; col++) {
78
+ const x = Math.round(GRID_LEFT + col * CELL_SIZE);
79
+ for (let y = GRID_TOP; y <= GRID_TOP + GRID_SIZE; y++) {
80
+ image.setPixelColor(0x000000ff, x, y);
81
+ }
82
+ }
83
+ for (let row = 0; row < 15; row++) {
84
+ const cells = rows[row];
85
+ if (!cells)
86
+ throw new Error(`Missing wordsearch row ${row + 1}`);
87
+ for (let col = 0; col < 15; col++) {
88
+ const text = cells[col];
89
+ if (!text)
90
+ throw new Error(`Missing wordsearch cell ${row + 1},${col + 1}`);
91
+ image.print({
92
+ font,
93
+ x: GRID_LEFT + col * CELL_SIZE + (CELL_SIZE - measureText(font, text)) / 2,
94
+ y: GRID_TOP + row * CELL_SIZE + 1,
95
+ text,
96
+ });
97
+ }
98
+ }
99
+ const imagePath = join(tmpdir(), `windsor-wordsearch-${process.pid}-${Date.now()}.png`);
100
+ await writeFile(imagePath, await image.getBuffer(JimpMime.png));
101
+ return imagePath;
78
102
  }
79
103
  async function printWordsearchWorker(_args, ctx) {
80
- const theme = THEMES[Math.floor(Math.random() * THEMES.length)];
81
- if (!theme)
82
- throw new Error('No themes available');
83
- const grid = makeGrid();
84
- const placed = [];
85
- for (const word of theme.words) {
86
- if (placeWord(grid, word))
87
- placed.push(word);
104
+ puzzlesPromise ??= loadPuzzles();
105
+ const puzzles = await puzzlesPromise;
106
+ const puzzle = puzzles[Math.floor(Math.random() * puzzles.length)];
107
+ if (!puzzle)
108
+ throw new Error('No wordsearch puzzle available');
109
+ const imagePath = await renderWordsearchImage(puzzle);
110
+ try {
111
+ const job = {
112
+ urls: [],
113
+ lines: [
114
+ 'Find these words:',
115
+ puzzle.words.join(' '),
116
+ ],
117
+ iconPath: imagePath,
118
+ };
119
+ await ctx.printJob(job);
120
+ }
121
+ finally {
122
+ await unlink(imagePath);
88
123
  }
89
- fillGrid(grid);
90
- const lines = [
91
- ...renderGrid(grid),
92
- '',
93
- 'Find these words:',
94
- placed.join(' '),
95
- ];
96
- const job = {
97
- urls: [],
98
- header: `Word Search: ${theme.name}`,
99
- lines,
100
- };
101
- await ctx.printJob(job);
102
124
  return {
103
125
  kind: "pass"
104
126
  };
package/dist/reactions.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export const Reaction = {
2
2
  ok: "✅",
3
3
  what: "❓",
4
- fail: "❌"
4
+ fail: "❌",
5
+ thinking: "🧠"
5
6
  };