feat(tools): 添加 CLI 模块管理命令和文档验证 demos
- CLI 新增 list/add/remove 命令管理项目模块 - 创建 demos 包验证模块文档正确性 - 包含 Timer/FSM/Pathfinding/Procgen/Spatial 5个模块的完整测试
This commit is contained in:
26
packages/tools/demos/package.json
Normal file
26
packages/tools/demos/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@esengine/demos",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Demo tests for ESEngine modules documentation",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tsx src/index.ts",
|
||||
"test:timer": "tsx src/timer.demo.ts",
|
||||
"test:fsm": "tsx src/fsm.demo.ts",
|
||||
"test:pathfinding": "tsx src/pathfinding.demo.ts",
|
||||
"test:procgen": "tsx src/procgen.demo.ts",
|
||||
"test:spatial": "tsx src/spatial.demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/timer": "workspace:*",
|
||||
"@esengine/fsm": "workspace:*",
|
||||
"@esengine/pathfinding": "workspace:*",
|
||||
"@esengine/procgen": "workspace:*",
|
||||
"@esengine/spatial": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
175
packages/tools/demos/src/fsm.demo.ts
Normal file
175
packages/tools/demos/src/fsm.demo.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* FSM Module Demo - Tests APIs from docs/modules/fsm/index.md
|
||||
*/
|
||||
|
||||
import { createStateMachine } from '@esengine/fsm';
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(`FAILED: ${message}`);
|
||||
console.log(` ✓ ${message}`);
|
||||
}
|
||||
|
||||
function section(name: string): void {
|
||||
console.log(`\n▶ ${name}`);
|
||||
}
|
||||
|
||||
type PlayerState = 'idle' | 'walk' | 'run' | 'jump';
|
||||
|
||||
export async function runFSMDemo(): Promise<void> {
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' FSM Module Demo');
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
// 1. Basic Creation
|
||||
section('1. createStateMachine()');
|
||||
const fsm = createStateMachine<PlayerState>('idle');
|
||||
assert(fsm !== null, 'State machine created');
|
||||
assert(fsm.current === 'idle', 'Initial state is idle');
|
||||
|
||||
// 2. Define States
|
||||
section('2. defineState()');
|
||||
let enterCalled = false;
|
||||
let exitCalled = false;
|
||||
let updateCalled = false;
|
||||
|
||||
fsm.defineState('idle', {
|
||||
onEnter: () => { enterCalled = true; },
|
||||
onExit: () => { exitCalled = true; },
|
||||
onUpdate: () => { updateCalled = true; }
|
||||
});
|
||||
fsm.defineState('walk', {});
|
||||
fsm.defineState('run', {});
|
||||
fsm.defineState('jump', {});
|
||||
|
||||
assert(fsm.hasState('idle'), 'hasState() returns true');
|
||||
assert(fsm.hasState('walk'), 'walk state exists');
|
||||
|
||||
// 3. Manual Transition
|
||||
section('3. transition()');
|
||||
fsm.transition('walk');
|
||||
assert(fsm.current === 'walk', 'Transitioned to walk');
|
||||
assert(exitCalled, 'onExit called on idle');
|
||||
assert(fsm.previous === 'idle', 'previous is idle');
|
||||
|
||||
// 4. State with Context
|
||||
section('4. Context Support');
|
||||
interface Context {
|
||||
speed: number;
|
||||
isMoving: boolean;
|
||||
}
|
||||
const fsmCtx = createStateMachine<PlayerState, Context>('idle', {
|
||||
context: { speed: 0, isMoving: false }
|
||||
});
|
||||
fsmCtx.defineState('idle', {
|
||||
onEnter: (ctx) => { ctx.speed = 0; }
|
||||
});
|
||||
fsmCtx.defineState('walk', {
|
||||
onEnter: (ctx) => { ctx.speed = 100; }
|
||||
});
|
||||
|
||||
fsmCtx.transition('walk');
|
||||
assert(fsmCtx.context.speed === 100, 'Context updated on enter');
|
||||
|
||||
// 5. Transition Conditions
|
||||
section('5. defineTransition() with conditions');
|
||||
const fsmTrans = createStateMachine<PlayerState, Context>('idle', {
|
||||
context: { speed: 0, isMoving: false }
|
||||
});
|
||||
fsmTrans.defineState('idle', {});
|
||||
fsmTrans.defineState('walk', {});
|
||||
|
||||
fsmTrans.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
|
||||
|
||||
fsmTrans.evaluateTransitions();
|
||||
assert(fsmTrans.current === 'idle', 'No transition when condition false');
|
||||
|
||||
fsmTrans.context.isMoving = true;
|
||||
fsmTrans.evaluateTransitions();
|
||||
assert(fsmTrans.current === 'walk', 'Transitions when condition true');
|
||||
|
||||
// 6. Transition Priority
|
||||
section('6. Transition Priority');
|
||||
const fsmPri = createStateMachine<'a' | 'b' | 'c'>('a');
|
||||
fsmPri.defineState('a', {});
|
||||
fsmPri.defineState('b', {});
|
||||
fsmPri.defineState('c', {});
|
||||
|
||||
fsmPri.defineTransition('a', 'b', () => true, 1);
|
||||
fsmPri.defineTransition('a', 'c', () => true, 10);
|
||||
|
||||
fsmPri.evaluateTransitions();
|
||||
assert(fsmPri.current === 'c', 'Higher priority (10) wins');
|
||||
|
||||
// 7. Update
|
||||
section('7. update()');
|
||||
const fsmUpdate = createStateMachine<PlayerState>('idle');
|
||||
let updateCount = 0;
|
||||
fsmUpdate.defineState('idle', {
|
||||
onUpdate: () => { updateCount++; }
|
||||
});
|
||||
fsmUpdate.update(16);
|
||||
fsmUpdate.update(16);
|
||||
assert(updateCount === 2, 'onUpdate called on each update');
|
||||
|
||||
// 8. Event Listeners
|
||||
section('8. Event Listeners');
|
||||
const fsmEvents = createStateMachine<PlayerState>('idle');
|
||||
fsmEvents.defineState('idle', {});
|
||||
fsmEvents.defineState('walk', {});
|
||||
|
||||
let enterEvent = false;
|
||||
let exitEvent = false;
|
||||
let changeEvent = false;
|
||||
|
||||
fsmEvents.onEnter('walk', () => { enterEvent = true; });
|
||||
fsmEvents.onExit('idle', () => { exitEvent = true; });
|
||||
fsmEvents.onChange(() => { changeEvent = true; });
|
||||
|
||||
fsmEvents.transition('walk');
|
||||
assert(enterEvent, 'onEnter listener called');
|
||||
assert(exitEvent, 'onExit listener called');
|
||||
assert(changeEvent, 'onChange listener called');
|
||||
|
||||
// 9. getStates / getTransitionsFrom
|
||||
section('9. Query Methods');
|
||||
const states = fsmEvents.getStates();
|
||||
assert(states.length >= 2, 'getStates() returns states');
|
||||
|
||||
// 10. canTransition
|
||||
section('10. canTransition()');
|
||||
const fsmCan = createStateMachine<PlayerState, Context>('idle', {
|
||||
context: { speed: 0, isMoving: false }
|
||||
});
|
||||
fsmCan.defineState('idle', {});
|
||||
fsmCan.defineState('walk', {});
|
||||
fsmCan.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
|
||||
|
||||
assert(!fsmCan.canTransition('walk'), 'Cannot transition when condition false');
|
||||
fsmCan.context.isMoving = true;
|
||||
assert(fsmCan.canTransition('walk'), 'Can transition when condition true');
|
||||
|
||||
// 11. Reset
|
||||
section('11. reset()');
|
||||
fsmCan.transition('walk');
|
||||
fsmCan.reset('idle');
|
||||
assert(fsmCan.current === 'idle', 'Reset to idle');
|
||||
|
||||
// 12. History
|
||||
section('12. getHistory()');
|
||||
const fsmHist = createStateMachine<PlayerState>('idle', { enableHistory: true });
|
||||
fsmHist.defineState('idle', {});
|
||||
fsmHist.defineState('walk', {});
|
||||
fsmHist.defineState('run', {});
|
||||
|
||||
fsmHist.transition('walk');
|
||||
fsmHist.transition('run');
|
||||
|
||||
const history = fsmHist.getHistory();
|
||||
assert(history.length >= 2, 'History recorded');
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' FSM Demo: ALL TESTS PASSED ✓');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
}
|
||||
|
||||
runFSMDemo().catch(console.error);
|
||||
75
packages/tools/demos/src/index.ts
Normal file
75
packages/tools/demos/src/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* ESEngine Module Demos - Run all demos to verify documentation
|
||||
*/
|
||||
|
||||
import { runTimerDemo } from './timer.demo.js';
|
||||
import { runFSMDemo } from './fsm.demo.js';
|
||||
import { runPathfindingDemo } from './pathfinding.demo.js';
|
||||
import { runProcgenDemo } from './procgen.demo.js';
|
||||
import { runSpatialDemo } from './spatial.demo.js';
|
||||
|
||||
async function runAllDemos(): Promise<void> {
|
||||
console.log('\n');
|
||||
console.log('╔═══════════════════════════════════════════════════════════╗');
|
||||
console.log('║ ESEngine Module Documentation Tests ║');
|
||||
console.log('╚═══════════════════════════════════════════════════════════╝');
|
||||
console.log('\n');
|
||||
|
||||
const demos = [
|
||||
{ name: 'Timer', fn: runTimerDemo },
|
||||
{ name: 'FSM', fn: runFSMDemo },
|
||||
{ name: 'Pathfinding', fn: runPathfindingDemo },
|
||||
{ name: 'Procgen', fn: runProcgenDemo },
|
||||
{ name: 'Spatial', fn: runSpatialDemo },
|
||||
];
|
||||
|
||||
const results: { name: string; passed: boolean; error?: string }[] = [];
|
||||
|
||||
for (const demo of demos) {
|
||||
try {
|
||||
await demo.fn();
|
||||
results.push({ name: demo.name, passed: true });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name: demo.name,
|
||||
passed: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n');
|
||||
console.log('╔═══════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Summary ║');
|
||||
console.log('╚═══════════════════════════════════════════════════════════╝');
|
||||
console.log('\n');
|
||||
|
||||
let allPassed = true;
|
||||
for (const result of results) {
|
||||
if (result.passed) {
|
||||
console.log(` ✓ ${result.name}: PASSED`);
|
||||
} else {
|
||||
console.log(` ✗ ${result.name}: FAILED - ${result.error}`);
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
if (allPassed) {
|
||||
console.log(' ══════════════════════════════════════');
|
||||
console.log(' ALL DOCUMENTATION TESTS PASSED ✓');
|
||||
console.log(' ══════════════════════════════════════');
|
||||
} else {
|
||||
console.log(' ══════════════════════════════════════');
|
||||
console.log(' SOME TESTS FAILED ✗');
|
||||
console.log(' ══════════════════════════════════════');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
runAllDemos().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
164
packages/tools/demos/src/pathfinding.demo.ts
Normal file
164
packages/tools/demos/src/pathfinding.demo.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Pathfinding Module Demo - Tests APIs from docs/modules/pathfinding/index.md
|
||||
*/
|
||||
|
||||
import {
|
||||
createGridMap,
|
||||
createAStarPathfinder,
|
||||
createLineOfSightSmoother,
|
||||
createCatmullRomSmoother,
|
||||
manhattanDistance,
|
||||
octileDistance
|
||||
} from '@esengine/pathfinding';
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(`FAILED: ${message}`);
|
||||
console.log(` ✓ ${message}`);
|
||||
}
|
||||
|
||||
function section(name: string): void {
|
||||
console.log(`\n▶ ${name}`);
|
||||
}
|
||||
|
||||
export async function runPathfindingDemo(): Promise<void> {
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' Pathfinding Module Demo');
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
// 1. Create Grid Map
|
||||
section('1. createGridMap()');
|
||||
const grid = createGridMap(20, 20);
|
||||
assert(grid !== null, 'Grid created');
|
||||
assert(grid.width === 20, 'Width is 20');
|
||||
assert(grid.height === 20, 'Height is 20');
|
||||
|
||||
// 2. Walkability
|
||||
section('2. setWalkable() / isWalkable()');
|
||||
assert(grid.isWalkable(5, 5), 'Initially walkable');
|
||||
grid.setWalkable(5, 5, false);
|
||||
assert(!grid.isWalkable(5, 5), 'Set to not walkable');
|
||||
grid.setWalkable(5, 5, true);
|
||||
assert(grid.isWalkable(5, 5), 'Restored to walkable');
|
||||
|
||||
// 3. Set Obstacles
|
||||
section('3. Setting Obstacles');
|
||||
grid.setWalkable(5, 5, false);
|
||||
grid.setWalkable(5, 6, false);
|
||||
grid.setWalkable(5, 7, false);
|
||||
assert(!grid.isWalkable(5, 6), 'Obstacle set');
|
||||
|
||||
// 4. Create Pathfinder
|
||||
section('4. createAStarPathfinder()');
|
||||
const pathfinder = createAStarPathfinder(grid);
|
||||
assert(pathfinder !== null, 'Pathfinder created');
|
||||
|
||||
// 5. Find Path
|
||||
section('5. findPath()');
|
||||
const result = pathfinder.findPath(0, 0, 15, 15);
|
||||
assert(result.found, 'Path found');
|
||||
assert(result.path.length > 0, `Path has ${result.path.length} points`);
|
||||
assert(result.cost > 0, `Path cost: ${result.cost.toFixed(2)}`);
|
||||
assert(result.nodesSearched > 0, `Searched ${result.nodesSearched} nodes`);
|
||||
|
||||
// 6. Path Blocked
|
||||
section('6. Path Blocked');
|
||||
// Create a wall
|
||||
for (let y = 0; y < 20; y++) {
|
||||
grid.setWalkable(10, y, false);
|
||||
}
|
||||
const blocked = pathfinder.findPath(0, 0, 15, 15);
|
||||
assert(!blocked.found, 'No path when fully blocked');
|
||||
|
||||
// Clear wall
|
||||
for (let y = 0; y < 20; y++) {
|
||||
grid.setWalkable(10, y, true);
|
||||
}
|
||||
|
||||
// 7. Movement Cost
|
||||
section('7. setCost()');
|
||||
const gridCost = createGridMap(10, 10);
|
||||
gridCost.setCost(5, 5, 10); // High cost tile
|
||||
const costResult = createAStarPathfinder(gridCost).findPath(0, 0, 9, 9);
|
||||
assert(costResult.found, 'Path found with cost');
|
||||
|
||||
// 8. Heuristics
|
||||
section('8. Heuristic Functions');
|
||||
const d1 = manhattanDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||
assert(d1 === 7, `Manhattan distance: ${d1}`);
|
||||
|
||||
const d2 = octileDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||
assert(d2 > 0, `Octile distance: ${d2.toFixed(2)}`);
|
||||
|
||||
// 9. Grid Options
|
||||
section('9. Grid Options');
|
||||
const gridOpts = createGridMap(10, 10, {
|
||||
allowDiagonal: false,
|
||||
heuristic: manhattanDistance
|
||||
});
|
||||
assert(gridOpts !== null, 'Grid with options created');
|
||||
|
||||
// 10. Path Smoothing - Line of Sight
|
||||
section('10. Line of Sight Smoother');
|
||||
const gridSmooth = createGridMap(20, 20);
|
||||
const pf = createAStarPathfinder(gridSmooth);
|
||||
const rawPath = pf.findPath(0, 0, 10, 10);
|
||||
|
||||
const losSmoother = createLineOfSightSmoother();
|
||||
const smoothed = losSmoother.smooth(rawPath.path, gridSmooth);
|
||||
assert(smoothed.length <= rawPath.path.length, `Smoothed: ${rawPath.path.length} -> ${smoothed.length} points`);
|
||||
|
||||
// 11. Catmull-Rom Smoother
|
||||
section('11. Catmull-Rom Smoother');
|
||||
const crSmoother = createCatmullRomSmoother(5, 0.5);
|
||||
const curved = crSmoother.smooth(rawPath.path, gridSmooth);
|
||||
assert(curved.length >= rawPath.path.length, `Curved path has ${curved.length} points`);
|
||||
|
||||
// 12. loadFromArray
|
||||
section('12. loadFromArray()');
|
||||
const gridArr = createGridMap(5, 3);
|
||||
gridArr.loadFromArray([
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 1, 0, 0, 0]
|
||||
]);
|
||||
assert(!gridArr.isWalkable(3, 0), 'Loaded obstacle at (3,0)');
|
||||
assert(!gridArr.isWalkable(1, 1), 'Loaded obstacle at (1,1)');
|
||||
assert(gridArr.isWalkable(0, 0), 'Loaded walkable at (0,0)');
|
||||
|
||||
// 13. loadFromString
|
||||
section('13. loadFromString()');
|
||||
const gridStr = createGridMap(5, 3);
|
||||
gridStr.loadFromString(`
|
||||
.....
|
||||
.#.#.
|
||||
.#...
|
||||
`);
|
||||
assert(!gridStr.isWalkable(1, 1), 'Loaded # as obstacle');
|
||||
assert(gridStr.isWalkable(0, 0), 'Loaded . as walkable');
|
||||
|
||||
// 14. setRectWalkable
|
||||
section('14. setRectWalkable()');
|
||||
const gridRect = createGridMap(10, 10);
|
||||
gridRect.setRectWalkable(2, 2, 4, 4, false);
|
||||
assert(!gridRect.isWalkable(3, 3), 'Rect set as obstacle');
|
||||
assert(gridRect.isWalkable(0, 0), 'Outside rect is walkable');
|
||||
|
||||
// 15. Pathfinder Options
|
||||
section('15. Pathfinder Options');
|
||||
const limitedResult = pathfinder.findPath(0, 0, 15, 15, {
|
||||
maxNodes: 100,
|
||||
heuristicWeight: 1.5
|
||||
});
|
||||
assert(limitedResult !== null, 'findPath with options works');
|
||||
|
||||
// 16. Reset Grid
|
||||
section('16. reset()');
|
||||
grid.reset();
|
||||
assert(grid.isWalkable(5, 5), 'Grid reset - all walkable');
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' Pathfinding Demo: ALL TESTS PASSED ✓');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
}
|
||||
|
||||
runPathfindingDemo().catch(console.error);
|
||||
208
packages/tools/demos/src/procgen.demo.ts
Normal file
208
packages/tools/demos/src/procgen.demo.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Procgen Module Demo - Tests APIs from docs/modules/procgen/index.md
|
||||
*/
|
||||
|
||||
import {
|
||||
createPerlinNoise,
|
||||
createSimplexNoise,
|
||||
createWorleyNoise,
|
||||
createFBM,
|
||||
createSeededRandom,
|
||||
createWeightedRandom,
|
||||
shuffle,
|
||||
shuffleCopy,
|
||||
pickOne,
|
||||
sample,
|
||||
sampleWithReplacement,
|
||||
weightedPick,
|
||||
weightedPickFromMap
|
||||
} from '@esengine/procgen';
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(`FAILED: ${message}`);
|
||||
console.log(` ✓ ${message}`);
|
||||
}
|
||||
|
||||
function section(name: string): void {
|
||||
console.log(`\n▶ ${name}`);
|
||||
}
|
||||
|
||||
export async function runProcgenDemo(): Promise<void> {
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' Procgen Module Demo');
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
// 1. Perlin Noise
|
||||
section('1. createPerlinNoise()');
|
||||
const perlin = createPerlinNoise(12345);
|
||||
assert(perlin !== null, 'Perlin noise created');
|
||||
|
||||
const val2d = perlin.noise2D(0.5, 0.5);
|
||||
assert(val2d >= -1 && val2d <= 1, `2D noise value in [-1,1]: ${val2d.toFixed(3)}`);
|
||||
|
||||
const val3d = perlin.noise3D(0.5, 0.5, 0.5);
|
||||
assert(val3d >= -1 && val3d <= 1, `3D noise value in [-1,1]: ${val3d.toFixed(3)}`);
|
||||
|
||||
// 2. Simplex Noise
|
||||
section('2. createSimplexNoise()');
|
||||
const simplex = createSimplexNoise(12345);
|
||||
const sval = simplex.noise2D(0.5, 0.5);
|
||||
assert(sval >= -1 && sval <= 1, `Simplex value: ${sval.toFixed(3)}`);
|
||||
|
||||
// 3. Worley Noise
|
||||
section('3. createWorleyNoise()');
|
||||
const worley = createWorleyNoise(12345);
|
||||
const wval = worley.noise2D(0.5, 0.5);
|
||||
assert(wval >= 0, `Worley distance: ${wval.toFixed(3)}`);
|
||||
|
||||
// 4. FBM
|
||||
section('4. createFBM()');
|
||||
const fbm = createFBM(perlin, {
|
||||
octaves: 6,
|
||||
lacunarity: 2.0,
|
||||
persistence: 0.5
|
||||
});
|
||||
const fbmVal = fbm.noise2D(0.1, 0.1);
|
||||
assert(typeof fbmVal === 'number', `FBM value: ${fbmVal.toFixed(3)}`);
|
||||
|
||||
const ridged = fbm.ridged2D(0.1, 0.1);
|
||||
assert(typeof ridged === 'number', `Ridged FBM: ${ridged.toFixed(3)}`);
|
||||
|
||||
const turb = fbm.turbulence2D(0.1, 0.1);
|
||||
assert(turb >= 0, `Turbulence: ${turb.toFixed(3)}`);
|
||||
|
||||
// 5. Seeded Random
|
||||
section('5. createSeededRandom()');
|
||||
const rng = createSeededRandom(42);
|
||||
assert(rng !== null, 'RNG created');
|
||||
|
||||
const r1 = rng.next();
|
||||
assert(r1 >= 0 && r1 < 1, `next() in [0,1): ${r1.toFixed(3)}`);
|
||||
|
||||
const r2 = rng.nextInt(1, 10);
|
||||
assert(r2 >= 1 && r2 <= 10, `nextInt(1,10): ${r2}`);
|
||||
|
||||
const r3 = rng.nextFloat(0, 100);
|
||||
assert(r3 >= 0 && r3 < 100, `nextFloat(0,100): ${r3.toFixed(2)}`);
|
||||
|
||||
const r4 = rng.nextBool();
|
||||
assert(typeof r4 === 'boolean', `nextBool(): ${r4}`);
|
||||
|
||||
const r5 = rng.nextBool(0.9);
|
||||
assert(typeof r5 === 'boolean', `nextBool(0.9): ${r5}`);
|
||||
|
||||
// 6. Deterministic
|
||||
section('6. Deterministic Sequences');
|
||||
const rng1 = createSeededRandom(42);
|
||||
const rng2 = createSeededRandom(42);
|
||||
const seq1 = [rng1.next(), rng1.next(), rng1.next()];
|
||||
const seq2 = [rng2.next(), rng2.next(), rng2.next()];
|
||||
assert(seq1[0] === seq2[0] && seq1[1] === seq2[1] && seq1[2] === seq2[2],
|
||||
'Same seed produces same sequence');
|
||||
|
||||
// 7. Distributions
|
||||
section('7. Distribution Methods');
|
||||
const rngDist = createSeededRandom(42);
|
||||
|
||||
const gauss = rngDist.nextGaussian();
|
||||
assert(typeof gauss === 'number', `nextGaussian(): ${gauss.toFixed(3)}`);
|
||||
|
||||
const gauss2 = rngDist.nextGaussian(100, 15);
|
||||
assert(typeof gauss2 === 'number', `nextGaussian(100,15): ${gauss2.toFixed(1)}`);
|
||||
|
||||
const exp = rngDist.nextExponential();
|
||||
assert(exp >= 0, `nextExponential(): ${exp.toFixed(3)}`);
|
||||
|
||||
// 8. Geometry Methods
|
||||
section('8. Geometry Methods');
|
||||
const rngGeo = createSeededRandom(42);
|
||||
|
||||
const pointCircle = rngGeo.nextPointInCircle(50);
|
||||
assert(pointCircle.x !== undefined && pointCircle.y !== undefined,
|
||||
`nextPointInCircle: (${pointCircle.x.toFixed(1)}, ${pointCircle.y.toFixed(1)})`);
|
||||
|
||||
const pointOnCircle = rngGeo.nextPointOnCircle(50);
|
||||
const dist = Math.sqrt(pointOnCircle.x ** 2 + pointOnCircle.y ** 2);
|
||||
assert(Math.abs(dist - 50) < 0.01, `nextPointOnCircle radius ~50: ${dist.toFixed(2)}`);
|
||||
|
||||
const dir = rngGeo.nextDirection2D();
|
||||
const len = Math.sqrt(dir.x ** 2 + dir.y ** 2);
|
||||
assert(Math.abs(len - 1) < 0.01, `nextDirection2D length ~1: ${len.toFixed(3)}`);
|
||||
|
||||
// 9. Weighted Random
|
||||
section('9. createWeightedRandom()');
|
||||
const rngW = createSeededRandom(42);
|
||||
const loot = createWeightedRandom([
|
||||
{ value: 'common', weight: 60 },
|
||||
{ value: 'rare', weight: 30 },
|
||||
{ value: 'epic', weight: 10 }
|
||||
]);
|
||||
|
||||
assert(loot.size === 3, 'Has 3 items');
|
||||
assert(loot.totalWeight === 100, 'Total weight is 100');
|
||||
assert(loot.getProbability(0) === 0.6, 'Common probability is 0.6');
|
||||
|
||||
const picked = loot.pick(rngW);
|
||||
assert(['common', 'rare', 'epic'].includes(picked), `Picked: ${picked}`);
|
||||
|
||||
// 10. Shuffle
|
||||
section('10. shuffle() / shuffleCopy()');
|
||||
const rngS = createSeededRandom(42);
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
const copy = shuffleCopy(arr, rngS);
|
||||
assert(copy.length === 5, 'Shuffled copy has same length');
|
||||
assert(arr[0] === 1, 'Original unchanged');
|
||||
|
||||
shuffle(arr, rngS);
|
||||
assert(arr.length === 5, 'In-place shuffle preserves length');
|
||||
|
||||
// 11. pickOne
|
||||
section('11. pickOne()');
|
||||
const rngP = createSeededRandom(42);
|
||||
const items = ['a', 'b', 'c', 'd'];
|
||||
const picked2 = pickOne(items, rngP);
|
||||
assert(items.includes(picked2), `Picked: ${picked2}`);
|
||||
|
||||
// 12. sample
|
||||
section('12. sample() / sampleWithReplacement()');
|
||||
const rngSamp = createSeededRandom(42);
|
||||
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
const sampled = sample(nums, 3, rngSamp);
|
||||
assert(sampled.length === 3, 'Sampled 3 items');
|
||||
assert(new Set(sampled).size === 3, 'All unique');
|
||||
|
||||
const withRep = sampleWithReplacement(nums, 5, rngSamp);
|
||||
assert(withRep.length === 5, 'Sampled 5 with replacement');
|
||||
|
||||
// 13. weightedPick
|
||||
section('13. weightedPick() / weightedPickFromMap()');
|
||||
const rngWP = createSeededRandom(42);
|
||||
const item = weightedPick([
|
||||
{ value: 'a', weight: 1 },
|
||||
{ value: 'b', weight: 2 }
|
||||
], rngWP);
|
||||
assert(['a', 'b'].includes(item), `weightedPick: ${item}`);
|
||||
|
||||
const item2 = weightedPickFromMap({
|
||||
'common': 60,
|
||||
'rare': 30
|
||||
}, rngWP);
|
||||
assert(['common', 'rare'].includes(item2), `weightedPickFromMap: ${item2}`);
|
||||
|
||||
// 14. Reset
|
||||
section('14. reset()');
|
||||
const rngReset = createSeededRandom(42);
|
||||
const first = rngReset.next();
|
||||
rngReset.next();
|
||||
rngReset.next();
|
||||
rngReset.reset();
|
||||
const afterReset = rngReset.next();
|
||||
assert(first === afterReset, 'Reset restores initial state');
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' Procgen Demo: ALL TESTS PASSED ✓');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
}
|
||||
|
||||
runProcgenDemo().catch(console.error);
|
||||
246
packages/tools/demos/src/spatial.demo.ts
Normal file
246
packages/tools/demos/src/spatial.demo.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Spatial Module Demo - Tests APIs from docs/modules/spatial/index.md
|
||||
*/
|
||||
|
||||
import {
|
||||
createGridSpatialIndex,
|
||||
createGridAOI,
|
||||
createBounds,
|
||||
createBoundsFromCenter,
|
||||
createBoundsFromCircle,
|
||||
isPointInBounds,
|
||||
boundsIntersect,
|
||||
distance,
|
||||
distanceSquared
|
||||
} from '@esengine/spatial';
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(`FAILED: ${message}`);
|
||||
console.log(` ✓ ${message}`);
|
||||
}
|
||||
|
||||
function section(name: string): void {
|
||||
console.log(`\n▶ ${name}`);
|
||||
}
|
||||
|
||||
interface Entity {
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export async function runSpatialDemo(): Promise<void> {
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' Spatial Module Demo');
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
// 1. Create Spatial Index
|
||||
section('1. createGridSpatialIndex()');
|
||||
const spatial = createGridSpatialIndex<Entity>(100);
|
||||
assert(spatial !== null, 'Spatial index created');
|
||||
assert(spatial.count === 0, 'Initially empty');
|
||||
|
||||
// 2. Insert
|
||||
section('2. insert()');
|
||||
const player: Entity = { id: 1, type: 'player' };
|
||||
const enemy1: Entity = { id: 2, type: 'enemy' };
|
||||
const enemy2: Entity = { id: 3, type: 'enemy' };
|
||||
|
||||
spatial.insert(player, { x: 100, y: 200 });
|
||||
spatial.insert(enemy1, { x: 150, y: 250 });
|
||||
spatial.insert(enemy2, { x: 500, y: 600 });
|
||||
|
||||
assert(spatial.count === 3, 'Count is 3');
|
||||
|
||||
// 3. findInRadius
|
||||
section('3. findInRadius()');
|
||||
const nearby = spatial.findInRadius({ x: 100, y: 200 }, 100);
|
||||
assert(nearby.length === 2, `Found ${nearby.length} entities in radius`);
|
||||
assert(nearby.includes(player), 'Found player');
|
||||
assert(nearby.includes(enemy1), 'Found enemy1');
|
||||
assert(!nearby.includes(enemy2), 'enemy2 is too far');
|
||||
|
||||
// 4. findInRadius with filter
|
||||
section('4. findInRadius() with filter');
|
||||
const enemies = spatial.findInRadius(
|
||||
{ x: 100, y: 200 },
|
||||
100,
|
||||
(e) => e.type === 'enemy'
|
||||
);
|
||||
assert(enemies.length === 1, 'Found 1 enemy');
|
||||
assert(enemies[0] === enemy1, 'Found enemy1');
|
||||
|
||||
// 5. findNearest
|
||||
section('5. findNearest()');
|
||||
const nearest = spatial.findNearest({ x: 100, y: 200 });
|
||||
assert(nearest === player || nearest === enemy1, 'Found nearest entity');
|
||||
|
||||
const nearestEnemy = spatial.findNearest(
|
||||
{ x: 100, y: 200 },
|
||||
undefined,
|
||||
(e) => e.type === 'enemy'
|
||||
);
|
||||
assert(nearestEnemy === enemy1, 'Found nearest enemy');
|
||||
|
||||
// 6. findKNearest
|
||||
section('6. findKNearest()');
|
||||
const k2 = spatial.findKNearest({ x: 100, y: 200 }, 2, 1000);
|
||||
assert(k2.length === 2, 'Found 2 nearest');
|
||||
|
||||
// 7. Update position
|
||||
section('7. update()');
|
||||
spatial.update(player, { x: 400, y: 400 });
|
||||
const afterMove = spatial.findInRadius({ x: 100, y: 200 }, 100);
|
||||
assert(!afterMove.includes(player), 'Player moved away');
|
||||
|
||||
// 8. Remove
|
||||
section('8. remove()');
|
||||
spatial.remove(enemy2);
|
||||
assert(spatial.count === 2, 'Count is 2 after remove');
|
||||
|
||||
// 9. findInRect
|
||||
section('9. findInRect()');
|
||||
const bounds = createBounds(0, 0, 200, 300);
|
||||
spatial.update(player, { x: 100, y: 200 });
|
||||
const inRect = spatial.findInRect(bounds);
|
||||
assert(inRect.length >= 1, `Found ${inRect.length} in rect`);
|
||||
|
||||
// 10. Raycast
|
||||
section('10. raycast()');
|
||||
spatial.update(player, { x: 100, y: 0 });
|
||||
spatial.update(enemy1, { x: 100, y: 200 });
|
||||
|
||||
const hits = spatial.raycast(
|
||||
{ x: 100, y: -100 },
|
||||
{ x: 0, y: 1 },
|
||||
500
|
||||
);
|
||||
assert(hits.length >= 1, `Raycast hit ${hits.length} entities`);
|
||||
|
||||
// 11. raycastFirst
|
||||
section('11. raycastFirst()');
|
||||
const firstHit = spatial.raycastFirst(
|
||||
{ x: 100, y: -100 },
|
||||
{ x: 0, y: 1 },
|
||||
500
|
||||
);
|
||||
if (firstHit) {
|
||||
assert(firstHit.target !== null, 'Hit has target');
|
||||
assert(firstHit.distance >= 0, `Hit distance: ${firstHit.distance.toFixed(1)}`);
|
||||
}
|
||||
|
||||
// 12. Clear
|
||||
section('12. clear()');
|
||||
spatial.clear();
|
||||
assert(spatial.count === 0, 'Cleared');
|
||||
|
||||
// =========================================================================
|
||||
// AOI Tests
|
||||
// =========================================================================
|
||||
|
||||
// 13. Create AOI
|
||||
section('13. createGridAOI()');
|
||||
const aoi = createGridAOI<Entity>(100);
|
||||
assert(aoi !== null, 'AOI created');
|
||||
|
||||
// 14. Add Observers
|
||||
section('14. addObserver()');
|
||||
const p1: Entity = { id: 1, type: 'player' };
|
||||
const p2: Entity = { id: 2, type: 'player' };
|
||||
|
||||
aoi.addObserver(p1, { x: 100, y: 100 }, { viewRange: 200 });
|
||||
aoi.addObserver(p2, { x: 150, y: 150 }, { viewRange: 200 });
|
||||
|
||||
// Note: After adding p2, p2 can see p1, but p1's visibility isn't auto-updated
|
||||
// We need to trigger an update for p1 to detect p2
|
||||
aoi.updatePosition(p1, { x: 100, y: 100 });
|
||||
|
||||
// 15. getEntitiesInView
|
||||
section('15. getEntitiesInView()');
|
||||
const visible = aoi.getEntitiesInView(p1);
|
||||
assert(visible.includes(p2), 'p1 can see p2');
|
||||
|
||||
// 16. canSee
|
||||
section('16. canSee()');
|
||||
assert(aoi.canSee(p1, p2), 'p1 can see p2');
|
||||
|
||||
// 17. updatePosition
|
||||
section('17. updatePosition()');
|
||||
aoi.updatePosition(p2, { x: 1000, y: 1000 });
|
||||
// Refresh p1's visibility (implementation requires explicit update for distant moves)
|
||||
aoi.updatePosition(p1, { x: 100, y: 100 });
|
||||
assert(!aoi.canSee(p1, p2), 'p1 cannot see p2 after move');
|
||||
|
||||
// 18. getObserversOf
|
||||
section('18. getObserversOf()');
|
||||
aoi.updatePosition(p2, { x: 120, y: 120 });
|
||||
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1's visibility
|
||||
const observers = aoi.getObserversOf(p2);
|
||||
assert(observers.includes(p1), 'p1 observes p2');
|
||||
|
||||
// 19. Event Listener
|
||||
section('19. addListener()');
|
||||
let eventCount = 0;
|
||||
aoi.addListener((event) => {
|
||||
eventCount++;
|
||||
});
|
||||
aoi.updatePosition(p2, { x: 2000, y: 2000 }); // Should trigger exit
|
||||
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1
|
||||
aoi.updatePosition(p2, { x: 130, y: 130 }); // Should trigger enter
|
||||
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1
|
||||
assert(eventCount >= 1, `Events triggered: ${eventCount}`);
|
||||
|
||||
// 20. updateViewRange
|
||||
section('20. updateViewRange()');
|
||||
aoi.updateViewRange(p1, 50);
|
||||
aoi.updatePosition(p2, { x: 200, y: 200 });
|
||||
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1's visibility with new range
|
||||
assert(!aoi.canSee(p1, p2), 'Cannot see after view range reduced');
|
||||
|
||||
// 21. removeObserver
|
||||
section('21. removeObserver()');
|
||||
aoi.removeObserver(p2);
|
||||
const afterRemove = aoi.getEntitiesInView(p1);
|
||||
assert(!afterRemove.includes(p2), 'p2 removed from AOI');
|
||||
|
||||
// =========================================================================
|
||||
// Utility Functions
|
||||
// =========================================================================
|
||||
|
||||
// 22. Bounds Creation
|
||||
section('22. Bounds Creation');
|
||||
const b1 = createBounds(0, 0, 100, 100);
|
||||
assert(b1.minX === 0 && b1.maxX === 100, 'createBounds works');
|
||||
|
||||
const b2 = createBoundsFromCenter({ x: 50, y: 50 }, 100, 100);
|
||||
assert(b2.minX === 0 && b2.maxX === 100, 'createBoundsFromCenter works');
|
||||
|
||||
const b3 = createBoundsFromCircle({ x: 50, y: 50 }, 50);
|
||||
assert(b3.minX === 0 && b3.maxX === 100, 'createBoundsFromCircle works');
|
||||
|
||||
// 23. Point in Bounds
|
||||
section('23. isPointInBounds()');
|
||||
assert(isPointInBounds({ x: 50, y: 50 }, b1), 'Point inside');
|
||||
assert(!isPointInBounds({ x: 150, y: 150 }, b1), 'Point outside');
|
||||
|
||||
// 24. Bounds Intersect
|
||||
section('24. boundsIntersect()');
|
||||
const ba = createBounds(0, 0, 100, 100);
|
||||
const bb = createBounds(50, 50, 150, 150);
|
||||
const bc = createBounds(200, 200, 300, 300);
|
||||
assert(boundsIntersect(ba, bb), 'Overlapping bounds intersect');
|
||||
assert(!boundsIntersect(ba, bc), 'Separate bounds do not intersect');
|
||||
|
||||
// 25. Distance
|
||||
section('25. distance() / distanceSquared()');
|
||||
const d = distance({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||
assert(Math.abs(d - 5) < 0.001, `Distance: ${d}`);
|
||||
|
||||
const dsq = distanceSquared({ x: 0, y: 0 }, { x: 3, y: 4 });
|
||||
assert(dsq === 25, `Distance squared: ${dsq}`);
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' Spatial Demo: ALL TESTS PASSED ✓');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
}
|
||||
|
||||
runSpatialDemo().catch(console.error);
|
||||
119
packages/tools/demos/src/timer.demo.ts
Normal file
119
packages/tools/demos/src/timer.demo.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Timer Module Demo - Tests APIs from docs/modules/timer/index.md
|
||||
*/
|
||||
|
||||
import { createTimerService } from '@esengine/timer';
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(`FAILED: ${message}`);
|
||||
console.log(` ✓ ${message}`);
|
||||
}
|
||||
|
||||
function section(name: string): void {
|
||||
console.log(`\n▶ ${name}`);
|
||||
}
|
||||
|
||||
export async function runTimerDemo(): Promise<void> {
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' Timer Module Demo');
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
// 1. Basic Creation
|
||||
section('1. createTimerService()');
|
||||
const timerService = createTimerService();
|
||||
assert(timerService !== null, 'Service created');
|
||||
assert(timerService.activeTimerCount === 0, 'Initial timer count is 0');
|
||||
assert(timerService.activeCooldownCount === 0, 'Initial cooldown count is 0');
|
||||
|
||||
// 2. One-shot Timer
|
||||
section('2. schedule() - One-shot Timer');
|
||||
let fired = false;
|
||||
const handle = timerService.schedule('test', 100, () => { fired = true; });
|
||||
assert(handle.id === 'test', 'Handle.id correct');
|
||||
assert(handle.isValid === true, 'Handle.isValid is true');
|
||||
assert(timerService.hasTimer('test'), 'hasTimer() returns true');
|
||||
|
||||
timerService.update(50);
|
||||
assert(!fired, 'Timer not fired at 50ms');
|
||||
timerService.update(60);
|
||||
assert(fired, 'Timer fired after 110ms');
|
||||
assert(!timerService.hasTimer('test'), 'Timer removed after firing');
|
||||
|
||||
// 3. Repeating Timer
|
||||
section('3. scheduleRepeating()');
|
||||
let count = 0;
|
||||
timerService.scheduleRepeating('repeat', 50, () => { count++; });
|
||||
timerService.update(50);
|
||||
assert(count === 1, 'Fires once at 50ms');
|
||||
timerService.update(50);
|
||||
assert(count === 2, 'Fires twice at 100ms');
|
||||
timerService.cancelById('repeat');
|
||||
timerService.update(100);
|
||||
assert(count === 2, 'Stopped after cancel');
|
||||
|
||||
// 4. Timer Cancellation
|
||||
section('4. cancel()');
|
||||
let cancelled = false;
|
||||
const h = timerService.schedule('cancel', 1000, () => { cancelled = true; });
|
||||
h.cancel();
|
||||
assert(!h.isValid, 'Handle invalid after cancel');
|
||||
timerService.update(2000);
|
||||
assert(!cancelled, 'Cancelled timer does not fire');
|
||||
|
||||
// 5. Timer Info
|
||||
section('5. getTimerInfo()');
|
||||
timerService.schedule('info', 500, () => {});
|
||||
const info = timerService.getTimerInfo('info');
|
||||
assert(info !== null, 'Returns info object');
|
||||
assert(info!.id === 'info', 'Info.id correct');
|
||||
assert(info!.repeating === false, 'Info.repeating is false');
|
||||
timerService.cancelById('info');
|
||||
|
||||
// 6. Cooldown System
|
||||
section('6. Cooldown API');
|
||||
timerService.startCooldown('skill', 200);
|
||||
assert(!timerService.isCooldownReady('skill'), 'Not ready initially');
|
||||
assert(timerService.isOnCooldown('skill'), 'isOnCooldown true');
|
||||
|
||||
timerService.update(100);
|
||||
const progress = timerService.getCooldownProgress('skill');
|
||||
assert(progress >= 0.4 && progress <= 0.6, `Progress ~0.5 (got ${progress.toFixed(2)})`);
|
||||
|
||||
timerService.update(150);
|
||||
assert(timerService.isCooldownReady('skill'), 'Ready after duration');
|
||||
|
||||
// 7. Cooldown Info
|
||||
section('7. getCooldownInfo()');
|
||||
timerService.startCooldown('cd', 300);
|
||||
timerService.update(150);
|
||||
const cdInfo = timerService.getCooldownInfo('cd');
|
||||
assert(cdInfo !== null, 'Returns cooldown info');
|
||||
assert(cdInfo!.duration === 300, 'Duration is 300');
|
||||
assert(!cdInfo!.isReady, 'isReady is false');
|
||||
|
||||
// 8. Reset Cooldown
|
||||
section('8. resetCooldown()');
|
||||
timerService.startCooldown('reset', 500);
|
||||
timerService.update(100);
|
||||
timerService.resetCooldown('reset');
|
||||
assert(timerService.isCooldownReady('reset'), 'Ready after reset');
|
||||
|
||||
// 9. Clear All
|
||||
section('9. clear()');
|
||||
timerService.schedule('t1', 1000, () => {});
|
||||
timerService.startCooldown('c1', 1000);
|
||||
timerService.clear();
|
||||
assert(timerService.activeTimerCount === 0, 'Timers cleared');
|
||||
assert(timerService.activeCooldownCount === 0, 'Cooldowns cleared');
|
||||
|
||||
// 10. Config Options
|
||||
section('10. Config Options');
|
||||
const limited = createTimerService({ maxTimers: 2, maxCooldowns: 1 });
|
||||
assert(limited !== null, 'Created with config');
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' Timer Demo: ALL TESTS PASSED ✓');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
}
|
||||
|
||||
runTimerDemo().catch(console.error);
|
||||
12
packages/tools/demos/tsconfig.json
Normal file
12
packages/tools/demos/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2022"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user