Compare commits

..

3 Commits

Author SHA1 Message Date
d762f8e67e feat: add game command functionality and update quick commands for Number Match game 2026-03-15 12:39:28 +11:00
df10268a83 feat: add MxjsClient for lightweight Matrix client functionality
- Implemented core methods for user authentication (register, login, logout).
- Added room management features (createRoom, joinRoom, leaveRoom, inviteUser).
- Included message handling capabilities (sendMessage, editMessage, redactEvent).
- Introduced user moderation actions (kickUser, banUser, unbanUser).
- Implemented profile management (getProfile, setDisplayName, setAvatarUrl).
- Added support for media uploads and fetching messages from room timelines.
- Included event handling with emit and on methods for custom event listeners.
- Sanitization of HTML content and mention handling in messages.
2026-03-15 12:27:23 +11:00
6d7ee1db71 feat: add /samsay command to utilize SAM speech synthesizer for text-to-speech functionality 2026-03-15 10:19:33 +11:00
8 changed files with 3223 additions and 586 deletions

10
package-lock.json generated
View File

@@ -9,9 +9,9 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@litruv/mxjs-lite": "^1.0.1",
"jimp": "^1.6.0"
},
"devDependencies": {}
}
},
"node_modules/@jimp/core": {
"version": "1.6.0",
@@ -425,6 +425,12 @@
"node": ">=18"
}
},
"node_modules/@litruv/mxjs-lite": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@litruv/mxjs-lite/-/mxjs-lite-1.1.2.tgz",
"integrity": "sha512-kEL/MZdezhibPNQFiQWUWRBPRxfjWTUNnDNpqxmDyLFFG8iLXMQy2mJAMYPBgxw6D+NXARiMcABp5pEWezfC9w==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",

View File

@@ -20,6 +20,7 @@
"author": "Max Litruv Boonzaayer",
"license": "MIT",
"dependencies": {
"@litruv/mxjs-lite": "^1.0.1",
"jimp": "^1.6.0"
},
"repository": {

File diff suppressed because it is too large Load Diff

1133
website/matrix-client.old.js Normal file

File diff suppressed because it is too large Load Diff

1243
website/mxjs-lite.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -253,13 +253,9 @@ class NumberMatchGame {
}
}
// Wrap-around horizontal (end of one row to start of next)
if (rowEnd === rowStart + 1 && colStart === this.width - 1 && colEnd === 0) {
// Adjacent via wrap
return true;
}
if (diff === 1 || diff === this.width) {
// Directly adjacent
// Wrap-around: scan reading order across row boundaries (right to end of row,
// down to next row, left-to-right until reaching the second tile)
if (rowStart !== rowEnd && this.isSegmentClear(start, end, 1)) {
return true;
}
@@ -556,9 +552,9 @@ export const gameMode = {
*/
function getBoardLineCount(game) {
const rows = Math.ceil(game.getTiles().length / game.width);
// header + top border + rows + bottom border + empty + status + message
// header + top border + rows + blank lines between rows + bottom border + empty + status + message + controls
// (prompt uses write not writeln, so doesn't add a line)
return 1 + 1 + rows + 1 + 1 + 1 + 1;
return 1 + 1 + rows + Math.max(0, rows - 1) + 1 + 1 + 1 + 1 + 1;
}
/**
@@ -607,6 +603,9 @@ function renderBoard(term, game, selectedIndex, message = '') {
line += '│';
term.writeln(line);
if (row < rows - 1) {
term.writeln(`${' '.repeat(width)}`);
}
}
// Bottom border
@@ -621,6 +620,9 @@ function renderBoard(term, game, selectedIndex, message = '') {
const msgText = message || '';
term.writeln(` ${msgText}`.padEnd(50));
// Clickable controls line
term.writeln(' \x1b[90m[ \x1b[36madd\x1b[90m ] [ \x1b[36mhint\x1b[90m ] [ \x1b[36mnew\x1b[90m ] [ \x1b[36mquit\x1b[90m ]\x1b[0m');
// Prompt
term.write('\x1b[33mgame>\x1b[0m ');

View File

@@ -0,0 +1,56 @@
/**
* SAM Say command - Use SAM (Software Automatic Mouth) to speak text
*/
/**
* SAM (Software Automatic Mouth) speech synthesizer instance
* @type {object|null}
*/
let sam = null;
/**
* Initialize SAM speech synthesizer with SAM voice
* @returns {object|null} SAM instance or null if unavailable
*/
function initSam() {
if (sam) return sam;
if (typeof SamJs !== 'undefined') {
// SAM preset: speed=72, pitch=64, mouth=128, throat=128
sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 });
}
return sam;
}
/**
* Speak text using SAM
* @param {string} text - Text to speak
* @returns {void}
*/
function samSpeak(text) {
const samInstance = initSam();
if (samInstance) {
try {
samInstance.speak(text);
} catch (_err) {
// Silently fail if speech doesn't work
}
}
}
export default {
description: 'Use SAM speech synthesizer to speak text',
execute: (term, writeClickable, VERSION, args) => {
const message = args.join(' ');
if (!message) {
return 'Usage: samsay <message>';
}
if (typeof SamJs === 'undefined') {
return 'SAM speech synthesizer is not available.';
}
samSpeak(message);
return `SAM says: ${message}`;
}
};

