refactor: new structure
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate output HTML from template + data for collecting-asteroids documents.
|
||||
*
|
||||
* Usage: node generate.mjs <docId>
|
||||
* Example: node generate.mjs collecting-asteroids-3
|
||||
*
|
||||
* Reads: docs/<docId>.template.html
|
||||
* Reads: docs/<docId>.data.json (optional)
|
||||
* Writes: docs/<docId>.output.html
|
||||
*
|
||||
* data.json format:
|
||||
* {
|
||||
* pages: [{
|
||||
* page: 1,
|
||||
* asteroids: [{ index, left, top, rotate, scale, value, asteroid }],
|
||||
* ships: [{ index, left, top, value }],
|
||||
* baseScales: { "asteroid5": 0.35 }
|
||||
* }]
|
||||
* }
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const docsDir = join(__dirname, '..', 'docs');
|
||||
|
||||
const docId = process.argv[2];
|
||||
if (!docId) {
|
||||
console.error('Usage: node generate.mjs <docId>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const templatePath = join(docsDir, `${docId}.template.html`);
|
||||
const dataPath = join(docsDir, `${docId}.data.json`);
|
||||
const outputPath = join(docsDir, `${docId}.output.html`);
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
console.error(`Template not found: ${templatePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let html = readFileSync(templatePath, 'utf-8');
|
||||
|
||||
if (existsSync(dataPath)) {
|
||||
const data = JSON.parse(readFileSync(dataPath, 'utf-8'));
|
||||
html = applyData(html, data);
|
||||
console.log(`Applied data from ${data.pages?.length || 0} pages`);
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, html);
|
||||
console.log(`Generated: ${outputPath}`);
|
||||
|
||||
function applyData(html, data) {
|
||||
if (!data.pages) return html;
|
||||
|
||||
// Split HTML into pages by PAGE comment markers or by w-[210mm] divs
|
||||
const pageMarker = /<!-- PAGE (\d+)/g;
|
||||
const hasPageComments = pageMarker.test(html);
|
||||
|
||||
if (hasPageComments) {
|
||||
// Split by page comments
|
||||
return applyByPageComments(html, data.pages);
|
||||
} else {
|
||||
// Split by page divs
|
||||
return applyByPageDivs(html, data.pages);
|
||||
}
|
||||
}
|
||||
|
||||
function applyByPageDivs(html, pages) {
|
||||
// Find page boundaries: <div class="w-[210mm] h-[297mm]"
|
||||
const pageRegex = /<div class="w-\[210mm\] h-\[297mm\]"/g;
|
||||
const starts = [];
|
||||
let match;
|
||||
while ((match = pageRegex.exec(html)) !== null) {
|
||||
starts.push(match.index);
|
||||
}
|
||||
|
||||
if (starts.length === 0) return html;
|
||||
|
||||
// Process pages in reverse order to preserve indices
|
||||
for (let i = pages.length - 1; i >= 0; i--) {
|
||||
const pageData = pages[i];
|
||||
const pageNum = pageData.page || (i + 1);
|
||||
const pageIdx = pageNum - 1;
|
||||
|
||||
if (pageIdx >= starts.length) continue;
|
||||
|
||||
const pageStart = starts[pageIdx];
|
||||
const pageEnd = pageIdx + 1 < starts.length ? starts[pageIdx + 1] : html.length;
|
||||
let pageHtml = html.slice(pageStart, pageEnd);
|
||||
|
||||
// Apply asteroid positions
|
||||
if (pageData.asteroids) {
|
||||
pageHtml = applyAsteroids(pageHtml, pageData.asteroids);
|
||||
}
|
||||
|
||||
// Apply ship positions
|
||||
if (pageData.ships) {
|
||||
pageHtml = applyShips(pageHtml, pageData.ships);
|
||||
}
|
||||
|
||||
html = html.slice(0, pageStart) + pageHtml + html.slice(pageEnd);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function applyByPageComments(html, pages) {
|
||||
// Same logic but split by <!-- PAGE N -->
|
||||
const parts = html.split(/(<!-- PAGE \d+)/);
|
||||
const pageChunks = [];
|
||||
let prelude = parts[0];
|
||||
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
pageChunks.push(parts[i] + (parts[i + 1] || ''));
|
||||
}
|
||||
|
||||
for (const pageData of pages) {
|
||||
const pageIdx = (pageData.page || 1) - 1;
|
||||
if (pageIdx >= pageChunks.length) continue;
|
||||
|
||||
if (pageData.asteroids) {
|
||||
pageChunks[pageIdx] = applyAsteroids(pageChunks[pageIdx], pageData.asteroids);
|
||||
}
|
||||
if (pageData.ships) {
|
||||
pageChunks[pageIdx] = applyShips(pageChunks[pageIdx], pageData.ships);
|
||||
}
|
||||
}
|
||||
|
||||
return prelude + pageChunks.join('');
|
||||
}
|
||||
|
||||
function applyAsteroids(pageHtml, asteroids) {
|
||||
// Match asteroid containers: <div class="absolute" style="left: Xmm; top: Ymm;">
|
||||
// followed by pack3-asteroids image
|
||||
const asteroidRegex = /(<div class="absolute" style="left: )-?\d+mm; top: -?\d+mm(;"><div class="relative w-\[88px\] h-\[88px\]"><img src="[^"]*pack3-asteroids\/[^"]*" class="w-full h-full object-contain" style="transform: rotate\()-?\d+deg(\) scale\()[\d.]+(\);" alt=""><div class="absolute inset-0 flex items-center justify-center"><span class="text-white font-extrabold text-2xl" style="text-shadow[^"]*">)\d+(<\/span>)/g;
|
||||
|
||||
let idx = 0;
|
||||
return pageHtml.replace(asteroidRegex, (match, prefix, rotPrefix, scalePrefix, scaleSuffix, valueSuffix) => {
|
||||
const ast = asteroids[idx++];
|
||||
if (!ast) return match;
|
||||
|
||||
const left = ast.left.replace('mm', '');
|
||||
const top = ast.top.replace('mm', '');
|
||||
const rotate = ast.rotate.replace('deg', '');
|
||||
const scale = ast.scale.toFixed(2);
|
||||
const value = ast.value;
|
||||
|
||||
return `${prefix}${left}mm; top: ${top}mm${rotPrefix}${rotate}deg${scalePrefix}${scale}${scaleSuffix}${value}${valueSuffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
function applyShips(pageHtml, ships) {
|
||||
// Match ship capacity circles
|
||||
const shipRegex = /(<div class="absolute w-12 h-12 rounded-full bg-indigo-600[^"]*" style="left: )[\d.]+%(; top: )[\d.]+(%[^>]*><span class="text-white font-extrabold text-xl">)\d+(<\/span>)/g;
|
||||
|
||||
let idx = 0;
|
||||
return pageHtml.replace(shipRegex, (match, prefix, topPrefix, valueSuffix, spanClose) => {
|
||||
const ship = ships[idx++];
|
||||
if (!ship) return match;
|
||||
|
||||
const left = ship.left.replace('%', '');
|
||||
const top = ship.top.replace('%', '');
|
||||
|
||||
return `${prefix}${left}%${topPrefix}${top}${valueSuffix}${ship.value}${spanClose}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Transform collecting-asteroids-2.html into collecting-asteroids-3.html
|
||||
* Output goes to stdout for review before saving.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const v2Html = readFileSync(resolve(root, 'output/html/collecting-asteroids-2.html'), 'utf-8');
|
||||
|
||||
// Page configs for v3
|
||||
const pages = [
|
||||
{
|
||||
comment: 'PAGE 1: ships 11(2), 9(2), 13(6) — R-L-R',
|
||||
hero: 'splitter4', footer: 'cabin3',
|
||||
cargoBays: [7, 8, 9],
|
||||
ships: [11, 9, 13],
|
||||
oldAsteroidTypes: ['asteroid7', 'asteroid14'],
|
||||
newAsteroidTypes: ['asteroid3', 'asteroid10'],
|
||||
asteroidValues: [3, 2, 1, 4, 5, 2, 3, 6, 5, 4, 1, 6],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 2: ships 12(3), 12(3), 18(4) — L-R-L',
|
||||
hero: 'splitter7', footer: 'cabin6',
|
||||
cargoBays: [1, 2, 3],
|
||||
ships: [12, 12, 18],
|
||||
oldAsteroidTypes: ['asteroid4', 'asteroid11'],
|
||||
newAsteroidTypes: ['asteroid6', 'asteroid13'],
|
||||
asteroidValues: [4, 5, 3, 6, 2, 7, 4, 3, 5, 2, 6, 4],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 3: ships 9(5), 11(3), 11(2) — R-L-R',
|
||||
hero: 'splitter1', footer: 'cabin9',
|
||||
cargoBays: [4, 5, 6],
|
||||
ships: [9, 11, 11],
|
||||
oldAsteroidTypes: ['asteroid10', 'asteroid3'],
|
||||
newAsteroidTypes: ['asteroid2', 'asteroid8'],
|
||||
asteroidValues: [2, 4, 1, 3, 5, 2, 1, 3, 4, 6, 5, 2],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 4: ships 15(4), 17(5), 11(2) — L-R-L',
|
||||
hero: 'splitter6', footer: 'cabin2',
|
||||
cargoBays: [7, 8, 9],
|
||||
ships: [15, 17, 11],
|
||||
oldAsteroidTypes: ['asteroid16', 'asteroid5'],
|
||||
newAsteroidTypes: ['asteroid12', 'asteroid15'],
|
||||
asteroidValues: [4, 3, 5, 2, 6, 1, 3, 4, 6, 2, 5, 4],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 5: ships 7(2), 9(4), 7(2) — R-L-R',
|
||||
hero: 'splitter2', footer: 'cabin5',
|
||||
cargoBays: [1, 2, 3],
|
||||
ships: [7, 9, 7],
|
||||
oldAsteroidTypes: ['asteroid2', 'asteroid13'],
|
||||
newAsteroidTypes: ['asteroid1', 'asteroid9'],
|
||||
asteroidValues: [3, 1, 4, 2, 5, 1, 6, 2, 3, 4, 5],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 6: ships 18(4), 18(5), 14(3) — L-R-L',
|
||||
hero: 'splitter8', footer: 'cabin7',
|
||||
cargoBays: [4, 5, 6],
|
||||
ships: [18, 18, 14],
|
||||
oldAsteroidTypes: ['asteroid8', 'asteroid12'],
|
||||
newAsteroidTypes: ['asteroid5', 'asteroid16'],
|
||||
asteroidValues: [3, 4, 5, 6, 2, 4, 7, 3, 2, 5, 6, 4, 3, 7],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 7: ships 10(2), 16(3), 22(6) — R-L-R',
|
||||
hero: 'splitter9', footer: 'cabin4',
|
||||
cargoBays: [7, 8, 9],
|
||||
ships: [10, 16, 22],
|
||||
oldAsteroidTypes: ['asteroid15', 'asteroid6'],
|
||||
newAsteroidTypes: ['asteroid11', 'asteroid7'],
|
||||
asteroidValues: [4, 3, 2, 6, 5, 3, 7, 4, 5, 2, 6, 4],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 8: ships 26(0), 18(0), 30(0) — UNSOLVABLE — L-R-L',
|
||||
hero: 'splitter5', footer: 'cabin1',
|
||||
cargoBays: [1, 2, 3],
|
||||
ships: [26, 18, 30],
|
||||
oldAsteroidTypes: ['asteroid9', 'asteroid1'],
|
||||
newAsteroidTypes: ['asteroid14', 'asteroid4'],
|
||||
asteroidValues: [6, 4, 5, 3, 7, 2, 4, 6, 5, 7, 3, 4, 6],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 9: ships 8(2), 18(3), 28(8) — R-L-R',
|
||||
hero: 'splitter3', footer: 'cabin8',
|
||||
cargoBays: [4, 5, 6],
|
||||
ships: [8, 18, 28],
|
||||
oldAsteroidTypes: ['asteroid11', 'asteroid4'],
|
||||
newAsteroidTypes: ['asteroid10', 'asteroid6'],
|
||||
asteroidValues: [4, 3, 2, 5, 6, 3, 4, 7, 2, 5, 4, 6, 3, 7, 5],
|
||||
},
|
||||
];
|
||||
|
||||
// Split HTML into pages
|
||||
// Pages are delimited by <!-- PAGE N comments
|
||||
const pageRegex = /<!-- PAGE \d+/g;
|
||||
const matches = [...v2Html.matchAll(pageRegex)];
|
||||
|
||||
// Extract page blocks
|
||||
const pageBlocks = [];
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const start = matches[i].index;
|
||||
const end = i + 1 < matches.length ? matches[i + 1].index : v2Html.lastIndexOf('</body>');
|
||||
pageBlocks.push(v2Html.substring(start, end));
|
||||
}
|
||||
|
||||
// Extract header (before first page) and footer (after last page)
|
||||
const header = v2Html.substring(0, matches[0].index);
|
||||
const footer = v2Html.substring(v2Html.lastIndexOf('</body>'));
|
||||
|
||||
function transformPage(block, config, pageIdx) {
|
||||
let html = block;
|
||||
|
||||
// 1. Replace page comment
|
||||
html = html.replace(/<!-- PAGE \d+:.*?-->/, `<!-- ${config.comment} -->`);
|
||||
|
||||
// 2. Replace hero image
|
||||
html = html.replace(
|
||||
/splitters\/splitter\d+\.png/,
|
||||
`splitters/${config.hero}.png`
|
||||
);
|
||||
|
||||
// 3. Replace footer image
|
||||
html = html.replace(
|
||||
/footers\/cabin\d+\.jpeg/,
|
||||
`footers/${config.footer}.jpeg`
|
||||
);
|
||||
|
||||
// 4. Replace cargo bay images (3 per page, in order)
|
||||
let cbIdx = 0;
|
||||
html = html.replace(
|
||||
/pack4-cargobay\/cargo-bay\d+\.png/g,
|
||||
(match) => `pack4-cargobay/cargo-bay${config.cargoBays[cbIdx++]}.png`
|
||||
);
|
||||
|
||||
// 5. Replace ship capacity numbers (text-xl spans inside blue circles)
|
||||
let shipIdx = 0;
|
||||
html = html.replace(
|
||||
/(<span class="text-white font-extrabold text-xl">)(\d+)(<\/span>)/g,
|
||||
(match, pre, val, post) => `${pre}${config.ships[shipIdx++]}${post}`
|
||||
);
|
||||
|
||||
// 6. Replace asteroid type images
|
||||
for (let i = 0; i < config.oldAsteroidTypes.length; i++) {
|
||||
const oldType = config.oldAsteroidTypes[i];
|
||||
const newType = config.newAsteroidTypes[i];
|
||||
html = html.replaceAll(
|
||||
`pack3-asteroids/${oldType}.png`,
|
||||
`pack3-asteroids/${newType}.png`
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Replace asteroid values (text-2xl spans, sequential)
|
||||
let avIdx = 0;
|
||||
html = html.replace(
|
||||
/(<span class="text-white font-extrabold text-2xl"[^>]*>)(\d+)(<\/span>)/g,
|
||||
(match, pre, val, post) => {
|
||||
if (avIdx < config.asteroidValues.length) {
|
||||
return `${pre}${config.asteroidValues[avIdx++]}${post}`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
|
||||
// Sanity check
|
||||
if (shipIdx !== 3) {
|
||||
console.error(`WARNING: Page ${pageIdx + 1} — replaced ${shipIdx} ship values (expected 3)`);
|
||||
}
|
||||
if (avIdx !== config.asteroidValues.length) {
|
||||
console.error(`WARNING: Page ${pageIdx + 1} — replaced ${avIdx} asteroid values (expected ${config.asteroidValues.length})`);
|
||||
}
|
||||
if (cbIdx !== 3) {
|
||||
console.error(`WARNING: Page ${pageIdx + 1} — replaced ${cbIdx} cargo bay refs (expected 3)`);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Transform all pages
|
||||
const newPages = pageBlocks.map((block, i) => transformPage(block, pages[i], i));
|
||||
|
||||
// Assemble
|
||||
const result = header + newPages.join('') + footer;
|
||||
|
||||
// Write to temp file
|
||||
import { writeFileSync } from 'fs';
|
||||
const tmpPath = resolve(root, 'output/html/.collecting-asteroids-3-tmp.html');
|
||||
writeFileSync(tmpPath, result);
|
||||
console.log(`Output written to: ${tmpPath}`);
|
||||
console.log(`Total lines: ${result.split('\n').length}`);
|
||||
|
||||
// Print summary of changes per page
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const p = pages[i];
|
||||
console.log(`\nPage ${i + 1}: hero=${p.hero} footer=${p.footer} cargoBays=[${p.cargoBays}]`);
|
||||
console.log(` Ships: [${p.ships}] Asteroids: [${p.asteroidValues}]`);
|
||||
console.log(` Types: ${p.oldAsteroidTypes} → ${p.newAsteroidTypes}`);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Asteroid partition solver.
|
||||
* Verifies that disjoint subsets of asteroid values can sum to each ship capacity.
|
||||
*
|
||||
* Usage:
|
||||
* node verify-asteroids.mjs --ships 10,10,14 --asteroids 3,2,1,4,5,2,3,6,5,4,1,6
|
||||
*
|
||||
* Output: prints whether a valid partition exists and one example solution.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function parseArg(flag) {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx === -1 || idx + 1 >= args.length) return null;
|
||||
return args[idx + 1].split(',').map(Number);
|
||||
}
|
||||
|
||||
const ships = parseArg('--ships');
|
||||
const asteroids = parseArg('--asteroids');
|
||||
|
||||
if (!ships || !asteroids) {
|
||||
console.log('Usage: node verify-asteroids.mjs --ships 10,10,14 --asteroids 3,2,1,4,5,2,3,6,5,4,1,6');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Ships: [${ships.join(', ')}] (sum=${ships.reduce((a, b) => a + b, 0)})`);
|
||||
console.log(`Asteroids: [${asteroids.join(', ')}] (count=${asteroids.length}, sum=${asteroids.reduce((a, b) => a + b, 0)})`);
|
||||
|
||||
/**
|
||||
* Find disjoint subsets of `available` (by index) that sum to each capacity in `capacities`.
|
||||
* Returns array of index arrays, or null if no solution.
|
||||
*/
|
||||
function findPartition(available, capacities, usedSet = new Set()) {
|
||||
if (capacities.length === 0) return [];
|
||||
|
||||
const target = capacities[0];
|
||||
const remaining = capacities.slice(1);
|
||||
const remainingSum = remaining.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Available sum check
|
||||
let availSum = 0;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
if (!usedSet.has(i)) availSum += available[i];
|
||||
}
|
||||
if (availSum < target + remainingSum) return null;
|
||||
|
||||
// Find all subsets summing to target using DFS
|
||||
const subset = [];
|
||||
|
||||
function dfs(startIdx, currentSum) {
|
||||
if (currentSum === target) {
|
||||
// Try solving remaining capacities
|
||||
const newUsed = new Set(usedSet);
|
||||
for (const idx of subset) newUsed.add(idx);
|
||||
const result = findPartition(available, remaining, newUsed);
|
||||
if (result !== null) {
|
||||
return [subset.slice(), ...result];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentSum > target) return null;
|
||||
|
||||
for (let i = startIdx; i < available.length; i++) {
|
||||
if (usedSet.has(i)) continue;
|
||||
if (currentSum + available[i] > target) continue;
|
||||
|
||||
// Pruning: check if remaining available sum can reach target
|
||||
let futureSum = 0;
|
||||
for (let j = i; j < available.length; j++) {
|
||||
if (!usedSet.has(j) && !subset.includes(j)) futureSum += available[j];
|
||||
}
|
||||
if (currentSum + futureSum < target) return null;
|
||||
|
||||
subset.push(i);
|
||||
const result = dfs(i + 1, currentSum + available[i]);
|
||||
if (result) return result;
|
||||
subset.pop();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return dfs(0, 0);
|
||||
}
|
||||
|
||||
// Sort ships descending for better pruning (hardest to fill first)
|
||||
const sortedShips = [...ships].sort((a, b) => b - a);
|
||||
|
||||
console.log(`\nSolving for ships (sorted desc): [${sortedShips.join(', ')}]`);
|
||||
|
||||
const solution = findPartition(asteroids, sortedShips);
|
||||
|
||||
if (solution) {
|
||||
console.log('\n✓ SOLVABLE! Solution found:');
|
||||
// Map back to original ship order
|
||||
const shipOrder = ships.map((cap, idx) => ({ cap, idx }))
|
||||
.sort((a, b) => b.cap - a.cap);
|
||||
|
||||
for (let i = 0; i < sortedShips.length; i++) {
|
||||
const indices = solution[i];
|
||||
const values = indices.map(j => asteroids[j]);
|
||||
const origShipIdx = shipOrder[i].idx;
|
||||
console.log(` Ship ${origShipIdx + 1} (capacity ${sortedShips[i]}): asteroids [${values.join(' + ')}] = ${values.reduce((a, b) => a + b, 0)} (indices: ${indices.join(',')})`);
|
||||
}
|
||||
|
||||
const usedIndices = new Set(solution.flat());
|
||||
const unused = asteroids.map((v, i) => ({ v, i })).filter(x => !usedIndices.has(x.i));
|
||||
if (unused.length > 0) {
|
||||
console.log(` Unused asteroids: [${unused.map(x => `${x.v}(idx ${x.i})`).join(', ')}]`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n✗ NO SOLUTION found.');
|
||||
console.log(` Ship sum: ${ships.reduce((a, b) => a + b, 0)}, Asteroid sum: ${asteroids.reduce((a, b) => a + b, 0)}`);
|
||||
if (asteroids.reduce((a, b) => a + b, 0) < ships.reduce((a, b) => a + b, 0)) {
|
||||
console.log(' Reason: asteroid sum < ship sum (mathematically impossible)');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user