fix: remove build artifacts from source control
BREAKING: This fixes a critical issue where compiled .js files were being committed directly into src/ alongside .ts files, breaking the app and polluting source control. Changes: - Fix tsconfig.server.json: outDir "." → "./dist", rootDir "." → "./src" - Remove 23 tracked .js files from src/ directory - Update .gitignore to block src/**/*.js and /dist - Clean working directory of all .js artifacts This ensures TypeScript compilation outputs to dist/ folder, not src/. Fixes player ownership implementation and all future builds.
This commit is contained in:
5
apps/web/.gitignore
vendored
5
apps/web/.gitignore
vendored
@@ -34,6 +34,11 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# compiled server-side code (from tsconfig.server.json)
|
||||
/dist
|
||||
src/**/*.js
|
||||
src/**/*.js.map
|
||||
|
||||
# vitest
|
||||
/.vitest
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"use strict";
|
||||
// TypeScript interfaces for Memory Pairs Challenge game
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -1,164 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateAbacusNumeralCards = generateAbacusNumeralCards;
|
||||
exports.generateComplementCards = generateComplementCards;
|
||||
exports.generateGameCards = generateGameCards;
|
||||
exports.getGridConfiguration = getGridConfiguration;
|
||||
exports.generateCardId = generateCardId;
|
||||
// Utility function to generate unique random numbers
|
||||
function generateUniqueNumbers(count, options) {
|
||||
const numbers = new Set();
|
||||
const { min, max } = options;
|
||||
while (numbers.size < count) {
|
||||
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
numbers.add(randomNum);
|
||||
}
|
||||
return Array.from(numbers);
|
||||
}
|
||||
// Utility function to shuffle an array
|
||||
function shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
// Generate cards for abacus-numeral game mode
|
||||
function generateAbacusNumeralCards(pairs) {
|
||||
// Generate unique numbers based on difficulty
|
||||
// For easier games, use smaller numbers; for harder games, use larger ranges
|
||||
const numberRanges = {
|
||||
6: { min: 1, max: 50 }, // 6 pairs: 1-50
|
||||
8: { min: 1, max: 100 }, // 8 pairs: 1-100
|
||||
12: { min: 1, max: 200 }, // 12 pairs: 1-200
|
||||
15: { min: 1, max: 300 }, // 15 pairs: 1-300
|
||||
};
|
||||
const range = numberRanges[pairs];
|
||||
const numbers = generateUniqueNumbers(pairs, range);
|
||||
const cards = [];
|
||||
numbers.forEach((number) => {
|
||||
// Abacus representation card
|
||||
cards.push({
|
||||
id: `abacus_${number}`,
|
||||
type: 'abacus',
|
||||
number,
|
||||
matched: false,
|
||||
});
|
||||
// Numerical representation card
|
||||
cards.push({
|
||||
id: `number_${number}`,
|
||||
type: 'number',
|
||||
number,
|
||||
matched: false,
|
||||
});
|
||||
});
|
||||
return shuffleArray(cards);
|
||||
}
|
||||
// Generate cards for complement pairs game mode
|
||||
function generateComplementCards(pairs) {
|
||||
// Define complement pairs for friends of 5 and friends of 10
|
||||
const complementPairs = [
|
||||
// Friends of 5
|
||||
{ pair: [0, 5], targetSum: 5 },
|
||||
{ pair: [1, 4], targetSum: 5 },
|
||||
{ pair: [2, 3], targetSum: 5 },
|
||||
// Friends of 10
|
||||
{ pair: [0, 10], targetSum: 10 },
|
||||
{ pair: [1, 9], targetSum: 10 },
|
||||
{ pair: [2, 8], targetSum: 10 },
|
||||
{ pair: [3, 7], targetSum: 10 },
|
||||
{ pair: [4, 6], targetSum: 10 },
|
||||
{ pair: [5, 5], targetSum: 10 },
|
||||
// Additional pairs for higher difficulties
|
||||
{ pair: [6, 4], targetSum: 10 },
|
||||
{ pair: [7, 3], targetSum: 10 },
|
||||
{ pair: [8, 2], targetSum: 10 },
|
||||
{ pair: [9, 1], targetSum: 10 },
|
||||
{ pair: [10, 0], targetSum: 10 },
|
||||
// More challenging pairs (can be used for expert mode)
|
||||
{ pair: [11, 9], targetSum: 20 },
|
||||
{ pair: [12, 8], targetSum: 20 },
|
||||
];
|
||||
// Select the required number of complement pairs
|
||||
const selectedPairs = complementPairs.slice(0, pairs);
|
||||
const cards = [];
|
||||
selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
|
||||
// First number in the pair
|
||||
cards.push({
|
||||
id: `comp1_${index}_${num1}`,
|
||||
type: 'complement',
|
||||
number: num1,
|
||||
complement: num2,
|
||||
targetSum,
|
||||
matched: false,
|
||||
});
|
||||
// Second number in the pair
|
||||
cards.push({
|
||||
id: `comp2_${index}_${num2}`,
|
||||
type: 'complement',
|
||||
number: num2,
|
||||
complement: num1,
|
||||
targetSum,
|
||||
matched: false,
|
||||
});
|
||||
});
|
||||
return shuffleArray(cards);
|
||||
}
|
||||
// Main card generation function
|
||||
function generateGameCards(gameType, difficulty) {
|
||||
switch (gameType) {
|
||||
case 'abacus-numeral':
|
||||
return generateAbacusNumeralCards(difficulty);
|
||||
case 'complement-pairs':
|
||||
return generateComplementCards(difficulty);
|
||||
default:
|
||||
throw new Error(`Unknown game type: ${gameType}`);
|
||||
}
|
||||
}
|
||||
// Utility function to get responsive grid configuration based on difficulty and screen size
|
||||
function getGridConfiguration(difficulty) {
|
||||
const configs = {
|
||||
6: {
|
||||
totalCards: 12,
|
||||
mobileColumns: 3, // 3x4 grid in portrait
|
||||
tabletColumns: 4, // 4x3 grid on tablet
|
||||
desktopColumns: 4, // 4x3 grid on desktop
|
||||
landscapeColumns: 6, // 6x2 grid in landscape
|
||||
cardSize: { width: '140px', height: '180px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
8: {
|
||||
totalCards: 16,
|
||||
mobileColumns: 3, // 3x6 grid in portrait (some spillover)
|
||||
tabletColumns: 4, // 4x4 grid on tablet
|
||||
desktopColumns: 4, // 4x4 grid on desktop
|
||||
landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
|
||||
cardSize: { width: '120px', height: '160px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
12: {
|
||||
totalCards: 24,
|
||||
mobileColumns: 3, // 3x8 grid in portrait
|
||||
tabletColumns: 4, // 4x6 grid on tablet
|
||||
desktopColumns: 6, // 6x4 grid on desktop
|
||||
landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
|
||||
cardSize: { width: '100px', height: '140px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
15: {
|
||||
totalCards: 30,
|
||||
mobileColumns: 3, // 3x10 grid in portrait
|
||||
tabletColumns: 5, // 5x6 grid on tablet
|
||||
desktopColumns: 6, // 6x5 grid on desktop
|
||||
landscapeColumns: 10, // 10x3 grid in landscape
|
||||
cardSize: { width: '90px', height: '120px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
};
|
||||
return configs[difficulty];
|
||||
}
|
||||
// Generate a unique ID for cards
|
||||
function generateCardId(type, identifier) {
|
||||
return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.validateAbacusNumeralMatch = validateAbacusNumeralMatch;
|
||||
exports.validateComplementMatch = validateComplementMatch;
|
||||
exports.validateMatch = validateMatch;
|
||||
exports.canFlipCard = canFlipCard;
|
||||
exports.getMatchHint = getMatchHint;
|
||||
exports.calculateMatchScore = calculateMatchScore;
|
||||
exports.analyzeGamePerformance = analyzeGamePerformance;
|
||||
// Validate abacus-numeral match (abacus card matches with number card of same value)
|
||||
function validateAbacusNumeralMatch(card1, card2) {
|
||||
// Both cards must have the same number
|
||||
if (card1.number !== card2.number) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Numbers do not match',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Cards must be different types (one abacus, one number)
|
||||
if (card1.type === card2.type) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Both cards are the same type',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// One must be abacus, one must be number
|
||||
const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus';
|
||||
const hasNumber = card1.type === 'number' || card2.type === 'number';
|
||||
if (!hasAbacus || !hasNumber) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Must match abacus with number representation',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Neither should be complement type for this game mode
|
||||
if (card1.type === 'complement' || card2.type === 'complement') {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Complement cards not valid in abacus-numeral mode',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
type: 'abacus-numeral',
|
||||
};
|
||||
}
|
||||
// Validate complement match (two numbers that add up to target sum)
|
||||
function validateComplementMatch(card1, card2) {
|
||||
// Both cards must be complement type
|
||||
if (card1.type !== 'complement' || card2.type !== 'complement') {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Both cards must be complement type',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Both cards must have the same target sum
|
||||
if (card1.targetSum !== card2.targetSum) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cards have different target sums',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Check if the numbers are actually complements
|
||||
if (!card1.complement || !card2.complement) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Complement information missing',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Verify the complement relationship
|
||||
if (card1.number !== card2.complement || card2.number !== card1.complement) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Numbers are not complements of each other',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Verify the sum equals the target
|
||||
const sum = card1.number + card2.number;
|
||||
if (sum !== card1.targetSum) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
type: 'complement',
|
||||
};
|
||||
}
|
||||
// Main validation function that determines which validation to use
|
||||
function validateMatch(card1, card2) {
|
||||
// Cannot match the same card with itself
|
||||
if (card1.id === card2.id) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cannot match card with itself',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Cannot match already matched cards
|
||||
if (card1.matched || card2.matched) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cannot match already matched cards',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Determine which type of match to validate based on card types
|
||||
const hasComplement = card1.type === 'complement' || card2.type === 'complement';
|
||||
if (hasComplement) {
|
||||
// If either card is complement type, use complement validation
|
||||
return validateComplementMatch(card1, card2);
|
||||
}
|
||||
else {
|
||||
// Otherwise, use abacus-numeral validation
|
||||
return validateAbacusNumeralMatch(card1, card2);
|
||||
}
|
||||
}
|
||||
// Helper function to check if a card can be flipped
|
||||
function canFlipCard(card, flippedCards, isProcessingMove) {
|
||||
// Cannot flip if processing a move
|
||||
if (isProcessingMove)
|
||||
return false;
|
||||
// Cannot flip already matched cards
|
||||
if (card.matched)
|
||||
return false;
|
||||
// Cannot flip if already flipped
|
||||
if (flippedCards.some((c) => c.id === card.id))
|
||||
return false;
|
||||
// Cannot flip if two cards are already flipped
|
||||
if (flippedCards.length >= 2)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
// Get hint for what kind of match the player should look for
|
||||
function getMatchHint(card) {
|
||||
switch (card.type) {
|
||||
case 'abacus':
|
||||
return `Find the number ${card.number}`;
|
||||
case 'number':
|
||||
return `Find the abacus showing ${card.number}`;
|
||||
case 'complement':
|
||||
if (card.complement !== undefined && card.targetSum !== undefined) {
|
||||
return `Find ${card.complement} to make ${card.targetSum}`;
|
||||
}
|
||||
return 'Find the matching complement';
|
||||
default:
|
||||
return 'Find the matching card';
|
||||
}
|
||||
}
|
||||
// Calculate match score based on difficulty and time
|
||||
function calculateMatchScore(difficulty, timeForMatch, isComplementMatch) {
|
||||
const baseScore = isComplementMatch ? 15 : 10; // Complement matches worth more
|
||||
const difficultyMultiplier = difficulty / 6; // Scale with difficulty
|
||||
const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000); // Bonus for speed
|
||||
return Math.round(baseScore * difficultyMultiplier + timeBonus);
|
||||
}
|
||||
// Analyze game performance
|
||||
function analyzeGamePerformance(totalMoves, matchedPairs, totalPairs, gameTime) {
|
||||
const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0;
|
||||
const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0; // Ideal is 100% (each pair found in 2 moves)
|
||||
const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0;
|
||||
// Calculate grade based on accuracy and efficiency
|
||||
let grade = 'F';
|
||||
if (accuracy >= 90 && efficiency >= 80)
|
||||
grade = 'A';
|
||||
else if (accuracy >= 80 && efficiency >= 70)
|
||||
grade = 'B';
|
||||
else if (accuracy >= 70 && efficiency >= 60)
|
||||
grade = 'C';
|
||||
else if (accuracy >= 60 && efficiency >= 50)
|
||||
grade = 'D';
|
||||
return {
|
||||
accuracy,
|
||||
efficiency,
|
||||
averageTimePerMove,
|
||||
grade,
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.schema = exports.db = void 0;
|
||||
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
||||
const better_sqlite3_2 = require("drizzle-orm/better-sqlite3");
|
||||
const schema = __importStar(require("./schema"));
|
||||
exports.schema = schema;
|
||||
/**
|
||||
* Database connection and client
|
||||
*
|
||||
* Creates a singleton SQLite connection with Drizzle ORM.
|
||||
* Enables foreign key constraints (required for cascading deletes).
|
||||
*
|
||||
* IMPORTANT: The database connection is lazy-loaded to avoid accessing
|
||||
* the database at module import time, which would cause build failures
|
||||
* when the database doesn't exist (e.g., in CI/CD environments).
|
||||
*/
|
||||
const databaseUrl = process.env.DATABASE_URL || './data/sqlite.db';
|
||||
let _sqlite = null;
|
||||
let _db = null;
|
||||
/**
|
||||
* Get the database connection (lazy-loaded singleton)
|
||||
* Only creates the connection when first accessed at runtime
|
||||
*/
|
||||
function getDb() {
|
||||
if (!_db) {
|
||||
_sqlite = new better_sqlite3_1.default(databaseUrl);
|
||||
// Enable foreign keys (SQLite requires explicit enable)
|
||||
_sqlite.pragma('foreign_keys = ON');
|
||||
// Enable WAL mode for better concurrency
|
||||
_sqlite.pragma('journal_mode = WAL');
|
||||
_db = (0, better_sqlite3_2.drizzle)(_sqlite, { schema });
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
/**
|
||||
* Database client instance
|
||||
* Uses a Proxy to lazy-load the connection on first access
|
||||
*/
|
||||
exports.db = new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
return getDb()[prop];
|
||||
},
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const migrator_1 = require("drizzle-orm/better-sqlite3/migrator");
|
||||
const index_1 = require("./index");
|
||||
/**
|
||||
* Migration runner
|
||||
*
|
||||
* Runs all pending migrations in the drizzle/ folder.
|
||||
* Safe to run multiple times (migrations are idempotent).
|
||||
*
|
||||
* Usage: pnpm db:migrate
|
||||
*/
|
||||
try {
|
||||
console.log('🔄 Running migrations...');
|
||||
(0, migrator_1.migrate)(index_1.db, { migrationsFolder: './drizzle' });
|
||||
console.log('✅ Migrations complete');
|
||||
process.exit(0);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.abacusSettings = void 0;
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const users_1 = require("./users");
|
||||
/**
|
||||
* Abacus display settings table - UI preferences per user
|
||||
*
|
||||
* One-to-one with users table. Stores abacus display configuration.
|
||||
* Deleted when user is deleted (cascade).
|
||||
*/
|
||||
exports.abacusSettings = (0, sqlite_core_1.sqliteTable)('abacus_settings', {
|
||||
/** Primary key and foreign key to users table */
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
/** Color scheme for beads */
|
||||
colorScheme: (0, sqlite_core_1.text)('color_scheme', {
|
||||
enum: ['monochrome', 'place-value', 'heaven-earth', 'alternating'],
|
||||
})
|
||||
.notNull()
|
||||
.default('place-value'),
|
||||
/** Bead shape */
|
||||
beadShape: (0, sqlite_core_1.text)('bead_shape', {
|
||||
enum: ['diamond', 'circle', 'square'],
|
||||
})
|
||||
.notNull()
|
||||
.default('diamond'),
|
||||
/** Color palette */
|
||||
colorPalette: (0, sqlite_core_1.text)('color_palette', {
|
||||
enum: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'],
|
||||
})
|
||||
.notNull()
|
||||
.default('default'),
|
||||
/** Hide inactive beads */
|
||||
hideInactiveBeads: (0, sqlite_core_1.integer)('hide_inactive_beads', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Color numerals based on place value */
|
||||
coloredNumerals: (0, sqlite_core_1.integer)('colored_numerals', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Scale factor for abacus size */
|
||||
scaleFactor: (0, sqlite_core_1.real)('scale_factor').notNull().default(1.0),
|
||||
/** Show numbers below abacus */
|
||||
showNumbers: (0, sqlite_core_1.integer)('show_numbers', { mode: 'boolean' }).notNull().default(true),
|
||||
/** Enable animations */
|
||||
animated: (0, sqlite_core_1.integer)('animated', { mode: 'boolean' }).notNull().default(true),
|
||||
/** Enable interaction */
|
||||
interactive: (0, sqlite_core_1.integer)('interactive', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Enable gesture controls */
|
||||
gestures: (0, sqlite_core_1.integer)('gestures', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Enable sound effects */
|
||||
soundEnabled: (0, sqlite_core_1.integer)('sound_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
/** Sound volume (0.0 - 1.0) */
|
||||
soundVolume: (0, sqlite_core_1.real)('sound_volume').notNull().default(0.8),
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.arcadeRooms = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
exports.arcadeRooms = (0, sqlite_core_1.sqliteTable)('arcade_rooms', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
// Room identity
|
||||
code: (0, sqlite_core_1.text)('code', { length: 6 }).notNull().unique(), // e.g., "ABC123"
|
||||
name: (0, sqlite_core_1.text)('name', { length: 50 }).notNull(),
|
||||
// Creator info
|
||||
createdBy: (0, sqlite_core_1.text)('created_by').notNull(), // User/guest ID
|
||||
creatorName: (0, sqlite_core_1.text)('creator_name', { length: 50 }).notNull(),
|
||||
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
// Lifecycle
|
||||
lastActivity: (0, sqlite_core_1.integer)('last_activity', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
ttlMinutes: (0, sqlite_core_1.integer)('ttl_minutes').notNull().default(60), // Time to live
|
||||
isLocked: (0, sqlite_core_1.integer)('is_locked', { mode: 'boolean' }).notNull().default(false),
|
||||
// Game configuration
|
||||
gameName: (0, sqlite_core_1.text)('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameConfig: (0, sqlite_core_1.text)('game_config', { mode: 'json' }).notNull(), // Game-specific settings
|
||||
// Current state
|
||||
status: (0, sqlite_core_1.text)('status', {
|
||||
enum: ['lobby', 'playing', 'finished'],
|
||||
})
|
||||
.notNull()
|
||||
.default('lobby'),
|
||||
currentSessionId: (0, sqlite_core_1.text)('current_session_id'), // FK to arcade_sessions (nullable)
|
||||
// Metadata
|
||||
totalGamesPlayed: (0, sqlite_core_1.integer)('total_games_played').notNull().default(0),
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.arcadeSessions = void 0;
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const arcade_rooms_1 = require("./arcade-rooms");
|
||||
const users_1 = require("./users");
|
||||
exports.arcadeSessions = (0, sqlite_core_1.sqliteTable)('arcade_sessions', {
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
// Session metadata
|
||||
currentGame: (0, sqlite_core_1.text)('current_game', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameUrl: (0, sqlite_core_1.text)('game_url').notNull(), // e.g., '/arcade/matching'
|
||||
// Game state (JSON blob)
|
||||
gameState: (0, sqlite_core_1.text)('game_state', { mode: 'json' }).notNull(),
|
||||
// Active players snapshot (for quick access)
|
||||
activePlayers: (0, sqlite_core_1.text)('active_players', { mode: 'json' }).notNull(),
|
||||
// Room association (null for solo play)
|
||||
roomId: (0, sqlite_core_1.text)('room_id').references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'set null' }),
|
||||
// Timing & TTL
|
||||
startedAt: (0, sqlite_core_1.integer)('started_at', { mode: 'timestamp' }).notNull(),
|
||||
lastActivityAt: (0, sqlite_core_1.integer)('last_activity_at', { mode: 'timestamp' }).notNull(),
|
||||
expiresAt: (0, sqlite_core_1.integer)('expires_at', { mode: 'timestamp' }).notNull(), // TTL-based
|
||||
// Status
|
||||
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||
// Version for optimistic locking
|
||||
version: (0, sqlite_core_1.integer)('version').notNull().default(1),
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Database schema exports
|
||||
*
|
||||
* This is the single source of truth for the database schema.
|
||||
* All tables, relations, and types are exported from here.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("./abacus-settings"), exports);
|
||||
__exportStar(require("./arcade-rooms"), exports);
|
||||
__exportStar(require("./arcade-sessions"), exports);
|
||||
__exportStar(require("./players"), exports);
|
||||
__exportStar(require("./room-members"), exports);
|
||||
__exportStar(require("./user-stats"), exports);
|
||||
__exportStar(require("./users"), exports);
|
||||
@@ -1,36 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.players = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const users_1 = require("./users");
|
||||
/**
|
||||
* Players table - user-created player profiles for games
|
||||
*
|
||||
* Each user can have multiple players (for multi-player modes).
|
||||
* Players are scoped to a user and deleted when user is deleted.
|
||||
*/
|
||||
exports.players = (0, sqlite_core_1.sqliteTable)('players', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
/** Foreign key to users table - cascades on delete */
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.notNull()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
/** Player display name */
|
||||
name: (0, sqlite_core_1.text)('name').notNull(),
|
||||
/** Player emoji avatar */
|
||||
emoji: (0, sqlite_core_1.text)('emoji').notNull(),
|
||||
/** Player color (hex) for UI theming */
|
||||
color: (0, sqlite_core_1.text)('color').notNull(),
|
||||
/** Whether this player is currently active in games */
|
||||
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(false),
|
||||
/** When this player was created */
|
||||
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
}, (table) => ({
|
||||
/** Index for fast lookups by userId */
|
||||
userIdIdx: (0, sqlite_core_1.index)('players_user_id_idx').on(table.userId),
|
||||
}));
|
||||
@@ -1,27 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.roomMembers = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const arcade_rooms_1 = require("./arcade-rooms");
|
||||
exports.roomMembers = (0, sqlite_core_1.sqliteTable)('room_members', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
roomId: (0, sqlite_core_1.text)('room_id')
|
||||
.notNull()
|
||||
.references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'cascade' }),
|
||||
userId: (0, sqlite_core_1.text)('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below)
|
||||
displayName: (0, sqlite_core_1.text)('display_name', { length: 50 }).notNull(),
|
||||
isCreator: (0, sqlite_core_1.integer)('is_creator', { mode: 'boolean' }).notNull().default(false),
|
||||
joinedAt: (0, sqlite_core_1.integer)('joined_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
lastSeen: (0, sqlite_core_1.integer)('last_seen', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
isOnline: (0, sqlite_core_1.integer)('is_online', { mode: 'boolean' }).notNull().default(true),
|
||||
}, (table) => ({
|
||||
// Explicit unique index for clarity and database-level enforcement
|
||||
userIdIdx: (0, sqlite_core_1.uniqueIndex)('idx_room_members_user_id_unique').on(table.userId),
|
||||
}));
|
||||
@@ -1,29 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.userStats = void 0;
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const users_1 = require("./users");
|
||||
/**
|
||||
* User stats table - game statistics per user
|
||||
*
|
||||
* One-to-one with users table. Tracks aggregate game performance.
|
||||
* Deleted when user is deleted (cascade).
|
||||
*/
|
||||
exports.userStats = (0, sqlite_core_1.sqliteTable)('user_stats', {
|
||||
/** Primary key and foreign key to users table */
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
/** Total number of games played */
|
||||
gamesPlayed: (0, sqlite_core_1.integer)('games_played').notNull().default(0),
|
||||
/** Total number of games won */
|
||||
totalWins: (0, sqlite_core_1.integer)('total_wins').notNull().default(0),
|
||||
/** User's most-played game type */
|
||||
favoriteGameType: (0, sqlite_core_1.text)('favorite_game_type', {
|
||||
enum: ['abacus-numeral', 'complement-pairs'],
|
||||
}),
|
||||
/** Best completion time in milliseconds */
|
||||
bestTime: (0, sqlite_core_1.integer)('best_time'),
|
||||
/** Highest accuracy percentage (0.0 - 1.0) */
|
||||
highestAccuracy: (0, sqlite_core_1.real)('highest_accuracy').notNull().default(0),
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.users = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
/**
|
||||
* Users table - stores both guest and authenticated users
|
||||
*
|
||||
* Guest users are created automatically on first visit via middleware.
|
||||
* They can upgrade to full accounts later while preserving their data.
|
||||
*/
|
||||
exports.users = (0, sqlite_core_1.sqliteTable)('users', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
/** Stable guest ID from HttpOnly cookie - unique per browser session */
|
||||
guestId: (0, sqlite_core_1.text)('guest_id').notNull().unique(),
|
||||
/** When this user record was created */
|
||||
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
/** When guest upgraded to full account (null for guests) */
|
||||
upgradedAt: (0, sqlite_core_1.integer)('upgraded_at', { mode: 'timestamp' }),
|
||||
/** Email (only set after upgrade) */
|
||||
email: (0, sqlite_core_1.text)('email').unique(),
|
||||
/** Display name (only set after upgrade) */
|
||||
name: (0, sqlite_core_1.text)('name'),
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Player manager for arcade rooms
|
||||
* Handles fetching and validating player participation in rooms
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getAllPlayers = getAllPlayers;
|
||||
exports.getActivePlayers = getActivePlayers;
|
||||
exports.getRoomActivePlayers = getRoomActivePlayers;
|
||||
exports.getRoomPlayerIds = getRoomPlayerIds;
|
||||
exports.validatePlayerInRoom = validatePlayerInRoom;
|
||||
exports.getPlayer = getPlayer;
|
||||
exports.getPlayers = getPlayers;
|
||||
const drizzle_orm_1 = require("drizzle-orm");
|
||||
const db_1 = require("../../db");
|
||||
/**
|
||||
* Get all players for a user (regardless of isActive status)
|
||||
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
|
||||
*/
|
||||
async function getAllPlayers(viewerId) {
|
||||
// First get the user record by guestId
|
||||
const user = await db_1.db.query.users.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId),
|
||||
});
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
// Now query all players by the actual user.id (no isActive filter)
|
||||
return await db_1.db.query.players.findMany({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id),
|
||||
orderBy: db_1.schema.players.createdAt,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get a user's active players (solo mode)
|
||||
* These are the players that will participate when the user joins a solo game
|
||||
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
|
||||
*/
|
||||
async function getActivePlayers(viewerId) {
|
||||
// First get the user record by guestId
|
||||
const user = await db_1.db.query.users.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId),
|
||||
});
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
// Now query players by the actual user.id
|
||||
return await db_1.db.query.players.findMany({
|
||||
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id), (0, drizzle_orm_1.eq)(db_1.schema.players.isActive, true)),
|
||||
orderBy: db_1.schema.players.createdAt,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get active players for all members in a room
|
||||
* Returns only players marked isActive=true from each room member
|
||||
* Returns a map of userId -> Player[]
|
||||
*/
|
||||
async function getRoomActivePlayers(roomId) {
|
||||
// Get all room members
|
||||
const members = await db_1.db.query.roomMembers.findMany({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId),
|
||||
});
|
||||
// Fetch active players for each member (respects isActive flag)
|
||||
const playerMap = new Map();
|
||||
for (const member of members) {
|
||||
const players = await getActivePlayers(member.userId);
|
||||
playerMap.set(member.userId, players);
|
||||
}
|
||||
return playerMap;
|
||||
}
|
||||
/**
|
||||
* Get all player IDs that should participate in a room game
|
||||
* Flattens the player lists from all room members
|
||||
*/
|
||||
async function getRoomPlayerIds(roomId) {
|
||||
const playerMap = await getRoomActivePlayers(roomId);
|
||||
const allPlayers = [];
|
||||
for (const players of playerMap.values()) {
|
||||
allPlayers.push(...players.map((p) => p.id));
|
||||
}
|
||||
return allPlayers;
|
||||
}
|
||||
/**
|
||||
* Validate that a player ID belongs to a user who is a member of a room
|
||||
*/
|
||||
async function validatePlayerInRoom(playerId, roomId) {
|
||||
// Get the player
|
||||
const player = await db_1.db.query.players.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId),
|
||||
});
|
||||
if (!player)
|
||||
return false;
|
||||
// Check if the player's user is a member of the room
|
||||
const member = await db_1.db.query.roomMembers.findFirst({
|
||||
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, player.userId)),
|
||||
});
|
||||
return !!member;
|
||||
}
|
||||
/**
|
||||
* Get player details by ID
|
||||
*/
|
||||
async function getPlayer(playerId) {
|
||||
return await db_1.db.query.players.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId),
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get multiple players by IDs
|
||||
*/
|
||||
async function getPlayers(playerIds) {
|
||||
if (playerIds.length === 0)
|
||||
return [];
|
||||
const players = [];
|
||||
for (const id of playerIds) {
|
||||
const player = await getPlayer(id);
|
||||
if (player)
|
||||
players.push(player);
|
||||
}
|
||||
return players;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Room code generation utility
|
||||
* Generates short, memorable codes for joining rooms
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateRoomCode = generateRoomCode;
|
||||
exports.isValidRoomCode = isValidRoomCode;
|
||||
exports.normalizeRoomCode = normalizeRoomCode;
|
||||
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous chars: 0,O,1,I
|
||||
const CODE_LENGTH = 6;
|
||||
/**
|
||||
* Generate a random 6-character room code
|
||||
* Format: ABC123 (uppercase letters + numbers, no ambiguous chars)
|
||||
*/
|
||||
function generateRoomCode() {
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * CHARS.length);
|
||||
code += CHARS[randomIndex];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
/**
|
||||
* Validate a room code format
|
||||
*/
|
||||
function isValidRoomCode(code) {
|
||||
if (code.length !== CODE_LENGTH)
|
||||
return false;
|
||||
return code.split('').every((char) => CHARS.includes(char));
|
||||
}
|
||||
/**
|
||||
* Normalize a room code (uppercase, remove spaces/dashes)
|
||||
*/
|
||||
function normalizeRoomCode(code) {
|
||||
return code.toUpperCase().replace(/[\s-]/g, '');
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Arcade room manager
|
||||
* Handles database operations for arcade rooms
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createRoom = createRoom;
|
||||
exports.getRoomById = getRoomById;
|
||||
exports.getRoomByCode = getRoomByCode;
|
||||
exports.updateRoom = updateRoom;
|
||||
exports.touchRoom = touchRoom;
|
||||
exports.deleteRoom = deleteRoom;
|
||||
exports.listActiveRooms = listActiveRooms;
|
||||
exports.cleanupExpiredRooms = cleanupExpiredRooms;
|
||||
exports.isRoomCreator = isRoomCreator;
|
||||
const drizzle_orm_1 = require("drizzle-orm");
|
||||
const db_1 = require("../../db");
|
||||
const room_code_1 = require("./room-code");
|
||||
/**
|
||||
* Create a new arcade room
|
||||
* Generates a unique room code and creates the room in the database
|
||||
*/
|
||||
async function createRoom(options) {
|
||||
const now = new Date();
|
||||
// Generate unique room code (retry up to 5 times if collision)
|
||||
let code = (0, room_code_1.generateRoomCode)();
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
while (attempts < MAX_ATTEMPTS) {
|
||||
const existing = await getRoomByCode(code);
|
||||
if (!existing)
|
||||
break;
|
||||
code = (0, room_code_1.generateRoomCode)();
|
||||
attempts++;
|
||||
}
|
||||
if (attempts === MAX_ATTEMPTS) {
|
||||
throw new Error('Failed to generate unique room code');
|
||||
}
|
||||
const newRoom = {
|
||||
code,
|
||||
name: options.name,
|
||||
createdBy: options.createdBy,
|
||||
creatorName: options.creatorName,
|
||||
createdAt: now,
|
||||
lastActivity: now,
|
||||
ttlMinutes: options.ttlMinutes || 60,
|
||||
isLocked: false,
|
||||
gameName: options.gameName,
|
||||
gameConfig: options.gameConfig,
|
||||
status: 'lobby',
|
||||
currentSessionId: null,
|
||||
totalGamesPlayed: 0,
|
||||
};
|
||||
const [room] = await db_1.db.insert(db_1.schema.arcadeRooms).values(newRoom).returning();
|
||||
console.log('[Room Manager] Created room:', room.id, 'code:', room.code);
|
||||
return room;
|
||||
}
|
||||
/**
|
||||
* Get a room by ID
|
||||
*/
|
||||
async function getRoomById(roomId) {
|
||||
return await db_1.db.query.arcadeRooms.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId),
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get a room by code
|
||||
*/
|
||||
async function getRoomByCode(code) {
|
||||
return await db_1.db.query.arcadeRooms.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.code, code.toUpperCase()),
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Update a room
|
||||
*/
|
||||
async function updateRoom(roomId, updates) {
|
||||
const now = new Date();
|
||||
// Always update lastActivity on any room update
|
||||
const updateData = {
|
||||
...updates,
|
||||
lastActivity: now,
|
||||
};
|
||||
const [updated] = await db_1.db
|
||||
.update(db_1.schema.arcadeRooms)
|
||||
.set(updateData)
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
/**
|
||||
* Update room activity timestamp
|
||||
* Call this on any room activity to refresh TTL
|
||||
*/
|
||||
async function touchRoom(roomId) {
|
||||
await db_1.db
|
||||
.update(db_1.schema.arcadeRooms)
|
||||
.set({ lastActivity: new Date() })
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId));
|
||||
}
|
||||
/**
|
||||
* Delete a room
|
||||
* Cascade deletes all room members
|
||||
*/
|
||||
async function deleteRoom(roomId) {
|
||||
await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId));
|
||||
console.log('[Room Manager] Deleted room:', roomId);
|
||||
}
|
||||
/**
|
||||
* List active rooms
|
||||
* Returns rooms ordered by most recently active
|
||||
*/
|
||||
async function listActiveRooms(gameName) {
|
||||
const whereConditions = [];
|
||||
// Filter by game if specified
|
||||
if (gameName) {
|
||||
whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.gameName, gameName));
|
||||
}
|
||||
// Only return non-locked rooms in lobby or playing status
|
||||
whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.isLocked, false), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'lobby'), (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'playing')));
|
||||
return await db_1.db.query.arcadeRooms.findMany({
|
||||
where: whereConditions.length > 0 ? (0, drizzle_orm_1.and)(...whereConditions) : undefined,
|
||||
orderBy: [(0, drizzle_orm_1.desc)(db_1.schema.arcadeRooms.lastActivity)],
|
||||
limit: 50, // Limit to 50 most recent rooms
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Clean up expired rooms
|
||||
* Delete rooms that have exceeded their TTL
|
||||
*/
|
||||
async function cleanupExpiredRooms() {
|
||||
const now = new Date();
|
||||
// Find rooms where lastActivity + ttlMinutes < now
|
||||
const expiredRooms = await db_1.db.query.arcadeRooms.findMany({
|
||||
columns: { id: true, ttlMinutes: true, lastActivity: true },
|
||||
});
|
||||
const toDelete = expiredRooms.filter((room) => {
|
||||
const expiresAt = new Date(room.lastActivity.getTime() + room.ttlMinutes * 60 * 1000);
|
||||
return expiresAt < now;
|
||||
});
|
||||
if (toDelete.length > 0) {
|
||||
const ids = toDelete.map((r) => r.id);
|
||||
await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.or)(...ids.map((id) => (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, id))));
|
||||
console.log(`[Room Manager] Cleaned up ${toDelete.length} expired rooms`);
|
||||
}
|
||||
return toDelete.length;
|
||||
}
|
||||
/**
|
||||
* Check if a user is the creator of a room
|
||||
*/
|
||||
async function isRoomCreator(roomId, userId) {
|
||||
const room = await getRoomById(roomId);
|
||||
return room?.createdBy === userId;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Room membership manager
|
||||
* Handles database operations for room members
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.addRoomMember = addRoomMember;
|
||||
exports.getRoomMember = getRoomMember;
|
||||
exports.getRoomMembers = getRoomMembers;
|
||||
exports.getOnlineRoomMembers = getOnlineRoomMembers;
|
||||
exports.setMemberOnline = setMemberOnline;
|
||||
exports.touchMember = touchMember;
|
||||
exports.removeMember = removeMember;
|
||||
exports.removeAllMembers = removeAllMembers;
|
||||
exports.getOnlineMemberCount = getOnlineMemberCount;
|
||||
exports.isMember = isMember;
|
||||
exports.getUserRooms = getUserRooms;
|
||||
const drizzle_orm_1 = require("drizzle-orm");
|
||||
const db_1 = require("../../db");
|
||||
/**
|
||||
* Add a member to a room
|
||||
* Automatically removes user from any other rooms they're in (modal room enforcement)
|
||||
* Returns the new membership and info about rooms that were auto-left
|
||||
*/
|
||||
async function addRoomMember(options) {
|
||||
const now = new Date();
|
||||
// Check if member already exists in THIS room
|
||||
const existing = await getRoomMember(options.roomId, options.userId);
|
||||
if (existing) {
|
||||
// Already in this room - just update status (no auto-leave needed)
|
||||
const [updated] = await db_1.db
|
||||
.update(db_1.schema.roomMembers)
|
||||
.set({
|
||||
isOnline: true,
|
||||
lastSeen: now,
|
||||
})
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.id, existing.id))
|
||||
.returning();
|
||||
return { member: updated };
|
||||
}
|
||||
// AUTO-LEAVE LOGIC: Remove from all other rooms before joining this one
|
||||
const currentRooms = await getUserRooms(options.userId);
|
||||
const autoLeaveResult = {
|
||||
leftRooms: [],
|
||||
previousRoomMembers: [],
|
||||
};
|
||||
for (const roomId of currentRooms) {
|
||||
if (roomId !== options.roomId) {
|
||||
// Get member info before removing (for socket events)
|
||||
const memberToRemove = await getRoomMember(roomId, options.userId);
|
||||
if (memberToRemove) {
|
||||
autoLeaveResult.previousRoomMembers.push({
|
||||
roomId,
|
||||
member: memberToRemove,
|
||||
});
|
||||
}
|
||||
// Remove from room
|
||||
await removeMember(roomId, options.userId);
|
||||
autoLeaveResult.leftRooms.push(roomId);
|
||||
console.log(`[Room Membership] Auto-left room ${roomId} for user ${options.userId}`);
|
||||
}
|
||||
}
|
||||
// Now add to new room
|
||||
const newMember = {
|
||||
roomId: options.roomId,
|
||||
userId: options.userId,
|
||||
displayName: options.displayName,
|
||||
isCreator: options.isCreator || false,
|
||||
joinedAt: now,
|
||||
lastSeen: now,
|
||||
isOnline: true,
|
||||
};
|
||||
try {
|
||||
const [member] = await db_1.db.insert(db_1.schema.roomMembers).values(newMember).returning();
|
||||
console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId);
|
||||
return {
|
||||
member,
|
||||
autoLeaveResult: autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
// Handle unique constraint violation
|
||||
// This should rarely happen due to auto-leave logic above, but catch it for safety
|
||||
if (error.code === 'SQLITE_CONSTRAINT' ||
|
||||
error.message?.includes('UNIQUE') ||
|
||||
error.message?.includes('unique')) {
|
||||
console.error('[Room Membership] Unique constraint violation:', error.message);
|
||||
throw new Error('ROOM_MEMBERSHIP_CONFLICT: User is already in another room. This should have been handled by auto-leave logic.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get a specific room member
|
||||
*/
|
||||
async function getRoomMember(roomId, userId) {
|
||||
return await db_1.db.query.roomMembers.findFirst({
|
||||
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)),
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get all members in a room
|
||||
*/
|
||||
async function getRoomMembers(roomId) {
|
||||
return await db_1.db.query.roomMembers.findMany({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId),
|
||||
orderBy: db_1.schema.roomMembers.joinedAt,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Get online members in a room
|
||||
*/
|
||||
async function getOnlineRoomMembers(roomId) {
|
||||
return await db_1.db.query.roomMembers.findMany({
|
||||
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.isOnline, true)),
|
||||
orderBy: db_1.schema.roomMembers.joinedAt,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Update member's online status
|
||||
*/
|
||||
async function setMemberOnline(roomId, userId, isOnline) {
|
||||
await db_1.db
|
||||
.update(db_1.schema.roomMembers)
|
||||
.set({
|
||||
isOnline,
|
||||
lastSeen: new Date(),
|
||||
})
|
||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
|
||||
}
|
||||
/**
|
||||
* Update member's last seen timestamp
|
||||
*/
|
||||
async function touchMember(roomId, userId) {
|
||||
await db_1.db
|
||||
.update(db_1.schema.roomMembers)
|
||||
.set({ lastSeen: new Date() })
|
||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
|
||||
}
|
||||
/**
|
||||
* Remove a member from a room
|
||||
*/
|
||||
async function removeMember(roomId, userId) {
|
||||
await db_1.db
|
||||
.delete(db_1.schema.roomMembers)
|
||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
|
||||
console.log('[Room Membership] Removed member:', userId, 'from room:', roomId);
|
||||
}
|
||||
/**
|
||||
* Remove all members from a room
|
||||
*/
|
||||
async function removeAllMembers(roomId) {
|
||||
await db_1.db.delete(db_1.schema.roomMembers).where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId));
|
||||
console.log('[Room Membership] Removed all members from room:', roomId);
|
||||
}
|
||||
/**
|
||||
* Get count of online members in a room
|
||||
*/
|
||||
async function getOnlineMemberCount(roomId) {
|
||||
const members = await getOnlineRoomMembers(roomId);
|
||||
return members.length;
|
||||
}
|
||||
/**
|
||||
* Check if a user is a member of a room
|
||||
*/
|
||||
async function isMember(roomId, userId) {
|
||||
const member = await getRoomMember(roomId, userId);
|
||||
return !!member;
|
||||
}
|
||||
/**
|
||||
* Get all rooms a user is a member of
|
||||
*/
|
||||
async function getUserRooms(userId) {
|
||||
const memberships = await db_1.db.query.roomMembers.findMany({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId),
|
||||
columns: { roomId: true },
|
||||
});
|
||||
return memberships.map((m) => m.roomId);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Room TTL Cleanup Scheduler
|
||||
* Periodically cleans up expired rooms
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.startRoomTTLCleanup = startRoomTTLCleanup;
|
||||
exports.stopRoomTTLCleanup = stopRoomTTLCleanup;
|
||||
const room_manager_1 = require("./room-manager");
|
||||
// Cleanup interval: run every 5 minutes
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
let cleanupInterval = null;
|
||||
/**
|
||||
* Start the TTL cleanup scheduler
|
||||
* Runs cleanup every 5 minutes
|
||||
*/
|
||||
function startRoomTTLCleanup() {
|
||||
if (cleanupInterval) {
|
||||
console.log('[Room TTL] Cleanup scheduler already running');
|
||||
return;
|
||||
}
|
||||
console.log('[Room TTL] Starting cleanup scheduler (every 5 minutes)');
|
||||
// Run immediately on start
|
||||
(0, room_manager_1.cleanupExpiredRooms)()
|
||||
.then((count) => {
|
||||
if (count > 0) {
|
||||
console.log(`[Room TTL] Initial cleanup removed ${count} expired rooms`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Room TTL] Initial cleanup failed:', error);
|
||||
});
|
||||
// Then run periodically
|
||||
cleanupInterval = setInterval(async () => {
|
||||
try {
|
||||
const count = await (0, room_manager_1.cleanupExpiredRooms)();
|
||||
if (count > 0) {
|
||||
console.log(`[Room TTL] Cleanup removed ${count} expired rooms`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Room TTL] Cleanup failed:', error);
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
/**
|
||||
* Stop the TTL cleanup scheduler
|
||||
*/
|
||||
function stopRoomTTLCleanup() {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
console.log('[Room TTL] Cleanup scheduler stopped');
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Arcade session manager
|
||||
* Handles database operations and validation for arcade sessions
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getArcadeSessionByRoom = getArcadeSessionByRoom;
|
||||
exports.createArcadeSession = createArcadeSession;
|
||||
exports.getArcadeSession = getArcadeSession;
|
||||
exports.applyGameMove = applyGameMove;
|
||||
exports.deleteArcadeSession = deleteArcadeSession;
|
||||
exports.updateSessionActivity = updateSessionActivity;
|
||||
exports.cleanupExpiredSessions = cleanupExpiredSessions;
|
||||
const drizzle_orm_1 = require("drizzle-orm");
|
||||
const db_1 = require("../../db");
|
||||
const validation_1 = require("./validation");
|
||||
const TTL_HOURS = 24;
|
||||
/**
|
||||
* Helper: Get database user ID from guest ID
|
||||
* The API uses guestId (from cookies) but database FKs use the internal user.id
|
||||
*/
|
||||
async function getUserIdFromGuestId(guestId) {
|
||||
const user = await db_1.db.query.users.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, guestId),
|
||||
columns: { id: true },
|
||||
});
|
||||
return user?.id;
|
||||
}
|
||||
/**
|
||||
* Get arcade session by room ID (for room-based multiplayer games)
|
||||
* Returns the shared session for all room members
|
||||
* @param roomId - The room ID
|
||||
*/
|
||||
async function getArcadeSessionByRoom(roomId) {
|
||||
const [session] = await db_1.db
|
||||
.select()
|
||||
.from(db_1.schema.arcadeSessions)
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId))
|
||||
.limit(1);
|
||||
if (!session)
|
||||
return undefined;
|
||||
// Check if session has expired
|
||||
if (session.expiresAt < new Date()) {
|
||||
// Clean up expired room session
|
||||
await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId));
|
||||
return undefined;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
/**
|
||||
* Create a new arcade session
|
||||
* For room-based games, checks if a session already exists for the room
|
||||
*/
|
||||
async function createArcadeSession(options) {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
|
||||
// For room-based games, check if session already exists for this room
|
||||
if (options.roomId) {
|
||||
const existingRoomSession = await getArcadeSessionByRoom(options.roomId);
|
||||
if (existingRoomSession) {
|
||||
console.log('[Session Manager] Room session already exists, returning existing:', {
|
||||
roomId: options.roomId,
|
||||
sessionUserId: existingRoomSession.userId,
|
||||
version: existingRoomSession.version,
|
||||
});
|
||||
return existingRoomSession;
|
||||
}
|
||||
}
|
||||
// Find or create user by guest ID
|
||||
let user = await db_1.db.query.users.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, options.userId),
|
||||
});
|
||||
if (!user) {
|
||||
console.log('[Session Manager] Creating new user with guestId:', options.userId);
|
||||
const [newUser] = await db_1.db
|
||||
.insert(db_1.schema.users)
|
||||
.values({
|
||||
guestId: options.userId, // Let id auto-generate via $defaultFn
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
user = newUser;
|
||||
console.log('[Session Manager] Created user with id:', user.id);
|
||||
}
|
||||
else {
|
||||
console.log('[Session Manager] Found existing user with id:', user.id);
|
||||
}
|
||||
const newSession = {
|
||||
userId: user.id, // Use the actual database ID, not the guestId
|
||||
currentGame: options.gameName,
|
||||
gameUrl: options.gameUrl,
|
||||
gameState: options.initialState,
|
||||
activePlayers: options.activePlayers,
|
||||
roomId: options.roomId, // Associate session with room
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt,
|
||||
isActive: true,
|
||||
version: 1,
|
||||
};
|
||||
console.log('[Session Manager] Creating new session:', {
|
||||
userId: user.id,
|
||||
roomId: options.roomId,
|
||||
gameName: options.gameName,
|
||||
});
|
||||
const [session] = await db_1.db.insert(db_1.schema.arcadeSessions).values(newSession).returning();
|
||||
return session;
|
||||
}
|
||||
/**
|
||||
* Get active arcade session for a user
|
||||
* @param guestId - The guest ID from the cookie (not the database user.id)
|
||||
*/
|
||||
async function getArcadeSession(guestId) {
|
||||
const userId = await getUserIdFromGuestId(guestId);
|
||||
if (!userId)
|
||||
return undefined;
|
||||
const [session] = await db_1.db
|
||||
.select()
|
||||
.from(db_1.schema.arcadeSessions)
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId))
|
||||
.limit(1);
|
||||
if (!session)
|
||||
return undefined;
|
||||
// Check if session has expired
|
||||
if (session.expiresAt < new Date()) {
|
||||
await deleteArcadeSession(guestId);
|
||||
return undefined;
|
||||
}
|
||||
// Check if session has a valid room association
|
||||
// Sessions without rooms are orphaned and should be cleaned up
|
||||
if (!session.roomId) {
|
||||
console.log('[Session Manager] Deleting orphaned session without room:', session.userId);
|
||||
await deleteArcadeSession(guestId);
|
||||
return undefined;
|
||||
}
|
||||
// Verify the room still exists
|
||||
const room = await db_1.db.query.arcadeRooms.findFirst({
|
||||
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, session.roomId),
|
||||
});
|
||||
if (!room) {
|
||||
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId);
|
||||
await deleteArcadeSession(guestId);
|
||||
return undefined;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
/**
|
||||
* Apply a game move to the session (with validation)
|
||||
* @param userId - The guest ID from the cookie
|
||||
* @param move - The game move to apply
|
||||
* @param roomId - Optional room ID for room-based games (enables shared session)
|
||||
*/
|
||||
async function applyGameMove(userId, move, roomId) {
|
||||
// For room-based games, look up the shared room session
|
||||
// For solo games, look up the user's personal session
|
||||
const session = roomId ? await getArcadeSessionByRoom(roomId) : await getArcadeSession(userId);
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No active session found',
|
||||
};
|
||||
}
|
||||
if (!session.isActive) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Session is not active',
|
||||
};
|
||||
}
|
||||
// Get the validator for this game
|
||||
const validator = (0, validation_1.getValidator)(session.currentGame);
|
||||
console.log('[SessionManager] About to validate move:', {
|
||||
moveType: move.type,
|
||||
playerId: move.playerId,
|
||||
gameStateCurrentPlayer: session.gameState?.currentPlayer,
|
||||
gameStateActivePlayers: session.gameState?.activePlayers,
|
||||
gameStatePhase: session.gameState?.gamePhase,
|
||||
});
|
||||
// Fetch player ownership for authorization checks (room-based games)
|
||||
let playerOwnership;
|
||||
let internalUserId;
|
||||
if (session.roomId) {
|
||||
try {
|
||||
// Convert guestId to internal userId for ownership comparison
|
||||
internalUserId = await getUserIdFromGuestId(userId);
|
||||
if (!internalUserId) {
|
||||
console.error('[SessionManager] Failed to convert guestId to userId:', userId);
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
};
|
||||
}
|
||||
const players = await db_1.db.query.players.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
playerOwnership = Object.fromEntries(players.map((p) => [p.id, p.userId]));
|
||||
console.log('[SessionManager] Player ownership map:', playerOwnership);
|
||||
console.log('[SessionManager] Internal userId for authorization:', internalUserId);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[SessionManager] Failed to fetch player ownership:', error);
|
||||
}
|
||||
}
|
||||
// Validate the move with authorization context (use internal userId, not guestId)
|
||||
const validationResult = validator.validateMove(session.gameState, move, {
|
||||
userId: internalUserId || userId, // Use internal userId for room-based games
|
||||
playerOwnership,
|
||||
});
|
||||
console.log('[SessionManager] Validation result:', {
|
||||
valid: validationResult.valid,
|
||||
error: validationResult.error,
|
||||
});
|
||||
if (!validationResult.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validationResult.error || 'Invalid move',
|
||||
};
|
||||
}
|
||||
// Update the session with new state (using optimistic locking)
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
|
||||
try {
|
||||
const [updatedSession] = await db_1.db
|
||||
.update(db_1.schema.arcadeSessions)
|
||||
.set({
|
||||
gameState: validationResult.newState,
|
||||
lastActivityAt: now,
|
||||
expiresAt,
|
||||
version: session.version + 1,
|
||||
})
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, session.userId) // Use the userId from the session we just fetched
|
||||
)
|
||||
// Version check for optimistic locking would go here
|
||||
// SQLite doesn't support WHERE clauses in UPDATE with RETURNING easily
|
||||
// We'll handle this by checking the version after
|
||||
.returning();
|
||||
if (!updatedSession) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update session',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
session: updatedSession,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error updating session:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Database error',
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Delete an arcade session
|
||||
* @param guestId - The guest ID from the cookie (not the database user.id)
|
||||
*/
|
||||
async function deleteArcadeSession(guestId) {
|
||||
const userId = await getUserIdFromGuestId(guestId);
|
||||
if (!userId)
|
||||
return;
|
||||
await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId));
|
||||
}
|
||||
/**
|
||||
* Update session activity timestamp (keep-alive)
|
||||
* @param guestId - The guest ID from the cookie (not the database user.id)
|
||||
*/
|
||||
async function updateSessionActivity(guestId) {
|
||||
const userId = await getUserIdFromGuestId(guestId);
|
||||
if (!userId)
|
||||
return;
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
|
||||
await db_1.db
|
||||
.update(db_1.schema.arcadeSessions)
|
||||
.set({
|
||||
lastActivityAt: now,
|
||||
expiresAt,
|
||||
})
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId));
|
||||
}
|
||||
/**
|
||||
* Clean up expired sessions (should be called periodically)
|
||||
*/
|
||||
async function cleanupExpiredSessions() {
|
||||
const now = new Date();
|
||||
const result = await db_1.db
|
||||
.delete(db_1.schema.arcadeSessions)
|
||||
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.expiresAt, now))
|
||||
.returning();
|
||||
return result.length;
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Server-side validator for matching game
|
||||
* Validates all game moves and state transitions
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.matchingGameValidator = exports.MatchingGameValidator = void 0;
|
||||
const cardGeneration_1 = require("../../../app/games/matching/utils/cardGeneration");
|
||||
const matchValidation_1 = require("../../../app/games/matching/utils/matchValidation");
|
||||
class MatchingGameValidator {
|
||||
validateMove(state, move, context) {
|
||||
switch (move.type) {
|
||||
case 'FLIP_CARD':
|
||||
return this.validateFlipCard(state, move.data.cardId, move.playerId, context);
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.cards, move.data.playerMetadata);
|
||||
case 'CLEAR_MISMATCH':
|
||||
return this.validateClearMismatch(state);
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state);
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value);
|
||||
case 'RESUME_GAME':
|
||||
return this.validateResumeGame(state);
|
||||
case 'HOVER_CARD':
|
||||
return this.validateHoverCard(state, move.data.cardId, move.playerId);
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${move.type}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
validateFlipCard(state, cardId, playerId, context) {
|
||||
// Game must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot flip cards outside of playing phase',
|
||||
};
|
||||
}
|
||||
// Check if it's the player's turn (in multiplayer)
|
||||
if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) {
|
||||
console.log('[Validator] Turn check failed:', {
|
||||
activePlayers: state.activePlayers,
|
||||
currentPlayer: state.currentPlayer,
|
||||
currentPlayerType: typeof state.currentPlayer,
|
||||
playerId,
|
||||
playerIdType: typeof playerId,
|
||||
matches: state.currentPlayer === playerId,
|
||||
});
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Not your turn',
|
||||
};
|
||||
}
|
||||
// Check player ownership authorization (if context provided)
|
||||
if (context?.userId && context?.playerOwnership) {
|
||||
const playerOwner = context.playerOwnership[playerId];
|
||||
if (playerOwner && playerOwner !== context.userId) {
|
||||
console.log('[Validator] Player ownership check failed:', {
|
||||
playerId,
|
||||
playerOwner,
|
||||
requestingUserId: context.userId,
|
||||
});
|
||||
return {
|
||||
valid: false,
|
||||
error: 'You can only move your own players',
|
||||
};
|
||||
}
|
||||
}
|
||||
// Find the card
|
||||
const card = state.gameCards.find((c) => c.id === cardId);
|
||||
if (!card) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Card not found',
|
||||
};
|
||||
}
|
||||
// Validate using existing game logic
|
||||
if (!(0, matchValidation_1.canFlipCard)(card, state.flippedCards, state.isProcessingMove)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot flip this card',
|
||||
};
|
||||
}
|
||||
// Calculate new state
|
||||
const newFlippedCards = [...state.flippedCards, card];
|
||||
let newState = {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
isProcessingMove: newFlippedCards.length === 2,
|
||||
// Clear mismatch feedback when player flips a new card
|
||||
showMismatchFeedback: false,
|
||||
};
|
||||
// If two cards are flipped, check for match
|
||||
if (newFlippedCards.length === 2) {
|
||||
const [card1, card2] = newFlippedCards;
|
||||
const matchResult = (0, matchValidation_1.validateMatch)(card1, card2);
|
||||
if (matchResult.isValid) {
|
||||
// Match found - update cards
|
||||
newState = {
|
||||
...newState,
|
||||
gameCards: newState.gameCards.map((c) => c.id === card1.id || c.id === card2.id
|
||||
? { ...c, matched: true, matchedBy: state.currentPlayer }
|
||||
: c),
|
||||
matchedPairs: state.matchedPairs + 1,
|
||||
scores: {
|
||||
...state.scores,
|
||||
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
|
||||
},
|
||||
consecutiveMatches: {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
|
||||
},
|
||||
moves: state.moves + 1,
|
||||
flippedCards: [],
|
||||
isProcessingMove: false,
|
||||
};
|
||||
// Check if game is complete
|
||||
if (newState.matchedPairs === newState.totalPairs) {
|
||||
newState = {
|
||||
...newState,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Match failed - keep cards flipped briefly so player can see them
|
||||
// Client will handle clearing them after a delay
|
||||
const shouldSwitchPlayer = state.activePlayers.length > 1;
|
||||
const nextPlayerIndex = shouldSwitchPlayer
|
||||
? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length
|
||||
: 0;
|
||||
const nextPlayer = shouldSwitchPlayer
|
||||
? state.activePlayers[nextPlayerIndex]
|
||||
: state.currentPlayer;
|
||||
newState = {
|
||||
...newState,
|
||||
currentPlayer: nextPlayer,
|
||||
consecutiveMatches: {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0,
|
||||
},
|
||||
moves: state.moves + 1,
|
||||
// Keep flippedCards so player can see both cards
|
||||
flippedCards: newFlippedCards,
|
||||
isProcessingMove: true, // Keep processing state so no more cards can be flipped
|
||||
showMismatchFeedback: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
};
|
||||
}
|
||||
validateStartGame(state, activePlayers, cards, playerMetadata) {
|
||||
// Allow starting a new game from any phase (for "New Game" button)
|
||||
// Must have at least one player
|
||||
if (!activePlayers || activePlayers.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Must have at least one player',
|
||||
};
|
||||
}
|
||||
// Use provided cards or generate new ones
|
||||
const gameCards = cards || (0, cardGeneration_1.generateGameCards)(state.gameType, state.difficulty);
|
||||
const newState = {
|
||||
...state,
|
||||
gameCards,
|
||||
cards: gameCards,
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
|
||||
gamePhase: 'playing',
|
||||
gameStartTime: Date.now(),
|
||||
currentPlayer: activePlayers[0],
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
// PAUSE/RESUME: Save original config so we can detect changes
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
},
|
||||
// Clear any paused game state (starting fresh)
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
};
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
};
|
||||
}
|
||||
validateClearMismatch(state) {
|
||||
// Only clear if there's actually a mismatch showing
|
||||
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
|
||||
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
|
||||
// Nothing to clear - return current state unchanged
|
||||
return {
|
||||
valid: true,
|
||||
newState: state,
|
||||
};
|
||||
}
|
||||
// Clear mismatched cards and feedback
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
showMismatchFeedback: false,
|
||||
isProcessingMove: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: GO_TO_SETUP
|
||||
*
|
||||
* Transitions the game back to setup phase, allowing players to reconfigure
|
||||
* the game. This is synchronized across all room members.
|
||||
*
|
||||
* Can be called from any phase (setup, playing, results).
|
||||
*
|
||||
* PAUSE/RESUME: If called from 'playing' or 'results', saves game state
|
||||
* to allow resuming later (if config unchanged).
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Validates the move is allowed
|
||||
* - Sets gamePhase to 'setup'
|
||||
* - Preserves current configuration (gameType, difficulty, etc.)
|
||||
* - Saves game state for resume if coming from active game
|
||||
* - Resets game progression state (scores, cards, etc.)
|
||||
*/
|
||||
validateGoToSetup(state) {
|
||||
// Determine if we're pausing an active game (for Resume functionality)
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results';
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// Pause/Resume: Save game state if pausing from active game
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
gameCards: state.gameCards,
|
||||
currentPlayer: state.currentPlayer,
|
||||
matchedPairs: state.matchedPairs,
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
// Keep originalConfig if it exists (was set when game started)
|
||||
// This allows detecting if config changed while paused
|
||||
// Reset visible game progression
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// Preserve configuration - players can modify in setup
|
||||
// gameType, difficulty, turnTimer stay as-is
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: SET_CONFIG
|
||||
*
|
||||
* Updates a configuration field during setup phase. This is synchronized
|
||||
* across all room members in real-time, allowing collaborative setup.
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Only allowed during setup phase
|
||||
* - Validates field name and value
|
||||
* - Updates the configuration field
|
||||
* - Other room members see the change immediately (optimistic + server validation)
|
||||
*
|
||||
* @param state Current game state
|
||||
* @param field Configuration field name
|
||||
* @param value New value for the field
|
||||
*/
|
||||
validateSetConfig(state, field, value) {
|
||||
// Can only change config during setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot change configuration outside of setup phase',
|
||||
};
|
||||
}
|
||||
// Validate field-specific values
|
||||
switch (field) {
|
||||
case 'gameType':
|
||||
if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
|
||||
return { valid: false, error: `Invalid gameType: ${value}` };
|
||||
}
|
||||
break;
|
||||
case 'difficulty':
|
||||
if (![6, 8, 12, 15].includes(value)) {
|
||||
return { valid: false, error: `Invalid difficulty: ${value}` };
|
||||
}
|
||||
break;
|
||||
case 'turnTimer':
|
||||
if (typeof value !== 'number' || value < 5 || value > 300) {
|
||||
return { valid: false, error: `Invalid turnTimer: ${value}` };
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return { valid: false, error: `Unknown config field: ${field}` };
|
||||
}
|
||||
// PAUSE/RESUME: If there's a paused game and config is changing,
|
||||
// clear the paused game state (can't resume anymore)
|
||||
const clearPausedGame = !!state.pausedGamePhase;
|
||||
// Apply the configuration change
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update totalPairs if difficulty changes
|
||||
...(field === 'difficulty' ? { totalPairs: value } : {}),
|
||||
// Clear paused game if config changed
|
||||
...(clearPausedGame
|
||||
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: RESUME_GAME
|
||||
*
|
||||
* Resumes a paused game if configuration hasn't changed.
|
||||
* Restores the saved game state from when GO_TO_SETUP was called.
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Validates there's a paused game
|
||||
* - Validates config hasn't changed since pause
|
||||
* - Restores game state and phase
|
||||
* - Clears paused game state
|
||||
*/
|
||||
validateResumeGame(state) {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only resume from setup phase',
|
||||
};
|
||||
}
|
||||
// Must have a paused game
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No paused game to resume',
|
||||
};
|
||||
}
|
||||
// Config must match original (no changes while paused)
|
||||
if (state.originalConfig) {
|
||||
const configChanged = state.gameType !== state.originalConfig.gameType ||
|
||||
state.difficulty !== state.originalConfig.difficulty ||
|
||||
state.turnTimer !== state.originalConfig.turnTimer;
|
||||
if (configChanged) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot resume - configuration has changed',
|
||||
};
|
||||
}
|
||||
}
|
||||
// Restore the paused game
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
gameCards: state.pausedGameState.gameCards,
|
||||
cards: state.pausedGameState.gameCards,
|
||||
currentPlayer: state.pausedGameState.currentPlayer,
|
||||
matchedPairs: state.pausedGameState.matchedPairs,
|
||||
moves: state.pausedGameState.moves,
|
||||
scores: state.pausedGameState.scores,
|
||||
activePlayers: state.pausedGameState.activePlayers,
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
// Clear paused state
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
// Keep originalConfig for potential future pauses
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Validate hover state update for networked presence
|
||||
*
|
||||
* Hover moves are lightweight and always valid - they just update
|
||||
* which card a player is hovering over for UI feedback to other players.
|
||||
*/
|
||||
validateHoverCard(state, cardId, playerId) {
|
||||
// Hover is always valid - it's just UI state for networked presence
|
||||
// Update the player's hover state
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[playerId]: cardId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
isGameComplete(state) {
|
||||
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs;
|
||||
}
|
||||
getInitialState(config) {
|
||||
return {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
gameType: config.gameType,
|
||||
difficulty: config.difficulty,
|
||||
turnTimer: config.turnTimer,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
totalPairs: config.difficulty,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {}, // Initialize empty player metadata
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Initialize paused game fields
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
// HOVER: Initialize hover state
|
||||
playerHovers: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.MatchingGameValidator = MatchingGameValidator;
|
||||
// Singleton instance
|
||||
exports.matchingGameValidator = new MatchingGameValidator();
|
||||
@@ -1,37 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Game validator registry
|
||||
* Maps game names to their validators
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.matchingGameValidator = void 0;
|
||||
exports.getValidator = getValidator;
|
||||
const MatchingGameValidator_1 = require("./MatchingGameValidator");
|
||||
const validators = new Map([
|
||||
['matching', MatchingGameValidator_1.matchingGameValidator],
|
||||
// Add other game validators here as they're implemented
|
||||
]);
|
||||
function getValidator(gameName) {
|
||||
const validator = validators.get(gameName);
|
||||
if (!validator) {
|
||||
throw new Error(`No validator found for game: ${gameName}`);
|
||||
}
|
||||
return validator;
|
||||
}
|
||||
var MatchingGameValidator_2 = require("./MatchingGameValidator");
|
||||
Object.defineProperty(exports, "matchingGameValidator", { enumerable: true, get: function () { return MatchingGameValidator_2.matchingGameValidator; } });
|
||||
__exportStar(require("./types"), exports);
|
||||
@@ -1,6 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Isomorphic game validation types
|
||||
* Used on both client and server for arcade session validation
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -1,154 +0,0 @@
|
||||
// Test file for typst.ts integration
|
||||
// This will test if we can render our existing Typst templates using typst.ts
|
||||
|
||||
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
|
||||
import fs from 'fs'
|
||||
|
||||
async function testBasicTypst() {
|
||||
console.log('🧪 Testing basic typst.ts functionality...')
|
||||
|
||||
try {
|
||||
// Test basic rendering
|
||||
const result = await $typst.svg({ mainContent: 'Hello, typst!' })
|
||||
console.log('✅ Basic typst.ts working!')
|
||||
console.log('📏 SVG length:', result.length)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Basic typst.ts failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function testSorobanTemplate() {
|
||||
console.log('🧮 Testing soroban template rendering...')
|
||||
|
||||
try {
|
||||
// Read our existing flashcards.typ template
|
||||
const { FLASHCARDS_TEMPLATE, SINGLE_CARD_TEMPLATE } = require('@soroban/templates')
|
||||
const flashcardsTemplate = fs.readFileSync(FLASHCARDS_TEMPLATE, 'utf-8')
|
||||
const singleCardTemplate = fs.readFileSync(SINGLE_CARD_TEMPLATE, 'utf-8')
|
||||
|
||||
console.log('📁 Templates loaded successfully')
|
||||
console.log('📏 flashcards.typ length:', flashcardsTemplate.length)
|
||||
console.log('📏 single-card.typ length:', singleCardTemplate.length)
|
||||
|
||||
// Create a simple test document that uses our templates
|
||||
const testContent = `
|
||||
${flashcardsTemplate}
|
||||
|
||||
// Test drawing a simple soroban for number 5
|
||||
#draw-soroban(5, columns: auto, show-empty: false, hide-inactive: false, bead-shape: "diamond", color-scheme: "place-value", base-size: 1.0)
|
||||
`
|
||||
|
||||
console.log('🎯 Attempting to render soroban for number 5...')
|
||||
|
||||
const result = await $typst.svg({ mainContent: testContent })
|
||||
|
||||
console.log('✅ Soroban template rendering successful!')
|
||||
console.log('📏 Generated SVG length:', result.length)
|
||||
console.log('🔍 SVG preview:', `${result.substring(0, 200)}...`)
|
||||
|
||||
// Save the result for inspection
|
||||
fs.writeFileSync('/tmp/soroban-test.svg', result)
|
||||
console.log('💾 Saved test SVG to /tmp/soroban-test.svg')
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('❌ Soroban template rendering failed:', error)
|
||||
console.error('📋 Error details:', error.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function testSingleCard() {
|
||||
console.log('🃏 Testing single card template...')
|
||||
|
||||
try {
|
||||
// Read templates
|
||||
const { FLASHCARDS_TEMPLATE, SINGLE_CARD_TEMPLATE } = require('@soroban/templates')
|
||||
const flashcardsTemplate = fs.readFileSync(FLASHCARDS_TEMPLATE, 'utf-8')
|
||||
const singleCardTemplate = fs.readFileSync(SINGLE_CARD_TEMPLATE, 'utf-8')
|
||||
|
||||
// Extract just the functions we need from single-card.typ and inline them
|
||||
// Remove the import line and create an inlined version
|
||||
const singleCardInlined = singleCardTemplate.replace(
|
||||
'#import "flashcards.typ": draw-soroban',
|
||||
'// Inlined draw-soroban from flashcards.typ'
|
||||
)
|
||||
|
||||
// Create test content using inlined single-card template
|
||||
const testContent = `
|
||||
${flashcardsTemplate}
|
||||
${singleCardInlined}
|
||||
|
||||
#set page(
|
||||
width: 120pt,
|
||||
height: 160pt,
|
||||
margin: 0pt,
|
||||
fill: white
|
||||
)
|
||||
|
||||
#set text(font: "DejaVu Sans", size: 48pt, fallback: true)
|
||||
|
||||
#align(center + horizon)[
|
||||
#box(
|
||||
width: 120pt - 2 * (120pt * 0.05),
|
||||
height: 160pt - 2 * (160pt * 0.05)
|
||||
)[
|
||||
#align(center + horizon)[
|
||||
#scale(x: 100%, y: 100%)[
|
||||
#draw-soroban(
|
||||
23,
|
||||
columns: auto,
|
||||
show-empty: false,
|
||||
hide-inactive: false,
|
||||
bead-shape: "diamond",
|
||||
color-scheme: "place-value",
|
||||
color-palette: "default",
|
||||
base-size: 1.0
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
`
|
||||
|
||||
console.log('🎯 Attempting to render single card for number 23...')
|
||||
|
||||
const result = await $typst.svg({ mainContent: testContent })
|
||||
|
||||
console.log('✅ Single card rendering successful!')
|
||||
console.log('📏 Generated SVG length:', result.length)
|
||||
|
||||
// Save the result
|
||||
fs.writeFileSync('/tmp/single-card-test.svg', result)
|
||||
console.log('💾 Saved single card test SVG to /tmp/single-card-test.svg')
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('❌ Single card rendering failed:', error)
|
||||
console.error('📋 Error details:', error.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Run the tests
|
||||
async function runTests() {
|
||||
console.log('🚀 Starting typst.ts integration tests...\n')
|
||||
|
||||
const basicTest = await testBasicTypst()
|
||||
if (!basicTest) {
|
||||
console.log('❌ Basic test failed, aborting further tests')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('\n')
|
||||
await testSorobanTemplate()
|
||||
|
||||
console.log('\n')
|
||||
await testSingleCard()
|
||||
|
||||
console.log('\n🏁 Tests completed!')
|
||||
}
|
||||
|
||||
runTests().catch(console.error)
|
||||
@@ -3,8 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2020",
|
||||
"outDir": ".",
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": false,
|
||||
"incremental": false,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user