From c1f69460ee69975de3211d8a74f8f6062649ede6 Mon Sep 17 00:00:00 2001 From: Dowon Date: Tue, 9 Jun 2026 16:32:19 +0900 Subject: [PATCH 1/8] resources: add clockwise point sorting helpers Add Direction utilities for sorting x/y points clockwise from either an explicit reference angle or the edge of a semicircle-contained cluster. --- resources/util.ts | 105 ++++++++++++++++++++++++++++++++++++ test/unittests/util_test.ts | 56 +++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/resources/util.ts b/resources/util.ts index 6e95ef6c2c4..24f36c9cca7 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -356,6 +356,13 @@ const xyTo4DirIntercardNum = (x: number, y: number, centerX: number, centerY: nu return Math.round(2 - 2 * ((Math.PI / 4) + Math.atan2(x, y)) / Math.PI) % 4; }; +const xyToClockwiseAngle = (x: number, y: number, centerX: number, centerY: number): number => { + // Returns a continuous angle where north is 0 and values increase clockwise. + x = x - centerX; + y = y - centerY; + return (Math.PI - Math.atan2(x, y) + 2 * Math.PI) % (2 * Math.PI); +}; + const hdgTo16DirNum = (heading: number): number => { // N = 0, NNE = 1, ..., NNW = 15 return (Math.round(8 - 8 * heading / Math.PI) % 16 + 16) % 16; @@ -383,6 +390,102 @@ const outputFromIntercardNum = (dirNum: number): DirectionOutputIntercard => { return outputIntercardDir[dirNum] ?? 'unknown'; }; +interface SortablePoint { + x: number; + y: number; +} + +type PointSortEntry = { + point: T; + angle: number; + index: number; +}; + +const twoPi = 2 * Math.PI; + +const clockwiseAngleDelta = (toAngle: number, fromAngle: number): number => { + // Normalize the wraparound case so the result is always in [0, 2π). + return (toAngle - fromAngle + twoPi) % twoPi; +}; + +const sortPointEntriesClockwiseFrom = ( + entries: PointSortEntry[], + referenceAngle: number, +): T[] => { + // Use original index as a stable tie-breaker for points on the same ray. + return entries + .map((entry) => ({ + ...entry, + delta: clockwiseAngleDelta(entry.angle, referenceAngle), + })) + .sort((a, b) => a.delta - b.delta || a.index - b.index) + .map((entry) => entry.point); +}; + +const sortPointsClockwiseFrom = ( + points: readonly T[], + centerX: number, + centerY: number, + referenceX: number, + referenceY: number, +): T[] => { + // Sorts points clockwise, starting from the angle of `reference`. + const referenceAngle = xyToClockwiseAngle(referenceX, referenceY, centerX, centerY); + const entries: PointSortEntry[] = points.map((point, index) => ({ + point: point, + angle: xyToClockwiseAngle(point.x, point.y, centerX, centerY), + index: index, + })); + + return sortPointEntriesClockwiseFrom(entries, referenceAngle); +}; + +const sortPointsClockwise = ( + points: readonly T[], + centerX: number, + centerY: number, +): T[] | undefined => { + // Sorts points clockwise if they fit within a semicircle around `center`. + // otherwise, returns undefined. + if (points.length <= 1) + return [...points]; + + const entries: PointSortEntry[] = points + .map((point, index) => ({ + point: point, + angle: xyToClockwiseAngle(point.x, point.y, centerX, centerY), + index: index, + })) + .sort((a, b) => a.angle - b.angle || a.index - b.index); + + let largestGap = 0; + let startIdx = 0; + for (const [idx, entry] of entries.entries()) { + const nextIdx = (idx + 1) % entries.length; + const nextEntry = entries[nextIdx]; + if (nextEntry === undefined) + continue; + const gap = clockwiseAngleDelta(nextEntry.angle, entry.angle); + + if (gap > largestGap) { + largestGap = gap; + startIdx = nextIdx; + } + } + + // With no angular separation, there is no clockwise ordering signal. + if (largestGap === 0) + return [...points]; + + // Points fit in a semicircle if the complement of their largest gap is <= 180 degrees. + const arcLength = twoPi - largestGap; + if (arcLength > Math.PI + 1e-9) + return undefined; + + const referenceAngle = entries[startIdx]?.angle ?? 0; + return sortPointEntriesClockwiseFrom(entries, referenceAngle); +}; + export const Directions = { output8Dir: output8Dir, output16Dir: output16Dir, @@ -402,6 +505,8 @@ export const Directions = { hdgTo4DirNum: hdgTo4DirNum, outputFrom8DirNum: outputFrom8DirNum, outputFromCardinalNum: outputFromCardinalNum, + sortPointsClockwise: sortPointsClockwise, + sortPointsClockwiseFrom: sortPointsClockwiseFrom, combatantStatePosTo8Dir: ( combatant: PluginCombatantState, centerX: number, diff --git a/test/unittests/util_test.ts b/test/unittests/util_test.ts index 4078335c5c7..ea71f79e2e8 100644 --- a/test/unittests/util_test.ts +++ b/test/unittests/util_test.ts @@ -4,6 +4,7 @@ import Util, { allJobs, casterDpsJobs, craftingJobs, + Directions, gatheringJobs, healerJobs, limitedJobs, @@ -93,4 +94,59 @@ describe('util tests', () => { assert(!Util.isLimitedJob(job)) ); }); + + it('sorts points clockwise from a reference point', () => { + const points = [ + { id: 'NE', x: 1, y: -1 }, + { id: 'N', x: 0, y: -1 }, + { id: 'NW', x: -1, y: -1 }, + ]; + + const sorted = Directions.sortPointsClockwiseFrom(points, 0, 0, -1, -1); + + assert.deepEqual(sorted.map((point) => point.id), ['NW', 'N', 'NE']); + assert.deepEqual(points.map((point) => point.id), ['NE', 'N', 'NW']); + assert.deepEqual(sorted, [points[2], points[1], points[0]]); + }); + + it('sorts clustered points clockwise when contained within a semicircle', () => { + const points = [ + { id: 'NE', x: 1, y: -1 }, + { id: 'NW', x: -1, y: -1 }, + { id: 'N', x: 0, y: -1 }, + ]; + + const sorted = Directions.sortPointsClockwise(points, 0, 0); + + assert.deepEqual(sorted?.map((point) => point.id), ['NW', 'N', 'NE']); + }); + + it('returns undefined when points are not contained within a semicircle', () => { + const points = [ + { id: 'N', x: 0, y: -1 }, + { id: 'E', x: 1, y: 0 }, + { id: 'S', x: 0, y: 1 }, + { id: 'W', x: -1, y: 0 }, + ]; + + assert.isUndefined(Directions.sortPointsClockwise(points, 0, 0)); + }); + + it('keeps points in input order when all angles are equal', () => { + const points = [ + { id: 'near', x: 0, y: -1 }, + { id: 'far', x: 0, y: -2 }, + ]; + + const sorted = Directions.sortPointsClockwise(points, 0, 0); + + assert.deepEqual(sorted?.map((point) => point.id), ['near', 'far']); + assert.notStrictEqual(sorted, points); + }); + + it('handles empty point sorting', () => { + const points: { id: string; x: number; y: number }[] = []; + + assert.deepEqual(Directions.sortPointsClockwise(points, 0, 0), []); + }); }); From 24c316ea57312ecf3960af2e845868ff6bfa1282 Mon Sep 17 00:00:00 2001 From: Dowon Date: Tue, 9 Jun 2026 17:51:08 +0900 Subject: [PATCH 2/8] util: update sortPointsClockwise to return undefined for 180-degree spans --- resources/util.ts | 4 ++-- test/unittests/util_test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/resources/util.ts b/resources/util.ts index 24f36c9cca7..e242e433466 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -477,9 +477,9 @@ const sortPointsClockwise = ( if (largestGap === 0) return [...points]; - // Points fit in a semicircle if the complement of their largest gap is <= 180 degrees. + // Return undefined for 180-degree or wider spans because this helper infers its start point. const arcLength = twoPi - largestGap; - if (arcLength > Math.PI + 1e-9) + if (arcLength >= Math.PI - 1e-9) return undefined; const referenceAngle = entries[startIdx]?.angle ?? 0; diff --git a/test/unittests/util_test.ts b/test/unittests/util_test.ts index ea71f79e2e8..3f69814daef 100644 --- a/test/unittests/util_test.ts +++ b/test/unittests/util_test.ts @@ -132,6 +132,15 @@ describe('util tests', () => { assert.isUndefined(Directions.sortPointsClockwise(points, 0, 0)); }); + it('returns undefined when points are exactly opposite', () => { + const points = [ + { id: 'N', x: 0, y: -1 }, + { id: 'S', x: 0, y: 1 }, + ]; + + assert.isUndefined(Directions.sortPointsClockwise(points, 0, 0)); + }); + it('keeps points in input order when all angles are equal', () => { const points = [ { id: 'near', x: 0, y: -1 }, From 128e672e34b9353c3ac8b79a17fa87ba881d4948 Mon Sep 17 00:00:00 2001 From: Dowon Date: Tue, 9 Jun 2026 18:01:23 +0900 Subject: [PATCH 3/8] test: enhance clockwise sorting test --- test/unittests/util_test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/unittests/util_test.ts b/test/unittests/util_test.ts index 3f69814daef..47386f130fa 100644 --- a/test/unittests/util_test.ts +++ b/test/unittests/util_test.ts @@ -98,15 +98,24 @@ describe('util tests', () => { it('sorts points clockwise from a reference point', () => { const points = [ { id: 'NE', x: 1, y: -1 }, + { id: 'SW', x: -1, y: 1 }, { id: 'N', x: 0, y: -1 }, { id: 'NW', x: -1, y: -1 }, + { id: 'S', x: 0, y: 1 }, + { id: 'W', x: -1, y: 0 }, + { id: 'SE', x: 3, y: 3 }, + { id: 'E', x: 2, y: 0 }, ]; - const sorted = Directions.sortPointsClockwiseFrom(points, 0, 0, -1, -1); + const expected = ['NW', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W']; + + let [refX, refY] = [-1, -1]; // same as NW + let sorted = Directions.sortPointsClockwiseFrom(points, 0, 0, refX, refY); + assert.deepEqual(sorted.map((point) => point.id), expected); - assert.deepEqual(sorted.map((point) => point.id), ['NW', 'N', 'NE']); - assert.deepEqual(points.map((point) => point.id), ['NE', 'N', 'NW']); - assert.deepEqual(sorted, [points[2], points[1], points[0]]); + [refX, refY] = [-1.2, -0.8]; + sorted = Directions.sortPointsClockwiseFrom(points, 0, 0, refX, refY); + assert.deepEqual(sorted.map((point) => point.id), expected); }); it('sorts clustered points clockwise when contained within a semicircle', () => { From 32fa10b957ee1256954af8d87bc3fa9d4e84487b Mon Sep 17 00:00:00 2001 From: Dowon Date: Tue, 9 Jun 2026 20:16:59 +0900 Subject: [PATCH 4/8] fix: remove index from PointSortEntry and simplify sorting logic --- resources/util.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/resources/util.ts b/resources/util.ts index e242e433466..b3573ac5306 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -398,7 +398,6 @@ interface SortablePoint { type PointSortEntry = { point: T; angle: number; - index: number; }; const twoPi = 2 * Math.PI; @@ -412,13 +411,12 @@ const sortPointEntriesClockwiseFrom = ( entries: PointSortEntry[], referenceAngle: number, ): T[] => { - // Use original index as a stable tie-breaker for points on the same ray. return entries .map((entry) => ({ ...entry, delta: clockwiseAngleDelta(entry.angle, referenceAngle), })) - .sort((a, b) => a.delta - b.delta || a.index - b.index) + .sort((a, b) => a.delta - b.delta) .map((entry) => entry.point); }; @@ -431,10 +429,9 @@ const sortPointsClockwiseFrom = ( ): T[] => { // Sorts points clockwise, starting from the angle of `reference`. const referenceAngle = xyToClockwiseAngle(referenceX, referenceY, centerX, centerY); - const entries: PointSortEntry[] = points.map((point, index) => ({ + const entries: PointSortEntry[] = points.map((point) => ({ point: point, angle: xyToClockwiseAngle(point.x, point.y, centerX, centerY), - index: index, })); return sortPointEntriesClockwiseFrom(entries, referenceAngle); @@ -451,12 +448,11 @@ const sortPointsClockwise = ( return [...points]; const entries: PointSortEntry[] = points - .map((point, index) => ({ + .map((point) => ({ point: point, angle: xyToClockwiseAngle(point.x, point.y, centerX, centerY), - index: index, })) - .sort((a, b) => a.angle - b.angle || a.index - b.index); + .sort((a, b) => a.angle - b.angle); let largestGap = 0; let startIdx = 0; From 9f4d8c25c3846fb79402b7cd74215d40a4b1f304 Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 13 Jun 2026 13:50:31 +0900 Subject: [PATCH 5/8] util: merge sortPointsClockwiseFrom into sortPointsClockwise --- resources/util.ts | 46 +++++++++++++++---------------------- test/unittests/util_test.ts | 39 ++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/resources/util.ts b/resources/util.ts index b3573ac5306..7f25076cbe1 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -420,30 +420,12 @@ const sortPointEntriesClockwiseFrom = ( .map((entry) => entry.point); }; -const sortPointsClockwiseFrom = ( - points: readonly T[], - centerX: number, - centerY: number, - referenceX: number, - referenceY: number, -): T[] => { - // Sorts points clockwise, starting from the angle of `reference`. - const referenceAngle = xyToClockwiseAngle(referenceX, referenceY, centerX, centerY); - const entries: PointSortEntry[] = points.map((point) => ({ - point: point, - angle: xyToClockwiseAngle(point.x, point.y, centerX, centerY), - })); - - return sortPointEntriesClockwiseFrom(entries, referenceAngle); -}; - const sortPointsClockwise = ( points: readonly T[], centerX: number, centerY: number, -): T[] | undefined => { - // Sorts points clockwise if they fit within a semicircle around `center`. - // otherwise, returns undefined. + options: { reference?: { x: number; y: number } } = {}, +): T[] => { if (points.length <= 1) return [...points]; @@ -454,6 +436,15 @@ const sortPointsClockwise = ( })) .sort((a, b) => a.angle - b.angle); + const reference = options.reference; + if (reference !== undefined) { + const referenceAngle = reference.x === centerX && reference.y === centerY + ? 0 + : xyToClockwiseAngle(reference.x, reference.y, centerX, centerY); + return sortPointEntriesClockwiseFrom(entries, referenceAngle); + } + + const eps = 1e-9; let largestGap = 0; let startIdx = 0; for (const [idx, entry] of entries.entries()) { @@ -463,7 +454,14 @@ const sortPointsClockwise = ( continue; const gap = clockwiseAngleDelta(nextEntry.angle, entry.angle); - if (gap > largestGap) { + const startEntry = entries[startIdx]; + if (startEntry === undefined) + continue; + // If there are multiple largest gaps, start from the smallest angle. + if ( + gap > largestGap + eps || + (Math.abs(gap - largestGap) <= eps && nextEntry.angle < startEntry.angle) + ) { largestGap = gap; startIdx = nextIdx; } @@ -473,11 +471,6 @@ const sortPointsClockwise = ( if (largestGap === 0) return [...points]; - // Return undefined for 180-degree or wider spans because this helper infers its start point. - const arcLength = twoPi - largestGap; - if (arcLength >= Math.PI - 1e-9) - return undefined; - const referenceAngle = entries[startIdx]?.angle ?? 0; return sortPointEntriesClockwiseFrom(entries, referenceAngle); }; @@ -502,7 +495,6 @@ export const Directions = { outputFrom8DirNum: outputFrom8DirNum, outputFromCardinalNum: outputFromCardinalNum, sortPointsClockwise: sortPointsClockwise, - sortPointsClockwiseFrom: sortPointsClockwiseFrom, combatantStatePosTo8Dir: ( combatant: PluginCombatantState, centerX: number, diff --git a/test/unittests/util_test.ts b/test/unittests/util_test.ts index 47386f130fa..d574b8539c5 100644 --- a/test/unittests/util_test.ts +++ b/test/unittests/util_test.ts @@ -110,11 +110,15 @@ describe('util tests', () => { const expected = ['NW', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W']; let [refX, refY] = [-1, -1]; // same as NW - let sorted = Directions.sortPointsClockwiseFrom(points, 0, 0, refX, refY); + let sorted = Directions.sortPointsClockwise(points, 0, 0, { + reference: { x: refX, y: refY }, + }); assert.deepEqual(sorted.map((point) => point.id), expected); [refX, refY] = [-1.2, -0.8]; - sorted = Directions.sortPointsClockwiseFrom(points, 0, 0, refX, refY); + sorted = Directions.sortPointsClockwise(points, 0, 0, { + reference: { x: refX, y: refY }, + }); assert.deepEqual(sorted.map((point) => point.id), expected); }); @@ -127,10 +131,10 @@ describe('util tests', () => { const sorted = Directions.sortPointsClockwise(points, 0, 0); - assert.deepEqual(sorted?.map((point) => point.id), ['NW', 'N', 'NE']); + assert.deepEqual(sorted.map((point) => point.id), ['NW', 'N', 'NE']); }); - it('returns undefined when points are not contained within a semicircle', () => { + it('sorts points that are not contained within a semicircle', () => { const points = [ { id: 'N', x: 0, y: -1 }, { id: 'E', x: 1, y: 0 }, @@ -138,16 +142,35 @@ describe('util tests', () => { { id: 'W', x: -1, y: 0 }, ]; - assert.isUndefined(Directions.sortPointsClockwise(points, 0, 0)); + const sorted = Directions.sortPointsClockwise(points, 0, 0); + + assert.deepEqual(sorted.map((point) => point.id), ['N', 'E', 'S', 'W']); }); - it('returns undefined when points are exactly opposite', () => { + it('sorts points that are exactly opposite', () => { const points = [ { id: 'N', x: 0, y: -1 }, { id: 'S', x: 0, y: 1 }, ]; - assert.isUndefined(Directions.sortPointsClockwise(points, 0, 0)); + const sorted = Directions.sortPointsClockwise(points, 0, 0); + + assert.deepEqual(sorted.map((point) => point.id), ['N', 'S']); + }); + + it('sorts points clockwise from north when reference is the center', () => { + const points = [ + { id: 'E', x: 1, y: 0 }, + { id: 'S', x: 0, y: 1 }, + { id: 'N', x: 0, y: -1 }, + { id: 'W', x: -1, y: 0 }, + ]; + + const sorted = Directions.sortPointsClockwise(points, 0, 0, { + reference: { x: 0, y: 0 }, + }); + + assert.deepEqual(sorted.map((point) => point.id), ['N', 'E', 'S', 'W']); }); it('keeps points in input order when all angles are equal', () => { @@ -158,7 +181,7 @@ describe('util tests', () => { const sorted = Directions.sortPointsClockwise(points, 0, 0); - assert.deepEqual(sorted?.map((point) => point.id), ['near', 'far']); + assert.deepEqual(sorted.map((point) => point.id), ['near', 'far']); assert.notStrictEqual(sorted, points); }); From d779c79f31cd86fd629f8c0dcf5d3adcd7d9e557 Mon Sep 17 00:00:00 2001 From: Dowon Date: Mon, 22 Jun 2026 16:46:20 +0900 Subject: [PATCH 6/8] fix: rename SortablePoint, move internal helper function --- resources/util.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/util.ts b/resources/util.ts index 7f25076cbe1..cdc64ef321b 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -356,13 +356,6 @@ const xyTo4DirIntercardNum = (x: number, y: number, centerX: number, centerY: nu return Math.round(2 - 2 * ((Math.PI / 4) + Math.atan2(x, y)) / Math.PI) % 4; }; -const xyToClockwiseAngle = (x: number, y: number, centerX: number, centerY: number): number => { - // Returns a continuous angle where north is 0 and values increase clockwise. - x = x - centerX; - y = y - centerY; - return (Math.PI - Math.atan2(x, y) + 2 * Math.PI) % (2 * Math.PI); -}; - const hdgTo16DirNum = (heading: number): number => { // N = 0, NNE = 1, ..., NNW = 15 return (Math.round(8 - 8 * heading / Math.PI) % 16 + 16) % 16; @@ -390,24 +383,31 @@ const outputFromIntercardNum = (dirNum: number): DirectionOutputIntercard => { return outputIntercardDir[dirNum] ?? 'unknown'; }; -interface SortablePoint { +type Point = { x: number; y: number; -} +}; -type PointSortEntry = { +type PointSortEntry = { point: T; angle: number; }; const twoPi = 2 * Math.PI; +const xyToClockwiseAngle = (x: number, y: number, centerX: number, centerY: number): number => { + // Returns a continuous angle where north is 0 and values increase clockwise. + x = x - centerX; + y = y - centerY; + return (Math.PI - Math.atan2(x, y) + 2 * Math.PI) % (2 * Math.PI); +}; + const clockwiseAngleDelta = (toAngle: number, fromAngle: number): number => { // Normalize the wraparound case so the result is always in [0, 2π). return (toAngle - fromAngle + twoPi) % twoPi; }; -const sortPointEntriesClockwiseFrom = ( +const sortPointEntriesClockwiseFrom = ( entries: PointSortEntry[], referenceAngle: number, ): T[] => { @@ -420,7 +420,7 @@ const sortPointEntriesClockwiseFrom = ( .map((entry) => entry.point); }; -const sortPointsClockwise = ( +const sortPointsClockwise = ( points: readonly T[], centerX: number, centerY: number, From 7472fc0cb22c7ca5320264fc859b098f6dc7723e Mon Sep 17 00:00:00 2001 From: Dowon Date: Mon, 22 Jun 2026 21:10:12 +0900 Subject: [PATCH 7/8] feat: rework clockwise sort helpers --- resources/util.ts | 156 +++++++++++++++--------------------- test/unittests/util_test.ts | 113 ++++++++++++-------------- 2 files changed, 118 insertions(+), 151 deletions(-) diff --git a/resources/util.ts b/resources/util.ts index cdc64ef321b..f91d298eba9 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -264,16 +264,16 @@ const output16Dir: DirectionOutput16[] = [ const outputCardinalDir: DirectionOutputCardinal[] = ['dirN', 'dirE', 'dirS', 'dirW']; const outputIntercardDir: DirectionOutputIntercard[] = ['dirNE', 'dirSE', 'dirSW', 'dirNW']; -const compareDirectionOutput = (a: DirectionOutput16, b: DirectionOutput16): number => { - const getIndex = (n: DirectionOutput16) => { - const index = output16Dir.indexOf(n); - // Values outside of output16Dir (i.e. 'unknown') sort last - if (index < 0) - return output16Dir.length; - return index; - }; +const getDirectionIndex = (n: DirectionOutput16) => { + const index = output16Dir.indexOf(n); + // Values outside of output16Dir (i.e. 'unknown') sort last + if (index < 0) + return output16Dir.length; + return index; +}; - return getIndex(a) - getIndex(b); +const compareDirectionOutput = (a: DirectionOutput16, b: DirectionOutput16): number => { + return getDirectionIndex(a) - getDirectionIndex(b); }; const outputStrings16Dir: OutputStrings = { @@ -383,96 +383,71 @@ const outputFromIntercardNum = (dirNum: number): DirectionOutputIntercard => { return outputIntercardDir[dirNum] ?? 'unknown'; }; +export type AnyDirection = + | DirectionOutputCardinal + | DirectionOutputIntercard + | DirectionOutput8 + | DirectionOutput16; + +// Example usage: +// const dirs: DirectionOutputCardinal[] = ['dirN', 'dirW']; +// dirs.sort(getSortDirectionsClockwiseFunction('dirE')); +// `dirs` should equal `['dirW', 'dirN']` +export const getSortDirectionsClockwiseFunction = ( + from?: AnyDirection, +): (left: AnyDirection, right: AnyDirection) => number => { + // Default to dirN + let offset = 0; + if (from !== undefined) + offset = getDirectionIndex(from); + + const count = output16Dir.length + 1; // +1 for unknown + + return (left: AnyDirection, right: AnyDirection) => { + const rightIndex = (count + getDirectionIndex(right) - offset) % count; + const leftIndex = (count + getDirectionIndex(left) - offset) % count; + return leftIndex - rightIndex; + }; +}; + type Point = { x: number; y: number; }; -type PointSortEntry = { - point: T; - angle: number; -}; - -const twoPi = 2 * Math.PI; - -const xyToClockwiseAngle = (x: number, y: number, centerX: number, centerY: number): number => { - // Returns a continuous angle where north is 0 and values increase clockwise. +const xyToHeading = (x: number, y: number, centerX: number, centerY: number) => { x = x - centerX; y = y - centerY; - return (Math.PI - Math.atan2(x, y) + 2 * Math.PI) % (2 * Math.PI); + return Math.atan2(x, y); }; -const clockwiseAngleDelta = (toAngle: number, fromAngle: number): number => { - // Normalize the wraparound case so the result is always in [0, 2π). - return (toAngle - fromAngle + twoPi) % twoPi; -}; - -const sortPointEntriesClockwiseFrom = ( - entries: PointSortEntry[], - referenceAngle: number, -): T[] => { - return entries - .map((entry) => ({ - ...entry, - delta: clockwiseAngleDelta(entry.angle, referenceAngle), - })) - .sort((a, b) => a.delta - b.delta) - .map((entry) => entry.point); -}; - -const sortPointsClockwise = ( - points: readonly T[], - centerX: number, - centerY: number, - options: { reference?: { x: number; y: number } } = {}, -): T[] => { - if (points.length <= 1) - return [...points]; - - const entries: PointSortEntry[] = points - .map((point) => ({ - point: point, - angle: xyToClockwiseAngle(point.x, point.y, centerX, centerY), - })) - .sort((a, b) => a.angle - b.angle); - - const reference = options.reference; - if (reference !== undefined) { - const referenceAngle = reference.x === centerX && reference.y === centerY - ? 0 - : xyToClockwiseAngle(reference.x, reference.y, centerX, centerY); - return sortPointEntriesClockwiseFrom(entries, referenceAngle); - } - - const eps = 1e-9; - let largestGap = 0; - let startIdx = 0; - for (const [idx, entry] of entries.entries()) { - const nextIdx = (idx + 1) % entries.length; - const nextEntry = entries[nextIdx]; - if (nextEntry === undefined) - continue; - const gap = clockwiseAngleDelta(nextEntry.angle, entry.angle); - - const startEntry = entries[startIdx]; - if (startEntry === undefined) - continue; - // If there are multiple largest gaps, start from the smallest angle. - if ( - gap > largestGap + eps || - (Math.abs(gap - largestGap) <= eps && nextEntry.angle < startEntry.angle) - ) { - largestGap = gap; - startIdx = nextIdx; - } - } - - // With no angular separation, there is no clockwise ordering signal. - if (largestGap === 0) - return [...points]; - - const referenceAngle = entries[startIdx]?.angle ?? 0; - return sortPointEntriesClockwiseFrom(entries, referenceAngle); +// Example usage: +// getSortPointsClockwiseFunction +// const points = [{ x: 101, y: 101 }, { x: 99, y: 99 }]; +// points.sort(getSortPointsClockwiseFunction({x: 100, y: 100}, {x: 99, y: 101})); +// `points` should now equal `[{ x: 99, y: 99 }, { x: 101, y: 101 }]` +export const getSortPointsClockwiseFunction = ( + center: T, + reference: number | T = Math.PI, // Default to north +): (left: T, right: T) => number => { + // Convert point to heading if needed + const offset = typeof reference === 'object' + ? xyToHeading(reference.x, reference.y, center.x, center.y) + : reference; + + const twoPI = Math.PI * 2; + + return (left: T, right: T) => { + // Get our base headings for the two points + const rightHeading = xyToHeading(right.x, right.y, center.x, center.y); + const leftHeading = xyToHeading(left.x, left.y, center.x, center.y); + + // Adjust by reference offset + const rightHeadingOffset = (twoPI + (offset - rightHeading)) % twoPI; + const leftHeadingOffset = (twoPI + (offset - leftHeading)) % twoPI; + + return leftHeadingOffset - rightHeadingOffset; + }; }; export const Directions = { @@ -494,7 +469,6 @@ export const Directions = { hdgTo4DirNum: hdgTo4DirNum, outputFrom8DirNum: outputFrom8DirNum, outputFromCardinalNum: outputFromCardinalNum, - sortPointsClockwise: sortPointsClockwise, combatantStatePosTo8Dir: ( combatant: PluginCombatantState, centerX: number, diff --git a/test/unittests/util_test.ts b/test/unittests/util_test.ts index d574b8539c5..1ea625ef0c6 100644 --- a/test/unittests/util_test.ts +++ b/test/unittests/util_test.ts @@ -2,10 +2,12 @@ import { assert } from 'chai'; import Util, { allJobs, + AnyDirection, casterDpsJobs, craftingJobs, - Directions, gatheringJobs, + getSortDirectionsClockwiseFunction, + getSortPointsClockwiseFunction, healerJobs, limitedJobs, meleeDpsJobs, @@ -95,6 +97,42 @@ describe('util tests', () => { ); }); + it('sorts directions clockwise from a reference direction and undefined reference', () => { + const dirs: AnyDirection[] = [ + 'dirNE', + 'dirSW', + 'dirN', + 'dirNW', + 'dirS', + 'dirW', + 'dirSE', + 'dirE', + ]; + + let expected = ['dirNW', 'dirN', 'dirNE', 'dirE', 'dirSE', 'dirS', 'dirSW', 'dirW']; + let sorted = dirs.sort(getSortDirectionsClockwiseFunction('dirNW')); + assert.deepEqual(sorted, expected); + + expected = ['dirN', 'dirNE', 'dirE', 'dirSE', 'dirS', 'dirSW', 'dirW', 'dirNW']; + sorted = dirs.sort(getSortDirectionsClockwiseFunction()); + assert.deepEqual(sorted, expected); + }); + + it('sorts directions and unknowns, putting unknowns at the end', () => { + const dirs: AnyDirection[] = [ + 'dirNE', + 'unknown', + 'dirN', + 'unknown', + 'dirNW', + 'dirS', + ]; + + const expected = ['dirN', 'dirNE', 'dirS', 'dirNW', 'unknown', 'unknown']; + const sorted = dirs.sort(getSortDirectionsClockwiseFunction()); + assert.deepEqual(sorted, expected); + }); + it('sorts points clockwise from a reference point', () => { const points = [ { id: 'NE', x: 1, y: -1 }, @@ -110,84 +148,39 @@ describe('util tests', () => { const expected = ['NW', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W']; let [refX, refY] = [-1, -1]; // same as NW - let sorted = Directions.sortPointsClockwise(points, 0, 0, { - reference: { x: refX, y: refY }, - }); + let sorted = points.sort( + getSortPointsClockwiseFunction({ x: 0, y: 0 }, { x: refX, y: refY }), + ); assert.deepEqual(sorted.map((point) => point.id), expected); [refX, refY] = [-1.2, -0.8]; - sorted = Directions.sortPointsClockwise(points, 0, 0, { - reference: { x: refX, y: refY }, - }); + sorted = points.sort(getSortPointsClockwiseFunction({ x: 0, y: 0 }, { x: refX, y: refY })); assert.deepEqual(sorted.map((point) => point.id), expected); }); - it('sorts clustered points clockwise when contained within a semicircle', () => { + it('sorts points clockwise with numeric reference', () => { const points = [ { id: 'NE', x: 1, y: -1 }, { id: 'NW', x: -1, y: -1 }, { id: 'N', x: 0, y: -1 }, ]; - const sorted = Directions.sortPointsClockwise(points, 0, 0); + const sorted = points.sort( + getSortPointsClockwiseFunction({ x: 0, y: 0 }, 0), // reference is south + ); assert.deepEqual(sorted.map((point) => point.id), ['NW', 'N', 'NE']); }); - it('sorts points that are not contained within a semicircle', () => { - const points = [ - { id: 'N', x: 0, y: -1 }, - { id: 'E', x: 1, y: 0 }, - { id: 'S', x: 0, y: 1 }, - { id: 'W', x: -1, y: 0 }, - ]; - - const sorted = Directions.sortPointsClockwise(points, 0, 0); - - assert.deepEqual(sorted.map((point) => point.id), ['N', 'E', 'S', 'W']); - }); - - it('sorts points that are exactly opposite', () => { - const points = [ - { id: 'N', x: 0, y: -1 }, - { id: 'S', x: 0, y: 1 }, - ]; - - const sorted = Directions.sortPointsClockwise(points, 0, 0); - - assert.deepEqual(sorted.map((point) => point.id), ['N', 'S']); - }); - - it('sorts points clockwise from north when reference is the center', () => { - const points = [ - { id: 'E', x: 1, y: 0 }, - { id: 'S', x: 0, y: 1 }, - { id: 'N', x: 0, y: -1 }, - { id: 'W', x: -1, y: 0 }, - ]; - - const sorted = Directions.sortPointsClockwise(points, 0, 0, { - reference: { x: 0, y: 0 }, - }); - - assert.deepEqual(sorted.map((point) => point.id), ['N', 'E', 'S', 'W']); - }); - it('keeps points in input order when all angles are equal', () => { const points = [ - { id: 'near', x: 0, y: -1 }, - { id: 'far', x: 0, y: -2 }, + { id: '1', x: 0, y: -3 }, + { id: '2', x: 0, y: -4 }, + { id: '3', x: 0, y: -1 }, + { id: '4', x: 0, y: -2 }, ]; - const sorted = Directions.sortPointsClockwise(points, 0, 0); - - assert.deepEqual(sorted.map((point) => point.id), ['near', 'far']); - assert.notStrictEqual(sorted, points); - }); - - it('handles empty point sorting', () => { - const points: { id: string; x: number; y: number }[] = []; - - assert.deepEqual(Directions.sortPointsClockwise(points, 0, 0), []); + const sorted = points.sort(getSortPointsClockwiseFunction({ x: 0, y: 0 })); + assert.deepEqual(sorted.map((point) => point.id), ['1', '2', '3', '4']); }); }); From f806986c753e321d99e902da0c23ac42ebfdc40d Mon Sep 17 00:00:00 2001 From: Dowon Date: Tue, 23 Jun 2026 10:00:23 +0900 Subject: [PATCH 8/8] fix: enhance 'unknown' handling, tests --- resources/util.ts | 7 +++++-- test/unittests/util_test.ts | 20 +++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/resources/util.ts b/resources/util.ts index f91d298eba9..7072bb6f7b7 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -398,12 +398,15 @@ export const getSortDirectionsClockwiseFunction = ( ): (left: AnyDirection, right: AnyDirection) => number => { // Default to dirN let offset = 0; - if (from !== undefined) + if (from !== undefined && from !== 'unknown') offset = getDirectionIndex(from); - const count = output16Dir.length + 1; // +1 for unknown + const count = output16Dir.length; return (left: AnyDirection, right: AnyDirection) => { + if (left === 'unknown' || right === 'unknown') { + return left === right ? 0 : left === 'unknown' ? 1 : -1; + } const rightIndex = (count + getDirectionIndex(right) - offset) % count; const leftIndex = (count + getDirectionIndex(left) - offset) % count; return leftIndex - rightIndex; diff --git a/test/unittests/util_test.ts b/test/unittests/util_test.ts index 1ea625ef0c6..816ca76b3aa 100644 --- a/test/unittests/util_test.ts +++ b/test/unittests/util_test.ts @@ -119,7 +119,7 @@ describe('util tests', () => { }); it('sorts directions and unknowns, putting unknowns at the end', () => { - const dirs: AnyDirection[] = [ + const dirs1: AnyDirection[] = [ 'dirNE', 'unknown', 'dirN', @@ -128,13 +128,19 @@ describe('util tests', () => { 'dirS', ]; - const expected = ['dirN', 'dirNE', 'dirS', 'dirNW', 'unknown', 'unknown']; - const sorted = dirs.sort(getSortDirectionsClockwiseFunction()); - assert.deepEqual(sorted, expected); + const expected1 = ['dirN', 'dirNE', 'dirS', 'dirNW', 'unknown', 'unknown']; + assert.deepEqual(dirs1.sort(getSortDirectionsClockwiseFunction()), expected1); + + const dirs2: AnyDirection[] = ['dirNE', 'unknown', 'dirN', 'dirNW']; + const expected2 = ['dirNW', 'dirN', 'dirNE', 'unknown']; + assert.deepEqual( + dirs2.sort(getSortDirectionsClockwiseFunction('dirNW')), + expected2, + ); }); it('sorts points clockwise from a reference point', () => { - const points = [ + const getPoints = () => [ { id: 'NE', x: 1, y: -1 }, { id: 'SW', x: -1, y: 1 }, { id: 'N', x: 0, y: -1 }, @@ -148,13 +154,13 @@ describe('util tests', () => { const expected = ['NW', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W']; let [refX, refY] = [-1, -1]; // same as NW - let sorted = points.sort( + let sorted = getPoints().sort( getSortPointsClockwiseFunction({ x: 0, y: 0 }, { x: refX, y: refY }), ); assert.deepEqual(sorted.map((point) => point.id), expected); [refX, refY] = [-1.2, -0.8]; - sorted = points.sort(getSortPointsClockwiseFunction({ x: 0, y: 0 }, { x: refX, y: refY })); + sorted = getPoints().sort(getSortPointsClockwiseFunction({ x: 0, y: 0 }, { x: refX, y: refY })); assert.deepEqual(sorted.map((point) => point.id), expected); });