View File

@@ -13,10 +13,12 @@ import contactCmd from './scripts/commands/contact.js';
import privacyCmd from './scripts/commands/privacy.js';
import blueskyCmd from './scripts/commands/bluesky.js';
import numbermatchCmd, { gameMode, processGameInput, handleTileClick } from './scripts/commands/numbermatch.js';
import samsayCmd from './scripts/commands/samsay.js';
// Import Matrix client
import {
initMatrixClient,
updateClientSession,
chatMode,
matrixApi,
fetchPublicLastMessage,
@@ -32,10 +34,49 @@ import {
exitChatMode,
updateQuickCommands,
runChatCommand,
runGameCommand,
renderChatPrompt,
sendChatMessage
} from './matrix-client.js';
/**
* SAM (Software Automatic Mouth) speech synthesizer instance
* @type {object|null}
*/
let sam = null;
/**
* Initialize SAM speech synthesizer with SAM voice
* @returns {object|null} SAM instance or null if unavailable
*/
function initSam() {
if (sam) return sam;
if (typeof SamJs !== 'undefined') {
// SAM preset: speed=72, pitch=64, mouth=128, throat=128
sam = new SamJs({ speed: 72, pitch: 64, mouth: 128, throat: 128 });
}
return sam;
}
/**
* Speak text using SAM
* @param {string} text - Text to speak
* @returns {void}
*/
function samSpeak(text) {
const samInstance = initSam();
if (samInstance) {
try {
samInstance.speak(text);
} catch (_err) {
// Silently fail if speech doesn't work
}
}
}
// Expose samSpeak globally for matrix-client
window.samSpeak = samSpeak;
// Version
const VERSION = '1.0.0';
@@ -260,6 +301,7 @@ term.registerLinkProvider({
// Find all command names that match our known commands
const commandNames = ['help', 'about', 'clear', 'echo', 'date', 'whoami', 'history', 'color', 'banner', 'bluesky', 'chat', 'github', 'contact', 'privacy', 'numbermatch'];
const gameCommandNames = ['add', 'hint', 'new', 'quit'];
// When in game mode, detect clickable tile numbers on board lines
if (gameMode.active && lineText.includes('│')) {
@@ -325,6 +367,33 @@ term.registerLinkProvider({
startIndex = index + 1;
}
});
// When in game mode, register add/hint/new/quit as clickable words on the controls line
if (gameMode.active) {
gameCommandNames.forEach(cmd => {
let startIndex = 0;
while (true) {
const index = lineText.indexOf(cmd, startIndex);
if (index === -1) break;
const charBefore = index > 0 ? lineText[index - 1] : ' ';
const charAfter = index + cmd.length < lineText.length ? lineText[index + cmd.length] : ' ';
if (/[\s\[]/.test(charBefore) && /[\s\]]/.test(charAfter)) {
const capturedCmd = cmd;
links.push({
range: {
start: { x: index + 1, y: bufferLineNumber },
end: { x: index + cmd.length + 1, y: bufferLineNumber }
},
text: cmd,
activate: () => {
if (window.runGameCommand) window.runGameCommand(capturedCmd);
}
});
}
startIndex = index + 1;
}
});
}
callback(links.length > 0 ? links : undefined);
}
@@ -481,7 +550,14 @@ const commands = {
},
numbermatch: {
description: numbermatchCmd.description,
execute: (args) => numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory)
execute: (args) => {
numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory);
updateQuickCommands('game');
}
},
samsay: {
description: samsayCmd.description,
execute: (args) => samsayCmd.execute(term, writeClickable, VERSION, args, commandHistory)
},
chat: {
description: 'Connect to chat room',
@@ -538,6 +614,14 @@ const commands = {
};
}
// Sync session to mxClient if exists
if (window.matrixSession.accessToken && window.matrixSession.userId) {
updateClientSession({
accessToken: window.matrixSession.accessToken,
userId: window.matrixSession.userId
});
}
// Register/login user if not logged in
if (!window.matrixSession.accessToken) {
try {
@@ -584,6 +668,8 @@ const commands = {
localStorage.setItem('matrix_username', username);
localStorage.setItem('matrix_password', password);
updateClientSession({ accessToken: regData.access_token, userId: regData.user_id });
term.writeln(` Registered as: ${regData.user_id}\r\n`);
} else if (regData.errcode === 'M_USER_IN_USE') {
// Username exists, try to login
@@ -610,6 +696,8 @@ const commands = {
localStorage.setItem('matrix_username', username);
localStorage.setItem('matrix_password', password);
updateClientSession({ accessToken: loginData.access_token, userId: loginData.user_id });
term.writeln(` Logged in as: ${loginData.user_id}\r\n`);
} else {
return ` Error: Failed to login - ${loginData.error || loginData.errcode || 'Unknown error'}`;
@@ -829,7 +917,12 @@ function positionInlineInput() {
if (mentionSuggestions.style.display !== 'none') {
mentionSuggestions.style.left = inlineInput.style.left;
mentionSuggestions.style.top = ((cursorY * charHeight) - charHeight - 6) + 'px';
// Get the actual height of the suggestions box
const suggestionsHeight = mentionSuggestions.offsetHeight || 0;
// Position it above the cursor line
mentionSuggestions.style.top = ((cursorY * charHeight) - suggestionsHeight - 6) + 'px';
mentionSuggestions.style.fontSize = term.options.fontSize + 'px';
mentionSuggestions.style.lineHeight = charHeight + 'px';
}
@@ -864,6 +957,192 @@ function hideInlineInput() {
mentionSuggestions.style.display = 'none';
}
/**
* Command autocomplete state
*/
const commandAutocomplete = {
matches: [],
index: -1,
tokenStart: 0,
tokenEnd: 0
};
/**
* Get command matches for autocomplete
* @param {string} query - Command prefix to match
* @param {boolean} isChatMode - Whether in chat mode
* @returns {Array<{name: string, description: string}>} Matching commands
*/
function getCommandMatches(query, isChatMode) {
const lowerQuery = query.toLowerCase();
if (isChatMode) {
// Chat commands
const chatCommands = [
{ name: '/help', description: 'Show chat commands' },
{ name: '/nick', description: 'Change display name' },
{ name: '/samsay', description: 'Send message with SAM speech' },
{ name: '/quit', description: 'Exit chat mode' }
];
return chatCommands.filter(cmd => cmd.name.startsWith(lowerQuery));
} else {
// Terminal commands
const terminalCommands = Object.keys(commands).map(name => ({
name,
description: commands[name].description || ''
}));
return terminalCommands.filter(cmd => cmd.name.toLowerCase().startsWith(lowerQuery));
}
}
/**
* Check if command suggestions are visible
* @returns {boolean} True if suggestions are visible
*/
function hasVisibleCommandSuggestions() {
return mentionSuggestions.style.display !== 'none'
&& commandAutocomplete.matches.length > 0;
}
/**
* Reset command autocomplete state
* @returns {void}
*/
function resetCommandAutocomplete() {
commandAutocomplete.matches = [];
commandAutocomplete.index = -1;
commandAutocomplete.tokenStart = 0;
commandAutocomplete.tokenEnd = 0;
mentionSuggestions.style.display = 'none';
mentionSuggestions.innerHTML = '';
}
/**
* Render command suggestions
* @returns {void}
*/
function renderCommandSuggestions() {
const { matches, index } = commandAutocomplete;
if (matches.length === 0) {
mentionSuggestions.style.display = 'none';
return;
}
const limitedMatches = matches.slice(0, 5);
mentionSuggestions.innerHTML = limitedMatches.map((cmd, i) => {
const selected = i === index ? ' selected' : '';
return `<div class="mention-item${selected}">${cmd.name} <span style="opacity: 0.6">- ${cmd.description}</span></div>`;
}).join('');
mentionSuggestions.style.display = 'block';
positionInlineInput();
}
/**
* Refresh command suggestions based on current input
* @returns {void}
*/
function refreshCommandSuggestions() {
const value = inlineInput.value;
const cursorPos = inlineInput.selectionStart;
// Check if we're at the start with a "/" or just typing a command
const isChatMode = chatMode.active;
let query = '';
let tokenStart = 0;
let tokenEnd = cursorPos;
if (isChatMode) {
// In chat mode, look for /command at start
if (value.startsWith('/')) {
const match = value.match(/^(\/\w*)/);
if (match && cursorPos <= match[1].length) {
query = match[1];
tokenStart = 0;
tokenEnd = match[1].length;
} else {
resetCommandAutocomplete();
return;
}
} else {
resetCommandAutocomplete();
return;
}
} else {
// In terminal mode, look for command at start (no slash)
if (cursorPos === value.length) {
const match = value.match(/^(\w*)/);
if (match && match[1].length > 0) {
query = match[1];
tokenStart = 0;
tokenEnd = match[1].length;
} else {
resetCommandAutocomplete();
return;
}
} else {
resetCommandAutocomplete();
return;
}
}
const matches = getCommandMatches(query, isChatMode);
if (matches.length === 0) {
resetCommandAutocomplete();
return;
}
commandAutocomplete.matches = matches;
commandAutocomplete.index = -1;
commandAutocomplete.tokenStart = tokenStart;
commandAutocomplete.tokenEnd = tokenEnd;
renderCommandSuggestions();
}
/**
* Apply the selected command autocomplete
* @returns {boolean} True if a command was applied
*/
function applyCommandAutocomplete() {
if (!hasVisibleCommandSuggestions()) {
return false;
}
// Cycle through commands or select first
if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) {
commandAutocomplete.index++;
} else {
commandAutocomplete.index = 0;
}
const selected = commandAutocomplete.matches[commandAutocomplete.index];
const fullValue = inlineInput.value;
const valueAfter = fullValue.slice(commandAutocomplete.tokenEnd);
const isChatMode = chatMode.active;
// For chat commands, include the slash; for terminal commands, don't
const commandText = selected.name;
const needsSpace = !valueAfter.startsWith(' ') && valueAfter.length > 0;
const newValue = commandText + (needsSpace ? ' ' : '') + valueAfter;
const newCursor = commandText.length + (needsSpace ? 1 : 0);
inlineInput.value = newValue;
inlineInput.setSelectionRange(newCursor, newCursor);
if (isChatMode) {
chatMode.inputLine = newValue;
}
// Update token end for continued cycling
commandAutocomplete.tokenEnd = commandText.length;
renderCommandSuggestions();
return true;
}
/**
* Submit current input
*/
@@ -887,9 +1166,10 @@ async function submitInlineInput() {
term.write('\x1b[1A\x1b[2K\r');
term.writeln('\x1b[33mChat Commands:\x1b[0m');
term.writeln(' /help - Show this help message');
term.writeln(' /nick [name] - Change your display name');
term.writeln(' /quit - Exit chat mode');
term.writeln(' /help - Show this help message');
term.writeln(' /nick [name] - Change your display name');
term.writeln(' /samsay [text] - Send message with SAM speech');
term.writeln(' /quit - Exit chat mode');
term.writeln('─'.repeat(term.cols || 60));
term.write('\x1b[1;32m>\x1b[0m ');
setTimeout(() => positionInlineInput(), 10);
@@ -939,6 +1219,24 @@ async function submitInlineInput() {
return;
}
if (cmd.startsWith('/samsay ')) {
const message = cmd.substring(8).trim();
if (!message) {
// Move cursor up to separator, clear it
term.write('\x1b[1A\x1b[2K\r');
term.writeln('\x1b[31mError: /samsay [text]\x1b[0m');
term.writeln('─'.repeat(term.cols || 60));
term.write('\x1b[1;32m>\x1b[0m ');
setTimeout(() => positionInlineInput(), 10);
return;
}
// Send to chat (will play when message comes back)
await sendChatMessage(cmd);
return;
}
if (cmd && !cmd.startsWith('/')) {
// Don't write the message here - let sync handle it
await sendChatMessage(cmd);
@@ -962,6 +1260,7 @@ async function submitInlineInput() {
term.write(rawValue + '\r\n');
const continueGame = processGameInput(term, cmd);
if (!continueGame) {
updateQuickCommands('terminal');
writePrompt();
}
setTimeout(() => positionInlineInput(), 10);
@@ -1051,8 +1350,9 @@ async function init() {
welcomeBannerMinimal
});
// Expose chat command for onclick handlers
// Expose chat and game command handlers for onclick buttons
window.runChatCommand = runChatCommand;
window.runGameCommand = runGameCommand;
// Display welcome banner with clickable commands
const cols = term.cols;
@@ -1084,24 +1384,62 @@ async function init() {
// Handle inline input events
inlineInput.addEventListener('keydown', async (e) => {
playTerminalKeySound(e);
if (e.key === 'Escape' && hasVisibleMentionSuggestions()) {
// Check if we have visible suggestions (mentions OR commands)
const hasMentions = hasVisibleMentionSuggestions();
const hasCommands = hasVisibleCommandSuggestions();
const hasSuggestions = hasMentions || hasCommands;
if (e.key === 'Escape' && hasSuggestions) {
e.preventDefault();
resetMentionAutocomplete();
} else if (e.key === ' ' && hasVisibleMentionSuggestions()) {
if (hasMentions) resetMentionAutocomplete();
if (hasCommands) resetCommandAutocomplete();
} else if (e.key === ' ' && hasSuggestions) {
e.preventDefault();
commitSelectedMentionSuggestion();
} else if (e.key === 'Enter' && hasVisibleMentionSuggestions()) {
if (hasMentions) commitSelectedMentionSuggestion();
if (hasCommands) {
resetCommandAutocomplete();
inlineInput.value += ' ';
if (chatMode.active) chatMode.inputLine = inlineInput.value;
}
} else if (e.key === 'Enter' && hasSuggestions) {
e.preventDefault();
commitSelectedMentionSuggestion();
if (hasMentions) {
commitSelectedMentionSuggestion();
} else if (hasCommands) {
// Just accept the current command and submit
resetCommandAutocomplete();
submitInlineInput();
}
} else if (e.key === 'Enter') {
e.preventDefault();
submitInlineInput();
} else if (e.key === 'Tab' && chatMode.active) {
} else if (e.key === 'Tab') {
e.preventDefault();
await applyMentionAutocomplete();
if (chatMode.active && hasMentions) {
await applyMentionAutocomplete();
} else if (hasCommands) {
applyCommandAutocomplete();
} else {
// Trigger command suggestions on Tab
refreshCommandSuggestions();
if (hasVisibleCommandSuggestions()) {
applyCommandAutocomplete();
} else if (chatMode.active) {
// Try mention autocomplete in chat
await applyMentionAutocomplete();
}
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length > 0) {
if (hasCommands) {
// Navigate command suggestions up
if (commandAutocomplete.index > 0) {
commandAutocomplete.index--;
renderCommandSuggestions();
}
} else if (commandHistory.length > 0) {
// Navigate command history
if (historyIndex === -1 || historyIndex >= commandHistory.length) {
historyIndex = commandHistory.length - 1;
} else if (historyIndex > 0) {
@@ -1111,7 +1449,13 @@ async function init() {
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) {
if (hasCommands) {
// Navigate command suggestions down
if (commandAutocomplete.index < commandAutocomplete.matches.length - 1) {
commandAutocomplete.index++;
renderCommandSuggestions();
}
} else if (historyIndex < commandHistory.length - 1) {
historyIndex++;
inlineInput.value = commandHistory[historyIndex] || '';
} else {
@@ -1126,6 +1470,9 @@ async function init() {
chatMode.inputLine = inlineInput.value;
await refreshMentionSuggestionsFromInput();
}
// Refresh command suggestions if typing at the start
refreshCommandSuggestions();
});
// Click on terminal focuses input