@@ -2,6 +2,8 @@ import { isAdjacent, pointKey, type Point } from "./domain.js";
22import type { ContributionGrid } from "./contribution-grid.js" ;
33
44const DEFAULT_SEED = 1 ;
5+ const ROUTE_VARIANT_COUNT = 3 ;
6+
57export function planSnakePath ( grid : ContributionGrid , seed = DEFAULT_SEED ) : readonly Point [ ] {
68 if ( grid . columns < 1 || grid . rows < 1 ) {
79 return [ ] ;
@@ -11,12 +13,236 @@ export function planSnakePath(grid: ContributionGrid, seed = DEFAULT_SEED): read
1113 throw new Error ( "Snake path seed must be an integer." ) ;
1214 }
1315
14- const path = buildJitteredRowSnake ( grid . columns , grid . rows , seed ) ;
16+ const routeVariant = ( seed >>> 0 ) % ROUTE_VARIANT_COUNT ;
17+ const path = buildRouteVariant ( grid . columns , grid . rows , routeVariant , seed ) ;
1518
1619 validatePath ( path , grid . columns , grid . rows ) ;
1720 return path ;
1821}
1922
23+ function buildRouteVariant ( columns : number , rows : number , variant : number , seed : number ) : Point [ ] {
24+ if ( variant === 1 ) {
25+ return buildJitteredRowSnake ( columns , rows , seed ) ;
26+ }
27+
28+ if ( variant === 2 ) {
29+ return buildSpiral ( columns , rows ) ;
30+ }
31+
32+ return buildRowSnake ( columns , rows ) ;
33+ }
34+
35+ function buildRowSnake ( columns : number , rows : number ) : Point [ ] {
36+ const path : Point [ ] = [ ] ;
37+
38+ for ( let y = 0 ; y < rows ; y += 1 ) {
39+ if ( y % 2 === 0 ) {
40+ for ( let x = 0 ; x < columns ; x += 1 ) {
41+ path . push ( { x, y } ) ;
42+ }
43+ } else {
44+ for ( let x = columns - 1 ; x >= 0 ; x -= 1 ) {
45+ path . push ( { x, y } ) ;
46+ }
47+ }
48+ }
49+
50+ return path ;
51+ }
52+
53+ function buildJitteredRowSnake ( columns : number , rows : number , seed : number ) : Point [ ] {
54+ const basePath = buildRowSnake ( columns , rows ) ;
55+
56+ if ( columns < 2 || rows < 2 ) {
57+ return basePath ;
58+ }
59+
60+ const random = createSeededRandom ( seed ) ;
61+ const path : Point [ ] = [ ] ;
62+ let baseIndex = 0 ;
63+ let horizontalRun = 0 ;
64+ let nextDetourAfter = 5 + Math . floor ( random ( ) * 6 ) ;
65+
66+ const first = basePath [ 0 ] ;
67+ if ( ! first ) {
68+ return path ;
69+ }
70+
71+ path . push ( first ) ;
72+
73+ while ( baseIndex < basePath . length - 1 ) {
74+ const current = basePath [ baseIndex ] ;
75+ const next = basePath [ baseIndex + 1 ] ;
76+
77+ if ( ! current || ! next ) {
78+ break ;
79+ }
80+
81+ const horizontal = current . y === next . y && current . x !== next . x ;
82+
83+ if ( horizontal ) {
84+ horizontalRun += 1 ;
85+
86+ if ( horizontalRun >= nextDetourAfter ) {
87+ const direction = next . x > current . x ? 1 : - 1 ;
88+ const maxSpan = direction > 0
89+ ? columns - 1 - current . x
90+ : current . x ;
91+
92+ const verticalCandidates = [ - 1 , 1 ]
93+ . flatMap ( ( verticalDirection ) =>
94+ [ 2 , 1 ]
95+ . filter ( ( depth ) => {
96+ const targetY = current . y + verticalDirection * depth ;
97+ return targetY >= 0 && targetY < rows ;
98+ } )
99+ . map ( ( depth ) => ( { verticalDirection, depth } ) )
100+ ) ;
101+
102+ const detour = verticalCandidates [ Math . floor ( random ( ) * verticalCandidates . length ) ] ;
103+
104+ if ( detour && maxSpan >= 3 ) {
105+ const span = Math . min ( 4 + Math . floor ( random ( ) * 5 ) , maxSpan ) ;
106+ const targetY = current . y + detour . verticalDirection * detour . depth ;
107+ const targetX = current . x + direction * span ;
108+
109+ for ( let step = 1 ; step <= detour . depth ; step += 1 ) {
110+ path . push ( {
111+ x : current . x ,
112+ y : current . y + detour . verticalDirection * step
113+ } ) ;
114+ }
115+
116+ for ( let step = 1 ; step <= span ; step += 1 ) {
117+ path . push ( {
118+ x : current . x + direction * step ,
119+ y : targetY
120+ } ) ;
121+ }
122+
123+ for ( let step = detour . depth - 1 ; step >= 0 ; step -= 1 ) {
124+ path . push ( {
125+ x : targetX ,
126+ y : current . y + detour . verticalDirection * step
127+ } ) ;
128+ }
129+
130+ baseIndex += span ;
131+ horizontalRun = 0 ;
132+ nextDetourAfter = 5 + Math . floor ( random ( ) * 6 ) ;
133+ continue ;
134+ }
135+ }
136+ } else {
137+ horizontalRun = 0 ;
138+ }
139+
140+ path . push ( next ) ;
141+ baseIndex += 1 ;
142+ }
143+
144+ const visited = new Set ( path . map ( pointKey ) ) ;
145+ const expectedLength = columns * rows ;
146+ let cleanupSteps = 0 ;
147+ const maxCleanupSteps = expectedLength * 2 ;
148+
149+ while ( visited . size < expectedLength ) {
150+ const current = path . at ( - 1 ) ;
151+ if ( ! current ) {
152+ break ;
153+ }
154+
155+ const adjacent = [
156+ { x : current . x + 1 , y : current . y } ,
157+ { x : current . x - 1 , y : current . y } ,
158+ { x : current . x , y : current . y + 1 } ,
159+ { x : current . x , y : current . y - 1 }
160+ ] . filter (
161+ ( point ) =>
162+ point . x >= 0 &&
163+ point . x < columns &&
164+ point . y >= 0 &&
165+ point . y < rows
166+ ) ;
167+
168+ const unvisitedAdjacent = adjacent . filter (
169+ ( point ) => ! visited . has ( pointKey ( point ) )
170+ ) ;
171+
172+ let next : Point | undefined ;
173+
174+ if ( unvisitedAdjacent . length > 0 ) {
175+ const horizontal = unvisitedAdjacent . filter ( ( point ) => point . y === current . y ) ;
176+ const candidates = horizontal . length > 0 && random ( ) < 0.75
177+ ? horizontal
178+ : unvisitedAdjacent ;
179+ next = candidates [ Math . floor ( random ( ) * candidates . length ) ] ;
180+ } else {
181+ const remaining : Point [ ] = [ ] ;
182+
183+ for ( let y = 0 ; y < rows ; y += 1 ) {
184+ for ( let x = 0 ; x < columns ; x += 1 ) {
185+ const candidate = { x, y } ;
186+ if ( ! visited . has ( pointKey ( candidate ) ) ) {
187+ remaining . push ( candidate ) ;
188+ }
189+ }
190+ }
191+
192+ const target = remaining
193+ . slice ( )
194+ . sort ( ( left , right ) => {
195+ const leftDistance =
196+ Math . abs ( left . x - current . x ) + Math . abs ( left . y - current . y ) ;
197+ const rightDistance =
198+ Math . abs ( right . x - current . x ) + Math . abs ( right . y - current . y ) ;
199+ return leftDistance - rightDistance ;
200+ } ) [ 0 ] ;
201+
202+ if ( ! target ) {
203+ break ;
204+ }
205+
206+ const horizontalSteps = adjacent . filter (
207+ ( point ) =>
208+ point . y === current . y &&
209+ Math . abs ( point . x - target . x ) < Math . abs ( current . x - target . x )
210+ ) ;
211+ const verticalSteps = adjacent . filter (
212+ ( point ) =>
213+ point . x === current . x &&
214+ Math . abs ( point . y - target . y ) < Math . abs ( current . y - target . y )
215+ ) ;
216+
217+ next = horizontalSteps [ 0 ] ?? verticalSteps [ 0 ] ;
218+ }
219+
220+ if ( ! next ) {
221+ break ;
222+ }
223+
224+ path . push ( next ) ;
225+ visited . add ( pointKey ( next ) ) ;
226+ cleanupSteps += 1 ;
227+
228+ if ( cleanupSteps > maxCleanupSteps ) {
229+ throw new Error ( "Snake path cleanup exceeded the maximum step budget." ) ;
230+ }
231+ }
232+
233+ return path ;
234+ }
235+
236+ function createSeededRandom ( seed : number ) : ( ) => number {
237+ let state = ( seed >>> 0 ) + 0x6d2b79f5 ;
238+
239+ return ( ) => {
240+ state = Math . imul ( state ^ ( state >>> 15 ) , state | 1 ) ;
241+ state ^= state + Math . imul ( state ^ ( state >>> 7 ) , state | 61 ) ;
242+ return ( ( state ^ ( state >>> 14 ) ) >>> 0 ) / 4294967296 ;
243+ } ;
244+ }
245+
20246function buildSpiral ( columns : number , rows : number ) : Point [ ] {
21247 const path : Point [ ] = [ ] ;
22248 let left = 0 ;
0 commit comments