@boltr/geometry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const {
4
+ isPositionOccupied,
5
+ isInFriendlyExclusionZone,
6
+ isInEnemyExclusionZone,
7
+ isPathBlockedByEnemies,
8
+ validateWaypointPath,
9
+ } = require('../src/movement');
10
+
11
+ const makeUnit = (overrides) => ({
12
+ unit_index: 0, player_id: 1, position: { x: 10, y: 10 },
13
+ base_radius: 0.5, eliminated: false, ...overrides,
14
+ });
15
+
16
+ describe('isPositionOccupied', () => {
17
+ it('detects overlap with sum of radii', () => {
18
+ const units = [makeUnit({ base_radius: 0.5 })];
19
+ // Distance 0.8 < 0.5 + 0.5 = 1.0 → occupied
20
+ assert.ok(isPositionOccupied({ x: 10.8, y: 10 }, 0.5, units));
21
+ });
22
+
23
+ it('returns false when no overlap', () => {
24
+ const units = [makeUnit({ base_radius: 0.5 })];
25
+ // Distance 2 > 0.5 + 0.5 = 1.0
26
+ assert.ok(!isPositionOccupied({ x: 12, y: 10 }, 0.5, units));
27
+ });
28
+
29
+ it('excludes self by unit_index', () => {
30
+ const units = [makeUnit({ unit_index: 0, base_radius: 0.5 })];
31
+ assert.ok(!isPositionOccupied({ x: 10, y: 10 }, 0.5, units, 0));
32
+ });
33
+
34
+ it('uses larger base_radius', () => {
35
+ const units = [makeUnit({ base_radius: 1.0 })];
36
+ // Distance 1.2 < 1.0 + 0.5 = 1.5 → occupied
37
+ assert.ok(isPositionOccupied({ x: 11.2, y: 10 }, 0.5, units));
38
+ });
39
+ });
40
+
41
+ describe('isInFriendlyExclusionZone', () => {
42
+ it('returns true when friendly bases would overlap', () => {
43
+ const op = makeUnit({ unit_index: 0, player_id: 1, base_radius: 0.5 });
44
+ const units = [
45
+ op,
46
+ makeUnit({ unit_index: 1, player_id: 1, position: { x: 15, y: 10 }, base_radius: 0.5 }),
47
+ ];
48
+ // Try to end at 14.5 → distance 0.5 < 0.5 + 0.5 = 1.0
49
+ assert.ok(isInFriendlyExclusionZone({ x: 14.5, y: 10 }, op, units));
50
+ });
51
+
52
+ it('returns false for enemy units', () => {
53
+ const op = makeUnit({ unit_index: 0, player_id: 1, base_radius: 0.5 });
54
+ const units = [
55
+ op,
56
+ makeUnit({ unit_index: 1, player_id: 2, position: { x: 10.5, y: 10 }, base_radius: 0.5 }),
57
+ ];
58
+ assert.ok(!isInFriendlyExclusionZone({ x: 10.5, y: 10 }, op, units));
59
+ });
60
+ });
61
+
62
+ describe('isPathBlockedByEnemies', () => {
63
+ it('detects enemy blocking the path', () => {
64
+ const op = makeUnit({ unit_index: 0, player_id: 1 });
65
+ const enemy = makeUnit({ unit_index: 2, player_id: 2, position: { x: 5, y: 10 }, base_radius: 0.5 });
66
+ const units = [op, enemy];
67
+ // Path from (0, 10) to (10, 10) passes through enemy at (5, 10)
68
+ // Blocking radius = 0.5 + 1.0 = 1.5
69
+ const result = isPathBlockedByEnemies({ x: 0, y: 10 }, { x: 10, y: 10 }, op, units);
70
+ assert.ok(result.blocked);
71
+ assert.equal(result.blocker.unit_index, 2);
72
+ });
73
+
74
+ it('exempts charge target', () => {
75
+ const op = makeUnit({ unit_index: 0, player_id: 1 });
76
+ const enemy = makeUnit({ unit_index: 2, player_id: 2, position: { x: 5, y: 10 }, base_radius: 0.5 });
77
+ const units = [op, enemy];
78
+ const result = isPathBlockedByEnemies({ x: 0, y: 10 }, { x: 10, y: 10 }, op, units, 2);
79
+ assert.ok(!result.blocked);
80
+ });
81
+
82
+ it('ignores friendly units', () => {
83
+ const op = makeUnit({ unit_index: 0, player_id: 1 });
84
+ const friendly = makeUnit({ unit_index: 1, player_id: 1, position: { x: 5, y: 10 } });
85
+ const units = [op, friendly];
86
+ const result = isPathBlockedByEnemies({ x: 0, y: 10 }, { x: 10, y: 10 }, op, units);
87
+ assert.ok(!result.blocked);
88
+ });
89
+ });
90
+
91
+ describe('validateWaypointPath', () => {
92
+ const op = makeUnit({ unit_index: 0, player_id: 1, base_radius: 0.5 });
93
+ const emptyUnits = [op];
94
+
95
+ it('validates a simple single-waypoint path', () => {
96
+ const result = validateWaypointPath(
97
+ { x: 0, y: 10 }, [{ x: 3, y: 10 }], 0.5, 6, op, emptyUnits, [], null
98
+ );
99
+ assert.ok(result.valid);
100
+ assert.ok(Math.abs(result.totalDistance - 3) < 0.001);
101
+ });
102
+
103
+ it('validates multi-waypoint path', () => {
104
+ const result = validateWaypointPath(
105
+ { x: 0, y: 0 }, [{ x: 3, y: 0 }, { x: 3, y: 4 }], 0.5, 10, op, emptyUnits, [], null
106
+ );
107
+ assert.ok(result.valid);
108
+ assert.ok(Math.abs(result.totalDistance - 7) < 0.001);
109
+ });
110
+
111
+ it('rejects path exceeding max distance', () => {
112
+ const result = validateWaypointPath(
113
+ { x: 0, y: 0 }, [{ x: 10, y: 0 }], 0.5, 6, op, emptyUnits, [], null
114
+ );
115
+ assert.ok(!result.valid);
116
+ assert.ok(result.error.includes('exceeds maximum'));
117
+ });
118
+
119
+ it('rejects empty waypoints', () => {
120
+ const result = validateWaypointPath(
121
+ { x: 0, y: 0 }, [], 0.5, 6, op, emptyUnits, [], null
122
+ );
123
+ assert.ok(!result.valid);
124
+ });
125
+
126
+ it('adds per-piece terrain penalty', () => {
127
+ const terrain = [
128
+ { type: 'lightCover', vertices: [{ x: 1, y: -2 }, { x: 2, y: -2 }, { x: 2, y: 2 }, { x: 1, y: 2 }] },
129
+ { type: 'difficult', vertices: [{ x: 3, y: -2 }, { x: 4, y: -2 }, { x: 4, y: 2 }, { x: 3, y: 2 }] },
130
+ ];
131
+ // Path crosses 2 pieces → +4" penalty, total = 5 + 4 = 9
132
+ const result = validateWaypointPath(
133
+ { x: 0, y: 0 }, [{ x: 5, y: 0 }], 0.5, 8, op, emptyUnits, terrain, null
134
+ );
135
+ assert.ok(!result.valid);
136
+ assert.ok(result.error.includes('4 inches penalty'));
137
+ });
138
+
139
+ it('rejects path blocked by enemy', () => {
140
+ const enemy = makeUnit({ unit_index: 2, player_id: 2, position: { x: 5, y: 0 }, base_radius: 0.5 });
141
+ const units = [op, enemy];
142
+ const result = validateWaypointPath(
143
+ { x: 0, y: 0 }, [{ x: 10, y: 0 }], 0.5, 15, op, units, [], null
144
+ );
145
+ assert.ok(!result.valid);
146
+ assert.ok(result.error.includes('blocked by enemy'));
147
+ });
148
+
149
+ it('rejects path ending on blocked terrain', () => {
150
+ const terrain = [
151
+ { type: 'heavyCover', vertices: [{ x: 4, y: -2 }, { x: 6, y: -2 }, { x: 6, y: 2 }, { x: 4, y: 2 }] },
152
+ ];
153
+ const result = validateWaypointPath(
154
+ { x: 0, y: 0 }, [{ x: 5, y: 0 }], 0.5, 10, op, emptyUnits, terrain, null
155
+ );
156
+ assert.ok(!result.valid);
157
+ assert.ok(result.error.includes('heavyCover'));
158
+ });
159
+ });
@@ -0,0 +1,118 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const {
4
+ calculateDistance,
5
+ isPointInPolygon,
6
+ lineSegmentsIntersect,
7
+ lineIntersectsPolygon,
8
+ pointToLineSegmentDistance,
9
+ pointToPolygonDistance,
10
+ lineSegmentIntersectsCircle,
11
+ isBaseOverlappingTerrain,
12
+ } = require('../src/primitives');
13
+
14
+ const square = [
15
+ { x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 },
16
+ ];
17
+
18
+ describe('calculateDistance', () => {
19
+ it('returns 0 for same point', () => {
20
+ assert.equal(calculateDistance({ x: 1, y: 1 }, { x: 1, y: 1 }), 0);
21
+ });
22
+ it('calculates horizontal distance', () => {
23
+ assert.equal(calculateDistance({ x: 0, y: 0 }, { x: 3, y: 0 }), 3);
24
+ });
25
+ it('calculates diagonal distance', () => {
26
+ const d = calculateDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
27
+ assert.ok(Math.abs(d - 5) < 0.0001);
28
+ });
29
+ });
30
+
31
+ describe('isPointInPolygon', () => {
32
+ it('returns true for point inside', () => {
33
+ assert.ok(isPointInPolygon({ x: 2, y: 2 }, square));
34
+ });
35
+ it('returns false for point outside', () => {
36
+ assert.ok(!isPointInPolygon({ x: 5, y: 5 }, square));
37
+ });
38
+ it('returns false for invalid vertices', () => {
39
+ assert.ok(!isPointInPolygon({ x: 0, y: 0 }, null));
40
+ assert.ok(!isPointInPolygon({ x: 0, y: 0 }, []));
41
+ assert.ok(!isPointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }]));
42
+ });
43
+ });
44
+
45
+ describe('lineSegmentsIntersect', () => {
46
+ it('detects crossing segments', () => {
47
+ assert.ok(lineSegmentsIntersect(0, 0, 4, 4, 0, 4, 4, 0));
48
+ });
49
+ it('returns false for parallel segments', () => {
50
+ assert.ok(!lineSegmentsIntersect(0, 0, 4, 0, 0, 1, 4, 1));
51
+ });
52
+ });
53
+
54
+ describe('lineIntersectsPolygon', () => {
55
+ it('detects line crossing polygon', () => {
56
+ assert.ok(lineIntersectsPolygon({ x: -1, y: 2 }, { x: 5, y: 2 }, square));
57
+ });
58
+ it('returns false for line outside polygon', () => {
59
+ assert.ok(!lineIntersectsPolygon({ x: 5, y: 5 }, { x: 6, y: 6 }, square));
60
+ });
61
+ });
62
+
63
+ describe('pointToLineSegmentDistance', () => {
64
+ it('returns perpendicular distance', () => {
65
+ const d = pointToLineSegmentDistance({ x: 2, y: 3 }, { x: 0, y: 0 }, { x: 4, y: 0 });
66
+ assert.ok(Math.abs(d - 3) < 0.0001);
67
+ });
68
+ it('returns distance to endpoint when perpendicular falls outside', () => {
69
+ const d = pointToLineSegmentDistance({ x: 5, y: 0 }, { x: 0, y: 0 }, { x: 4, y: 0 });
70
+ assert.ok(Math.abs(d - 1) < 0.0001);
71
+ });
72
+ });
73
+
74
+ describe('pointToPolygonDistance', () => {
75
+ it('returns 0 for point on edge', () => {
76
+ const d = pointToPolygonDistance({ x: 2, y: 0 }, square);
77
+ assert.ok(d < 0.0001);
78
+ });
79
+ it('returns correct distance to nearest edge', () => {
80
+ const d = pointToPolygonDistance({ x: 2, y: -1 }, square);
81
+ assert.ok(Math.abs(d - 1) < 0.0001);
82
+ });
83
+ });
84
+
85
+ describe('lineSegmentIntersectsCircle', () => {
86
+ it('detects segment passing through circle', () => {
87
+ assert.ok(lineSegmentIntersectsCircle(
88
+ { x: 0, y: 0 }, { x: 10, y: 0 },
89
+ { x: 5, y: 0.5 }, 1
90
+ ));
91
+ });
92
+ it('returns false for segment far from circle', () => {
93
+ assert.ok(!lineSegmentIntersectsCircle(
94
+ { x: 0, y: 0 }, { x: 10, y: 0 },
95
+ { x: 5, y: 5 }, 1
96
+ ));
97
+ });
98
+ it('detects segment tangent within radius', () => {
99
+ assert.ok(lineSegmentIntersectsCircle(
100
+ { x: 0, y: 0 }, { x: 10, y: 0 },
101
+ { x: 5, y: 0.9 }, 1
102
+ ));
103
+ });
104
+ });
105
+
106
+ describe('isBaseOverlappingTerrain', () => {
107
+ it('returns true when center is inside polygon', () => {
108
+ assert.ok(isBaseOverlappingTerrain({ x: 2, y: 2 }, 0.5, square));
109
+ });
110
+ it('returns true when base circle overlaps polygon boundary', () => {
111
+ // Point is 0.3 inches from polygon edge, base radius is 0.5
112
+ assert.ok(isBaseOverlappingTerrain({ x: -0.3, y: 2 }, 0.5, square));
113
+ });
114
+ it('returns false when base circle does not overlap', () => {
115
+ // Point is 1 inch from polygon, base radius is 0.5
116
+ assert.ok(!isBaseOverlappingTerrain({ x: -1, y: 2 }, 0.5, square));
117
+ });
118
+ });
@@ -0,0 +1,99 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const {
4
+ checkTargetOnBlockedTerrain,
5
+ checkMovementBlocked,
6
+ countPenaltyTerrainPieces,
7
+ checkTerrainMovementPenalty,
8
+ } = require('../src/terrain');
9
+
10
+ const impassableBlock = {
11
+ type: 'impassable',
12
+ vertices: [{ x: 8, y: 8 }, { x: 12, y: 8 }, { x: 12, y: 12 }, { x: 8, y: 12 }],
13
+ };
14
+
15
+ const lightCoverBlock = {
16
+ type: 'lightCover',
17
+ vertices: [{ x: 2, y: 8 }, { x: 4, y: 8 }, { x: 4, y: 12 }, { x: 2, y: 12 }],
18
+ };
19
+
20
+ const heavyCoverBlock = {
21
+ type: 'heavyCover',
22
+ vertices: [{ x: 14, y: 8 }, { x: 16, y: 8 }, { x: 16, y: 12 }, { x: 14, y: 12 }],
23
+ };
24
+
25
+ const difficultBlock = {
26
+ type: 'difficult',
27
+ vertices: [{ x: 18, y: 8 }, { x: 20, y: 8 }, { x: 20, y: 12 }, { x: 18, y: 12 }],
28
+ };
29
+
30
+ describe('checkTargetOnBlockedTerrain', () => {
31
+ it('returns terrain type when base overlaps blocked terrain', () => {
32
+ // Center at (10, 10) is inside the impassable block
33
+ assert.equal(checkTargetOnBlockedTerrain({ x: 10, y: 10 }, 0.5, [impassableBlock]), 'impassable');
34
+ });
35
+
36
+ it('returns terrain type when base edge overlaps blocked terrain', () => {
37
+ // Center 0.3" from edge, base_radius 0.5 → overlaps
38
+ assert.equal(checkTargetOnBlockedTerrain({ x: 7.8, y: 10 }, 0.5, [impassableBlock]), 'impassable');
39
+ });
40
+
41
+ it('returns null when base does not overlap blocked terrain', () => {
42
+ assert.equal(checkTargetOnBlockedTerrain({ x: 5, y: 10 }, 0.5, [impassableBlock]), null);
43
+ });
44
+
45
+ it('returns null for empty terrain', () => {
46
+ assert.equal(checkTargetOnBlockedTerrain({ x: 10, y: 10 }, 0.5, []), null);
47
+ assert.equal(checkTargetOnBlockedTerrain({ x: 10, y: 10 }, 0.5, null), null);
48
+ });
49
+ });
50
+
51
+ describe('checkMovementBlocked', () => {
52
+ it('returns path_blocked when crossing impassable', () => {
53
+ assert.equal(
54
+ checkMovementBlocked({ x: 5, y: 10 }, { x: 15, y: 10 }, 0.5, [impassableBlock]),
55
+ 'path_blocked'
56
+ );
57
+ });
58
+
59
+ it('returns target_blocked when destination base overlaps impassable', () => {
60
+ // End position just outside but base overlaps
61
+ assert.equal(
62
+ checkMovementBlocked({ x: 5, y: 10 }, { x: 7.8, y: 10 }, 0.5, [impassableBlock]),
63
+ 'target_blocked'
64
+ );
65
+ });
66
+
67
+ it('returns null when path is clear', () => {
68
+ assert.equal(
69
+ checkMovementBlocked({ x: 0, y: 10 }, { x: 5, y: 10 }, 0.5, [impassableBlock]),
70
+ null
71
+ );
72
+ });
73
+ });
74
+
75
+ describe('countPenaltyTerrainPieces', () => {
76
+ it('counts distinct terrain pieces crossed', () => {
77
+ const terrain = [lightCoverBlock, difficultBlock];
78
+ // Path from (0, 10) to (22, 10) crosses both
79
+ assert.equal(countPenaltyTerrainPieces({ x: 0, y: 10 }, { x: 22, y: 10 }, terrain), 2);
80
+ });
81
+
82
+ it('returns 0 when no penalty terrain crossed', () => {
83
+ assert.equal(countPenaltyTerrainPieces({ x: 0, y: 0 }, { x: 1, y: 0 }, [lightCoverBlock]), 0);
84
+ });
85
+
86
+ it('counts heavyCover as penalty terrain', () => {
87
+ assert.equal(countPenaltyTerrainPieces({ x: 13, y: 10 }, { x: 17, y: 10 }, [heavyCoverBlock]), 1);
88
+ });
89
+ });
90
+
91
+ describe('checkTerrainMovementPenalty', () => {
92
+ it('returns true when penalty terrain crossed', () => {
93
+ assert.ok(checkTerrainMovementPenalty({ x: 0, y: 10 }, { x: 5, y: 10 }, [lightCoverBlock]));
94
+ });
95
+
96
+ it('returns false when no penalty terrain crossed', () => {
97
+ assert.ok(!checkTerrainMovementPenalty({ x: 0, y: 0 }, { x: 1, y: 0 }, [lightCoverBlock]));
98
+ });
99
+ });
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@boltr/geometry",
3
+ "version": "0.1.0",
4
+ "description": "Shared geometry primitives for BOLTR tactical combat game",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./primitives": "./src/primitives.js",
9
+ "./terrain": "./src/terrain.js",
10
+ "./los": "./src/los.js",
11
+ "./movement": "./src/movement.js"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test __tests__/*.test.js"
15
+ },
16
+ "keywords": ["boltr", "geometry", "game"],
17
+ "license": "UNLICENSED",
18
+ "engines": {
19
+ "node": ">=18.0.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
24
+ }
package/src/index.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @boltr/geometry
3
+ *
4
+ * Shared geometry primitives and game-specific spatial helpers
5
+ * for the BOLTR tactical combat game.
6
+ *
7
+ * Used by both the Xano backend (lambdas) and the frontend client.
8
+ */
9
+
10
+ const primitives = require('./primitives');
11
+ const terrain = require('./terrain');
12
+ const los = require('./los');
13
+ const movement = require('./movement');
14
+
15
+ module.exports = {
16
+ // Primitives
17
+ calculateDistance: primitives.calculateDistance,
18
+ isPointInPolygon: primitives.isPointInPolygon,
19
+ lineSegmentsIntersect: primitives.lineSegmentsIntersect,
20
+ lineIntersectsPolygon: primitives.lineIntersectsPolygon,
21
+ pointToLineSegmentDistance: primitives.pointToLineSegmentDistance,
22
+ pointToPolygonDistance: primitives.pointToPolygonDistance,
23
+ lineSegmentIntersectsCircle: primitives.lineSegmentIntersectsCircle,
24
+ isBaseOverlappingTerrain: primitives.isBaseOverlappingTerrain,
25
+
26
+ // Terrain
27
+ BLOCKED_TERRAIN_TYPES: terrain.BLOCKED_TERRAIN_TYPES,
28
+ PENALTY_TERRAIN_TYPES: terrain.PENALTY_TERRAIN_TYPES,
29
+ checkTargetOnBlockedTerrain: terrain.checkTargetOnBlockedTerrain,
30
+ checkMovementBlocked: terrain.checkMovementBlocked,
31
+ countPenaltyTerrainPieces: terrain.countPenaltyTerrainPieces,
32
+ checkTerrainMovementPenalty: terrain.checkTerrainMovementPenalty,
33
+
34
+ // Line of Sight
35
+ checkLineOfSight: los.checkLineOfSight,
36
+
37
+ // Movement
38
+ DEFAULT_BASE_RADIUS: movement.DEFAULT_BASE_RADIUS,
39
+ isPositionOccupied: movement.isPositionOccupied,
40
+ isInFriendlyExclusionZone: movement.isInFriendlyExclusionZone,
41
+ isInEnemyExclusionZone: movement.isInEnemyExclusionZone,
42
+ isPathBlockedByEnemies: movement.isPathBlockedByEnemies,
43
+ validateWaypointPath: movement.validateWaypointPath,
44
+ };
package/src/los.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @boltr/geometry - Line of Sight checks
3
+ *
4
+ * Determines cover status between a shooter and target based on
5
+ * intervening terrain.
6
+ */
7
+
8
+ const {
9
+ lineIntersectsPolygon,
10
+ pointToPolygonDistance,
11
+ } = require('./primitives');
12
+
13
+ /**
14
+ * Check line of sight between shooter and target through terrain.
15
+ *
16
+ * Cover shooting mechanics:
17
+ * - Shooter bracing on cover (within 1"): shoots across without penalty
18
+ * - Target in cover (within 1"): gets defense bonus
19
+ * - Heavy cover blocks LOS when neither operative is within 1"
20
+ * - Light cover at distance has no effect (OBSCURED removed)
21
+ *
22
+ * @param {{position: {x: number, y: number}}} shooter
23
+ * @param {{position: {x: number, y: number}}} target
24
+ * @param {Array} terrain
25
+ * @returns {'CLEAR'|'LIGHT_COVER'|'HEAVY_COVER'|'BLOCKED'}
26
+ */
27
+ function checkLineOfSight(shooter, target, terrain) {
28
+ if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return 'CLEAR';
29
+
30
+ for (const piece of terrain) {
31
+ if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
32
+ if (!lineIntersectsPolygon(shooter.position, target.position, piece.vertices)) continue;
33
+
34
+ const terrainType = piece.type || 'lightCover';
35
+
36
+ // Only lightCover and heavyCover affect shooting
37
+ if (terrainType !== 'lightCover' && terrainType !== 'heavyCover') continue;
38
+
39
+ const shooterDist = pointToPolygonDistance(shooter.position, piece.vertices);
40
+ const targetDist = pointToPolygonDistance(target.position, piece.vertices);
41
+
42
+ // Shooter bracing on cover - no penalty, skip this terrain piece
43
+ if (shooterDist <= 1.0) continue;
44
+
45
+ // Target in cover - defense bonus applies
46
+ if (targetDist <= 1.0) {
47
+ if (terrainType === 'heavyCover') return 'HEAVY_COVER';
48
+ if (terrainType === 'lightCover') return 'LIGHT_COVER';
49
+ }
50
+
51
+ // Neither in contact with heavy cover - blocks LOS
52
+ if (terrainType === 'heavyCover') return 'BLOCKED';
53
+
54
+ // lightCover at distance - no effect (OBSCURED removed)
55
+ }
56
+ return 'CLEAR';
57
+ }
58
+
59
+ module.exports = {
60
+ checkLineOfSight,
61
+ };
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @boltr/geometry - Movement validation helpers
3
+ *
4
+ * Waypoint-based movement validation, enemy path blocking,
5
+ * and friendly spacing checks.
6
+ */
7
+
8
+ const {
9
+ calculateDistance,
10
+ lineSegmentIntersectsCircle,
11
+ lineIntersectsPolygon,
12
+ isBaseOverlappingTerrain,
13
+ } = require('./primitives');
14
+
15
+ const {
16
+ checkTargetOnBlockedTerrain,
17
+ checkMovementBlocked,
18
+ countPenaltyTerrainPieces,
19
+ } = require('./terrain');
20
+
21
+ /** Default base radius when not specified on a unit */
22
+ const DEFAULT_BASE_RADIUS = 0.5;
23
+
24
+ /**
25
+ * Check if a position's base overlaps another unit's base.
26
+ * Two bases overlap when the distance between centers is less than
27
+ * the sum of their radii.
28
+ *
29
+ * @param {{x: number, y: number}} position
30
+ * @param {number} baseRadius
31
+ * @param {Array} units Game state units array
32
+ * @param {number|null} excludeUnitIndex Unit to exclude (self)
33
+ * @returns {boolean}
34
+ */
35
+ function isPositionOccupied(position, baseRadius, units, excludeUnitIndex) {
36
+ return units.some(unit => {
37
+ if (unit.eliminated) return false;
38
+ if (excludeUnitIndex !== undefined && unit.unit_index === excludeUnitIndex) return false;
39
+ const unitRadius = unit.base_radius || DEFAULT_BASE_RADIUS;
40
+ return calculateDistance(unit.position, position) < (baseRadius + unitRadius);
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Check if a position is within the exclusion zone of any friendly operative.
46
+ * Uses sum-of-radii (#54): two friendly bases cannot overlap.
47
+ *
48
+ * @param {{x: number, y: number}} position
49
+ * @param {object} operative The moving operative
50
+ * @param {Array} units
51
+ * @returns {boolean}
52
+ */
53
+ function isInFriendlyExclusionZone(position, operative, units) {
54
+ const selfRadius = operative.base_radius || DEFAULT_BASE_RADIUS;
55
+ return units.some(friendly =>
56
+ friendly.player_id === operative.player_id &&
57
+ friendly.unit_index !== operative.unit_index &&
58
+ !friendly.eliminated &&
59
+ calculateDistance(position, friendly.position) < (selfRadius + (friendly.base_radius || DEFAULT_BASE_RADIUS))
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Check if position is within 1" exclusion zone of any enemy operative.
65
+ *
66
+ * @param {{x: number, y: number}} position
67
+ * @param {object} operative The moving operative
68
+ * @param {Array} units
69
+ * @returns {boolean}
70
+ */
71
+ function isInEnemyExclusionZone(position, operative, units) {
72
+ return units.some(enemy =>
73
+ enemy.player_id !== operative.player_id &&
74
+ !enemy.eliminated &&
75
+ calculateDistance(position, enemy.position) < 1
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Check if a straight-line path is blocked by any enemy operatives (#56).
81
+ * An enemy blocks a path when the line segment comes within
82
+ * (enemy.base_radius + 1.0) inches of the enemy center.
83
+ *
84
+ * @param {{x: number, y: number}} startPos
85
+ * @param {{x: number, y: number}} endPos
86
+ * @param {object} operative The moving operative
87
+ * @param {Array} units All game units
88
+ * @param {number|null} exemptUnitIndex Charge target to exempt
89
+ * @returns {{blocked: boolean, blocker?: object}}
90
+ */
91
+ function isPathBlockedByEnemies(startPos, endPos, operative, units, exemptUnitIndex) {
92
+ for (const enemy of units) {
93
+ if (enemy.player_id === operative.player_id) continue;
94
+ if (enemy.eliminated) continue;
95
+ if (exemptUnitIndex !== undefined && enemy.unit_index === exemptUnitIndex) continue;
96
+
97
+ const blockingRadius = (enemy.base_radius || DEFAULT_BASE_RADIUS) + 1.0;
98
+ if (lineSegmentIntersectsCircle(startPos, endPos, enemy.position, blockingRadius)) {
99
+ return { blocked: true, blocker: enemy };
100
+ }
101
+ }
102
+ return { blocked: false };
103
+ }
104
+
105
+ /**
106
+ * Validate a waypoint path: checks each segment for terrain blocking,
107
+ * enemy path blocking, and computes total distance with per-piece penalty.
108
+ *
109
+ * @param {{x: number, y: number}} startPos Operative's current position
110
+ * @param {{x: number, y: number}[]} waypoints Array of waypoint positions
111
+ * @param {number} baseRadius Operative's base radius
112
+ * @param {number} maxDistance Maximum allowed movement distance
113
+ * @param {object} operative The moving operative
114
+ * @param {Array} units All game units
115
+ * @param {Array} terrain Terrain array from map_data
116
+ * @param {object} [mapData] Map data for bounds checking
117
+ * @param {object} [options] Options
118
+ * @param {number|null} [options.exemptEnemyUnitIndex] Enemy unit to exempt from path blocking (charge target)
119
+ * @param {boolean} [options.checkEnemyBlocking=true] Whether to check enemy path blocking
120
+ * @param {Function} [options.isWithinBounds] Bounds-checking function
121
+ * @returns {{valid: boolean, error?: string, totalDistance?: number}}
122
+ */
123
+ function validateWaypointPath(startPos, waypoints, baseRadius, maxDistance, operative, units, terrain, mapData, options) {
124
+ const opts = options || {};
125
+ const checkEnemyBlocking = opts.checkEnemyBlocking !== false;
126
+ const exemptEnemyUnitIndex = opts.exemptEnemyUnitIndex;
127
+ const isWithinBounds = opts.isWithinBounds;
128
+
129
+ if (!waypoints || !Array.isArray(waypoints) || waypoints.length === 0) {
130
+ return { valid: false, error: 'Movement requires at least one waypoint' };
131
+ }
132
+
133
+ let totalDistance = 0;
134
+ const terrainPiecesCrossed = new Set();
135
+ let currentPos = startPos;
136
+
137
+ for (let i = 0; i < waypoints.length; i++) {
138
+ const wp = waypoints[i];
139
+ const isFinal = (i === waypoints.length - 1);
140
+
141
+ // Validate waypoint format
142
+ if (!wp || typeof wp.x !== 'number' || typeof wp.y !== 'number') {
143
+ return { valid: false, error: `Invalid waypoint format at index ${i}` };
144
+ }
145
+
146
+ // Bounds check
147
+ if (isWithinBounds && !isWithinBounds(wp, mapData)) {
148
+ return { valid: false, error: `Waypoint ${i} is outside battlefield bounds` };
149
+ }
150
+
151
+ // Check path segment for impassable terrain blocking
152
+ const blocked = checkMovementBlocked(currentPos, wp, baseRadius, terrain);
153
+ if (blocked) {
154
+ return { valid: false, error: `Movement blocked by impassable terrain at segment ${i}` };
155
+ }
156
+
157
+ // Check enemy path blocking (#56)
158
+ if (checkEnemyBlocking) {
159
+ const enemyBlock = isPathBlockedByEnemies(currentPos, wp, operative, units, exemptEnemyUnitIndex);
160
+ if (enemyBlock.blocked) {
161
+ return { valid: false, error: `Movement path blocked by enemy operative (unit ${enemyBlock.blocker.unit_index})` };
162
+ }
163
+ }
164
+
165
+ // Final waypoint: check destination terrain and spacing
166
+ if (isFinal) {
167
+ const blockedType = checkTargetOnBlockedTerrain(wp, baseRadius, terrain);
168
+ if (blockedType) {
169
+ return { valid: false, error: `Cannot end movement on ${blockedType} terrain` };
170
+ }
171
+
172
+ if (isPositionOccupied(wp, baseRadius, units, operative.unit_index)) {
173
+ return { valid: false, error: 'Target position is occupied by another operative' };
174
+ }
175
+
176
+ if (isInFriendlyExclusionZone(wp, operative, units)) {
177
+ return { valid: false, error: 'Cannot end movement within exclusion zone of friendly operative' };
178
+ }
179
+ }
180
+
181
+ // Intermediate waypoints: full validation
182
+ if (!isFinal) {
183
+ const blockedType = checkTargetOnBlockedTerrain(wp, baseRadius, terrain);
184
+ if (blockedType) {
185
+ return { valid: false, error: `Cannot pass through ${blockedType} terrain at waypoint ${i}` };
186
+ }
187
+ }
188
+
189
+ // Accumulate terrain penalty pieces for this segment
190
+ if (terrain && Array.isArray(terrain)) {
191
+ const PENALTY_TERRAIN_TYPES = ['difficult', 'lightCover', 'heavyCover'];
192
+ for (const piece of terrain) {
193
+ if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
194
+ if (!PENALTY_TERRAIN_TYPES.includes(piece.type)) continue;
195
+ if (lineIntersectsPolygon(currentPos, wp, piece.vertices)) {
196
+ // Use a stable identifier for the terrain piece
197
+ const pieceId = piece.id || `${piece.type}_${piece.vertices[0].x}_${piece.vertices[0].y}`;
198
+ terrainPiecesCrossed.add(pieceId);
199
+ }
200
+ }
201
+ }
202
+
203
+ // Accumulate segment distance
204
+ totalDistance += calculateDistance(currentPos, wp);
205
+ currentPos = wp;
206
+ }
207
+
208
+ // Apply per-terrain-piece penalty: +2" per unique piece crossed
209
+ const TERRAIN_PENALTY_PER_PIECE = 2;
210
+ const effectiveDistance = totalDistance + (terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE);
211
+
212
+ if (effectiveDistance > maxDistance) {
213
+ if (terrainPiecesCrossed.size > 0) {
214
+ return {
215
+ valid: false,
216
+ error: `Movement through terrain adds ${terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE} inches penalty (${totalDistance.toFixed(1)} + ${terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE} > ${maxDistance})`,
217
+ };
218
+ }
219
+ return {
220
+ valid: false,
221
+ error: `Movement distance ${totalDistance.toFixed(1)} inches exceeds maximum ${maxDistance} inches`,
222
+ };
223
+ }
224
+
225
+ return { valid: true, totalDistance, effectiveDistance, terrainPenalty: terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE };
226
+ }
227
+
228
+ module.exports = {
229
+ DEFAULT_BASE_RADIUS,
230
+ isPositionOccupied,
231
+ isInFriendlyExclusionZone,
232
+ isInEnemyExclusionZone,
233
+ isPathBlockedByEnemies,
234
+ validateWaypointPath,
235
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @boltr/geometry - Pure geometry primitives
3
+ *
4
+ * These are stateless math functions with no game-logic dependencies.
5
+ * Every function takes plain {x, y} point objects and vertex arrays.
6
+ */
7
+
8
+ /**
9
+ * Euclidean distance between two points.
10
+ * @param {{x: number, y: number}} pos1
11
+ * @param {{x: number, y: number}} pos2
12
+ * @returns {number}
13
+ */
14
+ function calculateDistance(pos1, pos2) {
15
+ return Math.sqrt(Math.pow(pos2.x - pos1.x, 2) + Math.pow(pos2.y - pos1.y, 2));
16
+ }
17
+
18
+ /**
19
+ * Point-in-polygon test (ray casting algorithm).
20
+ * @param {{x: number, y: number}} point
21
+ * @param {{x: number, y: number}[]} vertices
22
+ * @returns {boolean}
23
+ */
24
+ function isPointInPolygon(point, vertices) {
25
+ if (!vertices || !Array.isArray(vertices) || vertices.length < 3) return false;
26
+ let inside = false;
27
+ for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
28
+ const xi = vertices[i].x, yi = vertices[i].y;
29
+ const xj = vertices[j].x, yj = vertices[j].y;
30
+ if (((yi > point.y) !== (yj > point.y)) &&
31
+ (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi)) {
32
+ inside = !inside;
33
+ }
34
+ }
35
+ return inside;
36
+ }
37
+
38
+ /**
39
+ * Test whether two line segments intersect (exclusive of endpoints).
40
+ * @returns {boolean}
41
+ */
42
+ function lineSegmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
43
+ const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
44
+ if (Math.abs(denom) < 0.0001) return false;
45
+ const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom;
46
+ const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom;
47
+ return ua > 0.001 && ua < 0.999 && ub > 0.001 && ub < 0.999;
48
+ }
49
+
50
+ /**
51
+ * Test whether a line segment intersects any edge of a polygon.
52
+ * @param {{x: number, y: number}} lineStart
53
+ * @param {{x: number, y: number}} lineEnd
54
+ * @param {{x: number, y: number}[]} vertices
55
+ * @returns {boolean}
56
+ */
57
+ function lineIntersectsPolygon(lineStart, lineEnd, vertices) {
58
+ if (!vertices || !Array.isArray(vertices) || vertices.length < 3) return false;
59
+ for (let i = 0; i < vertices.length; i++) {
60
+ const j = (i + 1) % vertices.length;
61
+ if (lineSegmentsIntersect(
62
+ lineStart.x, lineStart.y, lineEnd.x, lineEnd.y,
63
+ vertices[i].x, vertices[i].y, vertices[j].x, vertices[j].y
64
+ )) return true;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * Shortest distance from a point to a line segment.
71
+ * @param {{x: number, y: number}} point
72
+ * @param {{x: number, y: number}} lineStart
73
+ * @param {{x: number, y: number}} lineEnd
74
+ * @returns {number}
75
+ */
76
+ function pointToLineSegmentDistance(point, lineStart, lineEnd) {
77
+ const dx = lineEnd.x - lineStart.x;
78
+ const dy = lineEnd.y - lineStart.y;
79
+ const lengthSquared = dx * dx + dy * dy;
80
+ if (lengthSquared === 0) return calculateDistance(point, lineStart);
81
+ let t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / lengthSquared;
82
+ t = Math.max(0, Math.min(1, t));
83
+ const projection = { x: lineStart.x + t * dx, y: lineStart.y + t * dy };
84
+ return calculateDistance(point, projection);
85
+ }
86
+
87
+ /**
88
+ * Shortest distance from a point to the boundary of a polygon.
89
+ * @param {{x: number, y: number}} point
90
+ * @param {{x: number, y: number}[]} vertices
91
+ * @returns {number}
92
+ */
93
+ function pointToPolygonDistance(point, vertices) {
94
+ if (!vertices || !Array.isArray(vertices) || vertices.length < 3) return Infinity;
95
+ let minDist = Infinity;
96
+ for (let i = 0; i < vertices.length; i++) {
97
+ const j = (i + 1) % vertices.length;
98
+ const dist = pointToLineSegmentDistance(point, vertices[i], vertices[j]);
99
+ if (dist < minDist) minDist = dist;
100
+ }
101
+ return minDist;
102
+ }
103
+
104
+ /**
105
+ * Test whether a line segment intersects a circle.
106
+ * Used for path-vs-enemy blocking checks (#56).
107
+ * @param {{x: number, y: number}} lineStart
108
+ * @param {{x: number, y: number}} lineEnd
109
+ * @param {{x: number, y: number}} center Circle center
110
+ * @param {number} radius Circle radius
111
+ * @returns {boolean}
112
+ */
113
+ function lineSegmentIntersectsCircle(lineStart, lineEnd, center, radius) {
114
+ const dist = pointToLineSegmentDistance(center, lineStart, lineEnd);
115
+ return dist < radius;
116
+ }
117
+
118
+ /**
119
+ * Test whether a base circle overlaps a terrain polygon.
120
+ * Minkowski-equivalent: the center is inside the polygon OR
121
+ * the center-to-boundary distance is less than the base radius.
122
+ * Used for terrain exclusion with operative base size (#55).
123
+ * @param {{x: number, y: number}} position Center of base
124
+ * @param {number} baseRadius Radius of base
125
+ * @param {{x: number, y: number}[]} vertices Polygon vertices
126
+ * @returns {boolean}
127
+ */
128
+ function isBaseOverlappingTerrain(position, baseRadius, vertices) {
129
+ if (isPointInPolygon(position, vertices)) return true;
130
+ return pointToPolygonDistance(position, vertices) < baseRadius;
131
+ }
132
+
133
+ module.exports = {
134
+ calculateDistance,
135
+ isPointInPolygon,
136
+ lineSegmentsIntersect,
137
+ lineIntersectsPolygon,
138
+ pointToLineSegmentDistance,
139
+ pointToPolygonDistance,
140
+ lineSegmentIntersectsCircle,
141
+ isBaseOverlappingTerrain,
142
+ };
package/src/terrain.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @boltr/geometry - Terrain interaction helpers
3
+ *
4
+ * Functions that check how movement paths and positions interact
5
+ * with terrain polygons on the battlefield.
6
+ */
7
+
8
+ const {
9
+ isPointInPolygon,
10
+ lineIntersectsPolygon,
11
+ isBaseOverlappingTerrain,
12
+ } = require('./primitives');
13
+
14
+ /** Terrain types that block placement / ending movement */
15
+ const BLOCKED_TERRAIN_TYPES = ['lightCover', 'heavyCover', 'impassable'];
16
+
17
+ /** Terrain types that add a movement penalty when crossed */
18
+ const PENALTY_TERRAIN_TYPES = ['difficult', 'lightCover', 'heavyCover'];
19
+
20
+ /**
21
+ * Check whether a position's base circle sits on blocked terrain.
22
+ * Returns the terrain type string if blocked, or null.
23
+ *
24
+ * @param {{x: number, y: number}} position
25
+ * @param {number} baseRadius
26
+ * @param {Array} terrain
27
+ * @returns {string|null}
28
+ */
29
+ function checkTargetOnBlockedTerrain(position, baseRadius, terrain) {
30
+ if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return null;
31
+
32
+ for (const piece of terrain) {
33
+ if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
34
+ if (!BLOCKED_TERRAIN_TYPES.includes(piece.type)) continue;
35
+ if (isBaseOverlappingTerrain(position, baseRadius, piece.vertices)) {
36
+ return piece.type;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Check whether a straight-line movement path is blocked by impassable terrain.
44
+ * Also checks whether the destination base overlaps impassable terrain.
45
+ *
46
+ * @param {{x: number, y: number}} startPos
47
+ * @param {{x: number, y: number}} endPos
48
+ * @param {number} baseRadius
49
+ * @param {Array} terrain
50
+ * @returns {'path_blocked'|'target_blocked'|null}
51
+ */
52
+ function checkMovementBlocked(startPos, endPos, baseRadius, terrain) {
53
+ if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return null;
54
+
55
+ for (const piece of terrain) {
56
+ if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
57
+ if (piece.type !== 'impassable') continue;
58
+
59
+ if (lineIntersectsPolygon(startPos, endPos, piece.vertices)) {
60
+ return 'path_blocked';
61
+ }
62
+
63
+ if (isBaseOverlappingTerrain(endPos, baseRadius, piece.vertices)) {
64
+ return 'target_blocked';
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Count distinct terrain pieces that impose a movement penalty along a path segment.
72
+ * Returns the number of unique penalty terrain pieces crossed.
73
+ *
74
+ * @param {{x: number, y: number}} startPos
75
+ * @param {{x: number, y: number}} endPos
76
+ * @param {Array} terrain
77
+ * @returns {number}
78
+ */
79
+ function countPenaltyTerrainPieces(startPos, endPos, terrain) {
80
+ if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return 0;
81
+
82
+ let count = 0;
83
+ for (const piece of terrain) {
84
+ if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
85
+ if (!PENALTY_TERRAIN_TYPES.includes(piece.type)) continue;
86
+ if (lineIntersectsPolygon(startPos, endPos, piece.vertices)) {
87
+ count++;
88
+ }
89
+ }
90
+ return count;
91
+ }
92
+
93
+ /**
94
+ * Legacy helper: returns boolean if ANY penalty terrain is crossed.
95
+ * Kept for backward compat during transition to per-piece penalty.
96
+ */
97
+ function checkTerrainMovementPenalty(startPos, endPos, terrain) {
98
+ return countPenaltyTerrainPieces(startPos, endPos, terrain) > 0;
99
+ }
100
+
101
+ module.exports = {
102
+ BLOCKED_TERRAIN_TYPES,
103
+ PENALTY_TERRAIN_TYPES,
104
+ checkTargetOnBlockedTerrain,
105
+ checkMovementBlocked,
106
+ countPenaltyTerrainPieces,
107
+ checkTerrainMovementPenalty,
108
+ };