diff --git a/.windsurf/rules/code-style-guide.md b/.windsurf/rules/code-style-guide.md new file mode 100644 index 0000000..a532d4d --- /dev/null +++ b/.windsurf/rules/code-style-guide.md @@ -0,0 +1,26 @@ +--- +trigger: model_decision +--- + +Indentation: Use 4 spaces per indentation level. Avoid tabs. + +Line Length: Limit lines to a maximum of 79 characters. For docstrings and comments, limit to 72 characters. + +Blank Lines: Separate top-level function and class definitions with two blank lines. Use a single blank line between method definitions inside a class and to separate logical sections within functions. + +Naming Conventions: +Use lowercase_with_underscores for functions and variables. +Use CamelCase for classes. +Use ALL_CAPS for constants. +Prefix internal attributes with a single underscore (_). + +Comments: Write clear, concise comments to explain code functionality. Update comments when code changes. + +Spaces: Use spaces around operators and after commas. +Trailing Commas: Use trailing commas in lists, tuples, and dictionaries. + +Avoid Extraneous Whitespace: Do not add unnecessary spaces at the beginning or end of lines. + +Imports: Group imports in the following order: standard library imports, related third-party imports, local application/library specific imports. Put a blank line between each group of imports. + +Avoid shadowing names: Do not use names of built-in functions, constants, types, and exceptions. \ No newline at end of file diff --git a/.windsurf/workflows/debug-issues-generic.md b/.windsurf/workflows/debug-issues-generic.md new file mode 100644 index 0000000..fdc1622 --- /dev/null +++ b/.windsurf/workflows/debug-issues-generic.md @@ -0,0 +1,4 @@ +--- +description: +--- + diff --git a/.windsurf/workflows/pr-analysis-remediation.md b/.windsurf/workflows/pr-analysis-remediation.md new file mode 100644 index 0000000..e871354 --- /dev/null +++ b/.windsurf/workflows/pr-analysis-remediation.md @@ -0,0 +1,106 @@ +--- +description: Perform PR analysis, remediate errors, and push changes using GitHub CLI +--- + +# PR Analysis and Remediation Workflow + +This workflow helps you analyze a pull request, identify and fix issues, and push the remediated changes back to the repository. + +## Prerequisites +- GitHub CLI (`gh`) installed and authenticated +- Repository cloned locally +- Appropriate permissions to push to the branch + +## Steps + +1. **Fetch the latest PR information** +```bash +gh pr list --state open +``` + +2. **Checkout the PR branch** +```bash +gh pr checkout +``` + +3. **Analyze PR changes and get diff** +```bash +gh pr diff +``` + +4. **Check PR status and reviews** +```bash +gh pr view --json reviews,statusCheckRollup +``` + +5. **Run automated checks locally** +// turbo +```bash +npm test +``` + +6. **Run linting and formatting** +// turbo +```bash +npm run lint +``` + +7. **Fix any linting issues automatically** +// turbo +```bash +npm run lint:fix +``` + +8. **Run type checking (if applicable)** +// turbo +```bash +npm run type-check +``` + +9. **Stage and commit any fixes** +```bash +git add . +git commit -m "fix: remediate PR analysis findings" +``` + +10. **Push the remediated changes** +```bash +git push origin HEAD +``` + +11. **Add a comment to the PR about the remediation** +```bash +gh pr comment --body "🔧 Automated remediation applied: +- Fixed linting issues +- Resolved test failures +- Applied code formatting +Ready for re-review!" +``` + +12. **Request re-review if needed** +```bash +gh pr review --request-changes --body "Please review the automated fixes applied" +``` + +## Optional: Advanced Analysis + +13. **Check for security vulnerabilities** +```bash +npm audit +``` + +14. **Run performance tests** +```bash +npm run test:performance +``` + +15. **Generate coverage report** +```bash +npm run test:coverage +``` + +## Notes +- Replace `` with the actual PR number +- Ensure you have the necessary permissions before pushing changes +- Review automated fixes before committing to avoid introducing new issues +- Consider running the full test suite after remediation diff --git a/static/js/__tests__/ai-movement.test.js b/static/js/__tests__/ai-movement.test.js new file mode 100644 index 0000000..6c70d6e --- /dev/null +++ b/static/js/__tests__/ai-movement.test.js @@ -0,0 +1,112 @@ +import { updateAI } from '../entities.js'; +import { gameState } from '../gameState.js'; +import { WORLD_SIZE } from '../config.js'; + +// Mock gameState +jest.mock('../gameState.js', () => ({ + gameState: { + aiPlayers: [] + } +})); + +describe('updateAI', () => { + beforeEach(() => { + // Reset gameState before each test + gameState.aiPlayers = []; + + // Mock Math.random to make tests deterministic + jest.spyOn(Math, 'random').mockImplementation(() => 0.5); + }); + + afterEach(() => { + // Restore Math.random + jest.restoreAllMocks(); + }); + + test('updates AI position based on direction', () => { + const ai = { + x: 100, + y: 100, + score: 100, + direction: Math.PI / 4 // 45 degrees + }; + + gameState.aiPlayers = [ai]; + + updateAI(); + + // AI should have moved in the direction of its angle + expect(gameState.aiPlayers[0].x).toBeGreaterThan(100); + expect(gameState.aiPlayers[0].y).toBeGreaterThan(100); + }); + + test('AI changes direction randomly', () => { + const ai = { + x: 100, + y: 100, + score: 100, + direction: 0 + }; + + gameState.aiPlayers = [ai]; + + // Mock Math.random to return a value that will trigger direction change + jest.spyOn(Math, 'random') + .mockImplementationOnce(() => 0.01) // Less than 0.02 to trigger direction change + .mockImplementationOnce(() => 0.5); // For the new direction calculation + + updateAI(); + + // Direction should have changed + expect(gameState.aiPlayers[0].direction).toBeCloseTo(Math.PI); + }); + + test('AI speed is inversely proportional to size', () => { + const smallAI = { + x: 100, + y: 100, + score: 100, + direction: 0 // Moving right + }; + + const largeAI = { + x: 100, + y: 100, + score: 400, + direction: 0 // Moving right + }; + + // Test small AI first + gameState.aiPlayers = [smallAI]; + updateAI(); + const smallAISpeed = gameState.aiPlayers[0].x - 100; + + // Test large AI + gameState.aiPlayers = [largeAI]; + updateAI(); + const largeAISpeed = gameState.aiPlayers[0].x - 100; + + // Small AI should move faster than large AI + expect(smallAISpeed).toBeGreaterThan(largeAISpeed); + }); + + test('AI stays within world boundaries', () => { + // Test AI at edge of world + const edgeAI = { + x: WORLD_SIZE - 1, + y: WORLD_SIZE - 1, + score: 100, + direction: Math.PI / 4 // 45 degrees, moving toward edge + }; + + gameState.aiPlayers = [edgeAI]; + + updateAI(); + + // AI should be constrained to world boundaries + expect(gameState.aiPlayers[0].x).toBeLessThanOrEqual(WORLD_SIZE); + expect(gameState.aiPlayers[0].y).toBeLessThanOrEqual(WORLD_SIZE); + expect(gameState.aiPlayers[0].x).toBeGreaterThanOrEqual(0); + expect(gameState.aiPlayers[0].y).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/static/js/__tests__/cell-merging.test.js b/static/js/__tests__/cell-merging.test.js new file mode 100644 index 0000000..4abc638 --- /dev/null +++ b/static/js/__tests__/cell-merging.test.js @@ -0,0 +1,129 @@ +import { updateCellMerging } from '../entities.js'; +import { gameState } from '../gameState.js'; +import { MERGE_COOLDOWN } from '../config.js'; + +// Mock gameState +jest.mock('../gameState.js', () => ({ + gameState: { + playerCells: [] + } +})); + +describe('updateCellMerging', () => { + beforeEach(() => { + // Reset gameState before each test + gameState.playerCells = []; + // Mock Date.now to control time + jest.spyOn(Date, 'now').mockImplementation(() => 1000); + }); + + afterEach(() => { + // Restore Date.now + jest.restoreAllMocks(); + }); + + test('cells do not merge when cooldown has not passed', () => { + // Create two cells that are close enough to merge but with recent split time + const cell1 = { + x: 100, y: 100, score: 100, + velocityX: 0, velocityY: 0, + splitTime: Date.now() - (MERGE_COOLDOWN / 2) + }; + const cell2 = { + x: 101, y: 101, score: 100, + velocityX: 0, velocityY: 0, + splitTime: Date.now() - (MERGE_COOLDOWN / 2) + }; + + gameState.playerCells = [cell1, cell2]; + + updateCellMerging(); + + // Cells should not merge, still have two cells + expect(gameState.playerCells.length).toBe(2); + }); + + test('cells merge when cooldown has passed and they are close enough', () => { + // Mock Date.now to return a time after the cooldown + jest.spyOn(Date, 'now').mockImplementation(() => MERGE_COOLDOWN + 2000); + + // Create two cells that are close enough to merge with old split time + const cell1 = { + x: 100, y: 100, score: 100, + velocityX: 0, velocityY: 0, + splitTime: 0 // Old split time + }; + const cell2 = { + x: 101, y: 101, score: 100, + velocityX: 0, velocityY: 0, + splitTime: 0 // Old split time + }; + + gameState.playerCells = [cell1, cell2]; + + updateCellMerging(); + + // Cells should merge into one + expect(gameState.playerCells.length).toBe(1); + // The merged cell should have the combined score + expect(gameState.playerCells[0].score).toBe(200); + }); + + test('cells attract each other when close but not close enough to merge', () => { + // Mock Date.now to return a time after the cooldown + jest.spyOn(Date, 'now').mockImplementation(() => MERGE_COOLDOWN + 2000); + + // Create two cells that are not close enough to merge immediately + const cell1 = { + x: 100, y: 100, score: 100, + velocityX: 0, velocityY: 0, + splitTime: 0 // Old split time + }; + const cell2 = { + x: 200, y: 200, score: 100, // Increased distance to ensure no merging + velocityX: 0, velocityY: 0, + splitTime: 0 // Old split time + }; + + gameState.playerCells = [cell1, cell2]; + + updateCellMerging(); + + // Cells should not merge yet, still have two cells + expect(gameState.playerCells.length).toBe(2); + // But they should have velocities towards each other + expect(gameState.playerCells[0].velocityX).toBeGreaterThan(0); + expect(gameState.playerCells[0].velocityY).toBeGreaterThan(0); + expect(gameState.playerCells[1].velocityX).toBeLessThan(0); + expect(gameState.playerCells[1].velocityY).toBeLessThan(0); + }); + + test('cells repel each other when too close', () => { + // Create two cells that are too close and should repel + const cell1 = { + x: 100, y: 100, score: 400, // Larger size + velocityX: 0, velocityY: 0, + splitTime: 0 + }; + const cell2 = { + x: 100.1, y: 100.1, score: 400, // Larger size + velocityX: 0, velocityY: 0, + splitTime: 0 + }; + + gameState.playerCells = [cell1, cell2]; + + // Mock Date.now to return a time before the cooldown + jest.spyOn(Date, 'now').mockImplementation(() => 1000); + + updateCellMerging(); + + // Cells should not merge, still have two cells + expect(gameState.playerCells.length).toBe(2); + // They should have velocities pushing away from each other + expect(gameState.playerCells[0].velocityX).toBeLessThan(0); + expect(gameState.playerCells[0].velocityY).toBeLessThan(0); + expect(gameState.playerCells[1].velocityX).toBeGreaterThan(0); + expect(gameState.playerCells[1].velocityY).toBeGreaterThan(0); + }); +}); diff --git a/static/js/__tests__/decay.test.js b/static/js/__tests__/decay.test.js new file mode 100644 index 0000000..1d3ce64 --- /dev/null +++ b/static/js/__tests__/decay.test.js @@ -0,0 +1,147 @@ +import * as utils from '../utils.js'; +import * as config from '../config.js'; + +describe('decay mechanics', () => { + // Mock Date.now() to return a consistent value for tests + const NOW = 1621234567890; // Fixed timestamp for testing + let originalDateNow; + // Setup and teardown for each test + beforeEach(() => { + // Mock the config values directly + config.DECAY_ENABLED = true; + config.DECAY_RATE = 0.05; + config.DECAY_THRESHOLD = 50; + config.DECAY_MOVEMENT_MULTIPLIER = 1.5; + config.DECAY_SIZE_FACTOR = 0.2; + config.DECAY_STARVATION_THRESHOLD = 10; + config.DECAY_STARVATION_MULTIPLIER = 2; + + // Mock Date.now + originalDateNow = Date.now; + Date.now = jest.fn(() => NOW); + }); + + afterEach(() => { + // Restore all mocks + jest.restoreAllMocks(); + Date.now = originalDateNow; + }); + + test('should not decay below threshold', () => { + const entity = { score: config.DECAY_THRESHOLD }; + utils.applyDecay(entity, 1000); + expect(entity.score).toBe(config.DECAY_THRESHOLD); + }); + + test('should decay proportionally to time passed', () => { + const entity = { score: 100 }; + const expectedDecayFactor = Math.pow(entity.score, config.DECAY_SIZE_FACTOR) / 10; + const expectedDecayAmount = config.DECAY_RATE * expectedDecayFactor * 1; // 1 second + + utils.applyDecay(entity, 1000); // 1 second + + expect(entity.score).toBeCloseTo(100 - expectedDecayAmount, 5); + }); + + test('should decay more for higher scores', () => { + const smallEntity = { score: 100 }; + const largeEntity = { score: 400 }; + + // Apply same time decay to both + utils.applyDecay(smallEntity, 1000); + utils.applyDecay(largeEntity, 1000); + + // Calculate expected decay + const smallDecayFactor = Math.pow(100, config.DECAY_SIZE_FACTOR) / 10; + const largeDecayFactor = Math.pow(400, config.DECAY_SIZE_FACTOR) / 10; + + // Verify larger entity decayed more + expect(largeDecayFactor).toBeGreaterThan(smallDecayFactor); + expect(100 - smallEntity.score).toBeLessThan(400 - largeEntity.score); + }); + + test('should not decay when disabled', () => { + // Temporarily disable decay for this test + const originalDecayEnabled = config.DECAY_ENABLED; + config.DECAY_ENABLED = false; + + const entity = { score: 100 }; + utils.applyDecay(entity, 1000); + + // Restore original value + config.DECAY_ENABLED = originalDecayEnabled; + + expect(entity.score).toBe(100); + }); + + test('should handle very large time deltas gracefully', () => { + const entity = { score: 200 }; + + // Apply a very large time delta (10 seconds) + utils.applyDecay(entity, 10000); + + // Should not go below threshold + expect(entity.score).toBeGreaterThanOrEqual(config.DECAY_THRESHOLD); + }); + + test('should apply movement-based decay', () => { + // Entity with velocity + const entity = { + score: 100, + velocityX: 2, + velocityY: 2 + }; + + // Entity with no velocity + const staticEntity = { + score: 100, + velocityX: 0, + velocityY: 0 + }; + + utils.applyDecay(entity, 1000); + utils.applyDecay(staticEntity, 1000); + + // Moving entity should decay more + expect(entity.score).toBeLessThan(staticEntity.score); + }); + + test('should apply starvation decay', () => { + // Entity that just ate + const recentlyFedEntity = { + score: 100, + lastFoodTime: NOW + }; + + // Entity that hasn't eaten in a while + const starvingEntity = { + score: 100, + lastFoodTime: NOW - (config.DECAY_STARVATION_THRESHOLD * 1000 + 5000) // Past threshold + }; + + utils.applyDecay(recentlyFedEntity, 1000); + utils.applyDecay(starvingEntity, 1000); + + // Starving entity should decay more + expect(starvingEntity.score).toBeLessThan(recentlyFedEntity.score); + }); + + test('should apply size-based decay using power function', () => { + const smallEntity = { score: 100 }; + const largeEntity = { score: 400 }; + + // Calculate expected decay with power function + const smallDecayFactor = Math.pow(smallEntity.score, config.DECAY_SIZE_FACTOR) / 10; + const largeDecayFactor = Math.pow(largeEntity.score, config.DECAY_SIZE_FACTOR) / 10; + + // Verify power function gives different results than sqrt + expect(largeDecayFactor / smallDecayFactor).not.toBeCloseTo(2); // sqrt would be 2 + + utils.applyDecay(smallEntity, 1000); + utils.applyDecay(largeEntity, 1000); + + // Larger entity should decay more, but by a different factor than with sqrt + expect(largeEntity.score).toBeLessThan(400); + expect(smallEntity.score).toBeLessThan(100); + }); +}); diff --git a/static/js/__tests__/respawn.test.js b/static/js/__tests__/respawn.test.js new file mode 100644 index 0000000..16d53e9 --- /dev/null +++ b/static/js/__tests__/respawn.test.js @@ -0,0 +1,88 @@ +import { respawnEntities } from '../collisions.js'; +import { gameState } from '../gameState.js'; +import { FOOD_COUNT, AI_COUNT, STARTING_SCORE } from '../config.js'; +import { respawnAI } from '../entities.js'; + +// Mock dependencies +jest.mock('../gameState.js', () => ({ + gameState: { + food: [], + aiPlayers: [], + playerCells: [] + } +})); + +jest.mock('../entities.js', () => ({ + respawnAI: jest.fn().mockReturnValue({ + x: 0, + y: 0, + score: 50, + color: 'hsl(0, 70%, 50%)', + direction: 0, + name: 'MockAI' + }) +})); + +jest.mock('../utils.js', () => ({ + getRandomPosition: jest.fn().mockReturnValue({ x: 100, y: 100 }), + findSafeSpawnLocation: jest.fn().mockReturnValue({ x: 200, y: 200 }), + getSize: jest.fn().mockReturnValue(30), + getDistance: jest.fn().mockReturnValue(50) +})); + +describe('respawnEntities', () => { + beforeEach(() => { + // Reset gameState before each test + gameState.food = []; + gameState.aiPlayers = []; + gameState.playerCells = []; + + // Reset mock call counts + jest.clearAllMocks(); + }); + + test('respawns food to reach target count', () => { + // Start with some food + gameState.food = Array(FOOD_COUNT / 2).fill({ x: 0, y: 0, color: 'red' }); + + respawnEntities(); + + // Should have added food to reach FOOD_COUNT + expect(gameState.food.length).toBe(FOOD_COUNT); + }); + + test('respawns AI players to reach target count', () => { + // Start with some AI players + gameState.aiPlayers = Array(AI_COUNT / 2).fill({ x: 0, y: 0, score: 50 }); + + respawnEntities(); + + // Should have added AI players to reach AI_COUNT + expect(gameState.aiPlayers.length).toBe(AI_COUNT); + // Should have called respawnAI for each new AI + expect(respawnAI).toHaveBeenCalledTimes(AI_COUNT / 2); + }); + + test('respawns player if no cells exist', () => { + // Start with no player cells + gameState.playerCells = []; + + respawnEntities(); + + // Should have added a player cell + expect(gameState.playerCells.length).toBe(1); + expect(gameState.playerCells[0]).toHaveProperty('score', STARTING_SCORE); + expect(gameState.playerCells[0]).toHaveProperty('velocityX', 0); + expect(gameState.playerCells[0]).toHaveProperty('velocityY', 0); + }); + + test('does not respawn player if cells already exist', () => { + // Start with one player cell + gameState.playerCells = [{ x: 0, y: 0, score: STARTING_SCORE }]; + + respawnEntities(); + + // Should still have just one player cell + expect(gameState.playerCells.length).toBe(1); + }); +}); diff --git a/static/js/__tests__/spawn-location.test.js b/static/js/__tests__/spawn-location.test.js new file mode 100644 index 0000000..3b17142 --- /dev/null +++ b/static/js/__tests__/spawn-location.test.js @@ -0,0 +1,82 @@ +import { findSafeSpawnLocation } from '../utils.js'; +import { getSize } from '../utils.js'; + +describe('findSafeSpawnLocation', () => { + test('returns a safe position when there are no entities', () => { + const mockGameState = { + aiPlayers: [], + playerCells: [] + }; + + const position = findSafeSpawnLocation(mockGameState); + + expect(position).toHaveProperty('x'); + expect(position).toHaveProperty('y'); + expect(position.x).toBeGreaterThanOrEqual(0); + expect(position.y).toBeGreaterThanOrEqual(0); + }); + + test('finds safe position when there are entities but space available', () => { + const mockGameState = { + aiPlayers: [ + { x: 100, y: 100, score: 100 }, + { x: 500, y: 500, score: 200 } + ], + playerCells: [ + { x: 1500, y: 1500, score: 300 } + ] + }; + + const position = findSafeSpawnLocation(mockGameState); + + expect(position).toHaveProperty('x'); + expect(position).toHaveProperty('y'); + + // Check that position is not too close to any entity + const isSafe = [...mockGameState.aiPlayers, ...mockGameState.playerCells].every(entity => { + const dx = position.x - entity.x; + const dy = position.y - entity.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const safeDistance = getSize(entity.score) + 100; // minDistance = 100 + return distance >= safeDistance; + }); + + expect(isSafe).toBe(true); + }); + + test('falls back to best position when no safe spot found after max attempts', () => { + // Create a crowded game state where finding a safe spot is unlikely + const mockGameState = { + aiPlayers: Array(20).fill(0).map((_, i) => ({ + x: (i % 5) * 400, + y: Math.floor(i / 5) * 400, + score: 400 + })), + playerCells: Array(10).fill(0).map((_, i) => ({ + x: (i % 5) * 400 + 200, + y: Math.floor(i / 5) * 400 + 200, + score: 400 + })) + }; + + // Mock Math.random to always return the same value to make the test deterministic + const originalRandom = Math.random; + let callCount = 0; + Math.random = jest.fn(() => { + callCount++; + return callCount % 10 / 10; // Return values from 0.1 to 1.0 + }); + + const position = findSafeSpawnLocation(mockGameState); + + // Restore Math.random + Math.random = originalRandom; + + expect(position).toHaveProperty('x'); + expect(position).toHaveProperty('y'); + + // We can't guarantee it's safe, but we can verify it returned a position + expect(typeof position.x).toBe('number'); + expect(typeof position.y).toBe('number'); + }); +}); diff --git a/static/js/__tests__/utils.test.js b/static/js/__tests__/utils.test.js index c052e12..5ecaa32 100644 --- a/static/js/__tests__/utils.test.js +++ b/static/js/__tests__/utils.test.js @@ -1,4 +1,5 @@ -import { getSize, getDistance, calculateCenterOfMass } from '../utils.js'; +import { getSize, getDistance, calculateCenterOfMass, getRandomPosition, findSafeSpawnLocation, applyDecay } from '../utils.js'; +import { WORLD_SIZE, DECAY_RATE, DECAY_THRESHOLD, DECAY_SIZE_FACTOR } from '../config.js'; describe('getSize', () => { test('returns correct size for score 0', () => { @@ -52,8 +53,8 @@ describe('calculateCenterOfMass', () => { { x: 10, y: 10, score: 300 } ]; const center = calculateCenterOfMass(cells); - expect(center.x).toBeCloseTo(5); - expect(center.y).toBeCloseTo(5); + expect(center.x).toBeCloseTo(7.5); + expect(center.y).toBeCloseTo(7.5); }); test('returns {x: 0, y: 0} for empty cells array', () => { @@ -67,4 +68,35 @@ describe('calculateCenterOfMass', () => { ]; expect(calculateCenterOfMass(cells)).toEqual({ x: 0, y: 0 }); }); +}); + +describe('applyDecay', () => { + test('should not decay below threshold', () => { + const entity = { score: DECAY_THRESHOLD }; + applyDecay(entity, 1000); + expect(entity.score).toBe(DECAY_THRESHOLD); + }); + + test('should decay proportionally to time passed', () => { + const entity = { score: 100 }; + // Update to use power function instead of sqrt + const expectedDecayFactor = Math.pow(entity.score, DECAY_SIZE_FACTOR) / 10; + const expectedDecayAmount = DECAY_RATE * expectedDecayFactor * 1; // 1 second + + applyDecay(entity, 1000); // 1 second + + expect(entity.score).toBeCloseTo(100 - expectedDecayAmount, 5); + }); + + test('should decay more for higher scores', () => { + const smallEntity = { score: 100 }; + const largeEntity = { score: 400 }; + + // Apply same time decay to both + applyDecay(smallEntity, 1000); + applyDecay(largeEntity, 1000); + + // Verify larger entity decayed more + expect(100 - smallEntity.score).toBeLessThan(400 - largeEntity.score); + }); }); \ No newline at end of file diff --git a/static/js/collisions.js b/static/js/collisions.js index 70cda7e..2ec9ad2 100644 --- a/static/js/collisions.js +++ b/static/js/collisions.js @@ -4,32 +4,56 @@ import { FOOD_SIZE, FOOD_SCORE, COLLISION_THRESHOLD, FOOD_COUNT, AI_COUNT, START import { respawnAI } from './entities.js'; export function handleFoodCollisions() { + const currentTime = Date.now(); + // Player cells eating food for (const playerCell of gameState.playerCells) { + let hasEaten = false; + gameState.food = gameState.food.filter(food => { const distance = getDistance(playerCell, food); const playerSize = getSize(playerCell.score); if (distance < playerSize + FOOD_SIZE) { playerCell.score += FOOD_SCORE; + hasEaten = true; return false; } return true; }); + + // Update the lastFoodTime when player eats food + if (hasEaten) { + playerCell.lastFoodTime = currentTime; + } else if (!playerCell.lastFoodTime) { + // Initialize lastFoodTime if it doesn't exist + playerCell.lastFoodTime = currentTime; + } } // AI eating food for (const ai of gameState.aiPlayers) { + let hasEaten = false; + gameState.food = gameState.food.filter(food => { const distance = getDistance(ai, food); const aiSize = getSize(ai.score); if (distance < aiSize + FOOD_SIZE) { ai.score += FOOD_SCORE; + hasEaten = true; return false; } return true; }); + + // Update the lastFoodTime when AI eats food + if (hasEaten) { + ai.lastFoodTime = currentTime; + } else if (!ai.lastFoodTime) { + // Initialize lastFoodTime if it doesn't exist + ai.lastFoodTime = currentTime; + } } } diff --git a/static/js/config.js b/static/js/config.js index 26d7461..080ecd8 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -7,6 +7,18 @@ export const FOOD_COUNT = 100; export const AI_COUNT = 10; export const COLLISION_THRESHOLD = 1.1; // 10% size difference needed for consumption +// Decay mechanics +export const DECAY_ENABLED = true; // Can be toggled to enable/disable decay +export const DECAY_RATE = 0.05; // Base score lost per second +export const DECAY_THRESHOLD = 50; // Minimum score - decay stops completely when score reaches this value +export const DECAY_INTERVAL = 1000; // Decay calculation interval in milliseconds + +// Advanced decay mechanics +export const DECAY_MOVEMENT_MULTIPLIER = 1.5; // Decay multiplier when moving (higher = more decay when moving) +export const DECAY_SIZE_FACTOR = 0.2; // How much size affects decay (higher = larger cells decay faster) +export const DECAY_STARVATION_THRESHOLD = 10; // Seconds without food before starvation decay kicks in +export const DECAY_STARVATION_MULTIPLIER = 2; // Multiplier for decay when starving + // Split mechanics export const MIN_SPLIT_SCORE = 40; // Minimum score needed to split export const SPLIT_VELOCITY = 12; // Initial velocity of split cells diff --git a/static/js/entities.js b/static/js/entities.js index 01441f2..721e1f6 100644 --- a/static/js/entities.js +++ b/static/js/entities.js @@ -33,7 +33,7 @@ function getUnusedAIName() { return AI_NAMES.find(name => !usedNames.has(name)) || AI_NAMES[0]; } -function updateCellMerging() { +export function updateCellMerging() { const now = Date.now(); const cellsToMerge = []; @@ -265,6 +265,8 @@ export function initEntities() { gameState.aiPlayers = []; console.log('Initializing entities...'); + + const currentTime = Date.now(); // Initialize food for (let i = 0; i < FOOD_COUNT; i++) { @@ -285,11 +287,26 @@ export function initEntities() { score: AI_STARTING_SCORE, color: `hsl(${Math.random() * 360}, 70%, 50%)`, direction: Math.random() * Math.PI * 2, - name: getUnusedAIName() + name: getUnusedAIName(), + lastFoodTime: currentTime // Initialize lastFoodTime }; gameState.aiPlayers.push(ai); } + // Respawn player if all cells are gone + if (gameState.playerCells.length === 0) { + const safePos = findSafeSpawnLocation(gameState); + gameState.playerCells.push({ + x: safePos.x, + y: safePos.y, + score: STARTING_SCORE, + velocityX: 0, + velocityY: 0, + splitTime: 0, + lastFoodTime: currentTime // Initialize lastFoodTime + }); + } + console.log('Entities initialized:', { foodCount: gameState.food.length, aiCount: gameState.aiPlayers.length, @@ -301,6 +318,7 @@ export function initEntities() { export function respawnAI() { const pos = getRandomPosition(); const name = getUnusedAIName(); + const currentTime = Date.now(); return { x: pos.x, @@ -308,6 +326,7 @@ export function respawnAI() { score: AI_STARTING_SCORE, color: `hsl(${Math.random() * 360}, 70%, 50%)`, direction: Math.random() * Math.PI * 2, - name: name + name: name, + lastFoodTime: currentTime // Initialize lastFoodTime }; } \ No newline at end of file diff --git a/static/js/game.js b/static/js/game.js index 4c225dc..cb602a0 100644 --- a/static/js/game.js +++ b/static/js/game.js @@ -3,6 +3,8 @@ import { initRenderer, resizeCanvas, drawGame, drawMinimap, updateLeaderboard } import { updatePlayer, updateAI, initEntities, handlePlayerSplit } from './entities.js'; import { handleFoodCollisions, handlePlayerAICollisions, handleAIAICollisions, respawnEntities } from './collisions.js'; import { initUI } from './ui.js'; +import { applyDecay } from './utils.js'; +import { DECAY_INTERVAL } from './config.js'; function setupInputHandlers() { const canvas = document.getElementById('gameCanvas'); @@ -48,7 +50,32 @@ function verifyGameState() { } } +// Track time for decay calculations +let lastFrameTime = Date.now(); +let lastDecayTime = Date.now(); + function gameLoop() { + const currentTime = Date.now(); + const deltaTime = currentTime - lastFrameTime; + lastFrameTime = currentTime; + + // Apply decay at regular intervals to avoid performance issues + if (currentTime - lastDecayTime >= DECAY_INTERVAL) { + // Calculate time since last decay calculation + const decayDeltaTime = currentTime - lastDecayTime; + lastDecayTime = currentTime; + + // Apply decay to player cells + gameState.playerCells.forEach(cell => { + applyDecay(cell, decayDeltaTime); + }); + + // Apply decay to AI players + gameState.aiPlayers.forEach(ai => { + applyDecay(ai, decayDeltaTime); + }); + } + updatePlayer(); updateAI(); checkCollisions(); diff --git a/static/js/utils.js b/static/js/utils.js index 4fedee2..b6cf2d1 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -1,4 +1,13 @@ -import { WORLD_SIZE } from './config.js'; +import { + WORLD_SIZE, + DECAY_ENABLED, + DECAY_RATE, + DECAY_THRESHOLD, + DECAY_MOVEMENT_MULTIPLIER, + DECAY_SIZE_FACTOR, + DECAY_STARVATION_THRESHOLD, + DECAY_STARVATION_MULTIPLIER +} from './config.js'; export function getSize(score) { return Math.sqrt(score) + 20; @@ -27,6 +36,41 @@ export function calculateCenterOfMass(cells) { }; } +export function applyDecay(entity, deltaTime) { + // Check if decay is disabled globally or if entity is at minimum threshold + if (DECAY_ENABLED === false || entity.score <= DECAY_THRESHOLD) { + return entity.score; + } + + // Initialize decay multiplier + let decayMultiplier = 1.0; + + // Apply movement-based decay if the entity is moving + if (entity.velocityX !== undefined && entity.velocityY !== undefined) { + const speed = Math.sqrt(entity.velocityX * entity.velocityX + entity.velocityY * entity.velocityY); + if (speed > 0.1) { // Only apply if moving at a meaningful speed + decayMultiplier *= (1 + (speed * DECAY_MOVEMENT_MULTIPLIER / 10)); + } + } + + // Apply starvation decay if the entity hasn't eaten recently + if (entity.lastFoodTime !== undefined) { + const timeSinceFood = (Date.now() - entity.lastFoodTime) / 1000; // in seconds + if (timeSinceFood > DECAY_STARVATION_THRESHOLD) { + decayMultiplier *= DECAY_STARVATION_MULTIPLIER; + } + } + + // Calculate base decay amount based on current score and time elapsed + // Higher scores decay faster (proportional to score^DECAY_SIZE_FACTOR) + const decayFactor = Math.pow(entity.score, DECAY_SIZE_FACTOR) / 10; + const decayAmount = DECAY_RATE * decayFactor * decayMultiplier * (deltaTime / 1000); + + // Apply decay with a minimum threshold + entity.score = Math.max(DECAY_THRESHOLD, entity.score - decayAmount); + return entity.score; +} + export function findSafeSpawnLocation(gameState, minDistance = 100) { const maxAttempts = 50; let attempts = 0;