@@ -147,6 +147,86 @@ function isLight(s: string): boolean {
147147 return ( sq . file + sq . rank ) % 2 === 1 ;
148148}
149149
150+ const GENERIC_THEMES = new Set ( [ "portal" , "queen" , "rook" , "bishop" , "knight" , "mateIn1" , "mateIn2" ] ) ;
151+
152+ function candidateKey ( c : RawCandidate ) : string {
153+ return `${ c . fen } |${ c . wPortal ?? "-" } |${ c . bPortal ?? "-" } |${ c . moves . join ( "," ) } ` ;
154+ }
155+
156+ function motifOf ( c : RawCandidate ) : string {
157+ return c . themes . find ( ( t ) => ! GENERIC_THEMES . has ( t ) ) ?? "misc" ;
158+ }
159+
160+ function spreadPick < T > ( items : T [ ] , count : number ) : T [ ] {
161+ if ( count <= 0 || items . length === 0 ) return [ ] ;
162+ if ( items . length <= count ) return items . slice ( ) ;
163+ if ( count === 1 ) return [ items [ Math . floor ( items . length / 2 ) ] ] ;
164+ const out : T [ ] = [ ] ;
165+ for ( let i = 0 ; i < count ; i ++ ) {
166+ const idx = Math . round ( ( i * ( items . length - 1 ) ) / ( count - 1 ) ) ;
167+ out . push ( items [ idx ] ) ;
168+ }
169+ return out ;
170+ }
171+
172+ function curateCandidates ( valid : RawCandidate [ ] , targetCount = 20 ) : RawCandidate [ ] {
173+ if ( valid . length <= targetCount ) return valid . slice ( ) ;
174+
175+ const quotas : Record < string , number > = {
176+ captureThenWarp : 5 ,
177+ anastasia : 4 ,
178+ "smothered-h6" : 2 ,
179+ "smothered-a6" : 2 ,
180+ rookLiftWhite : 4 ,
181+ rookLiftBlack : 3 ,
182+ } ;
183+
184+ const byMotif = new Map < string , RawCandidate [ ] > ( ) ;
185+ for ( const c of valid ) {
186+ const motif = motifOf ( c ) ;
187+ const arr = byMotif . get ( motif ) ?? [ ] ;
188+ arr . push ( c ) ;
189+ byMotif . set ( motif , arr ) ;
190+ }
191+ for ( const arr of byMotif . values ( ) ) {
192+ arr . sort ( ( a , b ) => candidateKey ( a ) . localeCompare ( candidateKey ( b ) ) ) ;
193+ }
194+
195+ const picked : RawCandidate [ ] = [ ] ;
196+ const used = new Set < string > ( ) ;
197+ const take = ( arr : RawCandidate [ ] , n : number ) => {
198+ for ( const c of spreadPick ( arr , n ) ) {
199+ const key = candidateKey ( c ) ;
200+ if ( used . has ( key ) ) continue ;
201+ used . add ( key ) ;
202+ picked . push ( c ) ;
203+ }
204+ } ;
205+
206+ for ( const [ motif , quota ] of Object . entries ( quotas ) ) {
207+ const arr = byMotif . get ( motif ) ;
208+ if ( ! arr || arr . length === 0 || quota <= 0 ) continue ;
209+ take ( arr , Math . min ( quota , arr . length ) ) ;
210+ }
211+
212+ if ( picked . length < targetCount ) {
213+ const rest = valid . slice ( ) . sort ( ( a , b ) => {
214+ const motifCmp = motifOf ( a ) . localeCompare ( motifOf ( b ) ) ;
215+ if ( motifCmp !== 0 ) return motifCmp ;
216+ return candidateKey ( a ) . localeCompare ( candidateKey ( b ) ) ;
217+ } ) ;
218+ for ( const c of rest ) {
219+ if ( picked . length >= targetCount ) break ;
220+ const key = candidateKey ( c ) ;
221+ if ( used . has ( key ) ) continue ;
222+ used . add ( key ) ;
223+ picked . push ( c ) ;
224+ }
225+ }
226+
227+ return picked . slice ( 0 , targetCount ) ;
228+ }
229+
150230// ── pattern generators ──────────────────────────────────────────────────────
151231
152232/**
@@ -365,6 +445,72 @@ function genCaptureThenWarp(): RawCandidate[] {
365445 return out ;
366446}
367447
448+ /**
449+ * Pattern E — "Rook lift" (white to move).
450+ *
451+ * Black king is boxed on g8 by pawns f7/g7/h7. White rook stands on its own
452+ * portal and teleports to e8 for immediate back-rank mate.
453+ */
454+ function genRookLiftWhite ( ) : RawCandidate [ ] {
455+ const out : RawCandidate [ ] = [ ] ;
456+ const fixed : Record < string , string > = {
457+ "g8" : "k" ,
458+ "f7" : "p" ,
459+ "g7" : "p" ,
460+ "h7" : "p" ,
461+ "a1" : "K" ,
462+ } ;
463+ const rookPortals = [
464+ "b1" , "c1" , "d1" , "e1" , "f1" , "h1" ,
465+ "a2" , "b2" , "c2" , "d2" , "e2" , "f2" , "g2" , "h2" ,
466+ ] ;
467+ for ( const portal of rookPortals ) {
468+ const pieces = { ...fixed , [ portal ] : "R" } ;
469+ const fen = `${ fenPlacement ( pieces ) } w - -` ;
470+ out . push ( {
471+ fen,
472+ wPortal : portal ,
473+ bPortal : null ,
474+ moves : [ `${ portal } e8` ] ,
475+ themes : [ "portal" , "rook" , "rookLiftWhite" , "mateIn1" ] ,
476+ } ) ;
477+ }
478+ return out ;
479+ }
480+
481+ /**
482+ * Pattern F — "Rook lift" mirror (black to move).
483+ *
484+ * White king is boxed on g1 by pawns f2/g2/h2. Black rook stands on its own
485+ * portal and teleports to e1 for immediate back-rank mate.
486+ */
487+ function genRookLiftBlack ( ) : RawCandidate [ ] {
488+ const out : RawCandidate [ ] = [ ] ;
489+ const fixed : Record < string , string > = {
490+ "a8" : "k" ,
491+ "g1" : "K" ,
492+ "f2" : "P" ,
493+ "g2" : "P" ,
494+ "h2" : "P" ,
495+ } ;
496+ const rookPortals = [
497+ "b8" , "c8" , "d8" , "e8" , "f8" , "h8" ,
498+ "a7" , "b7" , "c7" , "d7" , "e7" , "f7" , "g7" , "h7" ,
499+ ] ;
500+ for ( const portal of rookPortals ) {
501+ const pieces = { ...fixed , [ portal ] : "r" } ;
502+ const fen = `${ fenPlacement ( pieces ) } b - -` ;
503+ out . push ( {
504+ fen,
505+ wPortal : null ,
506+ bPortal : portal ,
507+ moves : [ `${ portal } e1` ] ,
508+ themes : [ "portal" , "rook" , "rookLiftBlack" , "mateIn1" ] ,
509+ } ) ;
510+ }
511+ return out ;
512+ }
513+
368514// ── build ───────────────────────────────────────────────────────────────────
369515
370516function build ( ) : PortalPuzzleRow [ ] {
@@ -375,11 +521,22 @@ function build(): PortalPuzzleRow[] {
375521 ...genKnightSmotherG ( ) ,
376522 ...genKnightSmotherB ( ) ,
377523 ...genCaptureThenWarp ( ) ,
524+ ...genRookLiftWhite ( ) ,
525+ ...genRookLiftBlack ( ) ,
378526 ] ;
379- const valid : PortalPuzzleRow [ ] = [ ] ;
380- let n = 1 ;
527+
528+ const validCandidates : RawCandidate [ ] = [ ] ;
381529 for ( const c of candidates ) {
382530 if ( ! validate ( c ) ) continue ;
531+ validCandidates . push ( c ) ;
532+ }
533+
534+ // Keep the portal set intentionally compact and varied.
535+ const curated = curateCandidates ( validCandidates , 20 ) ;
536+
537+ const valid : PortalPuzzleRow [ ] = [ ] ;
538+ let n = 1 ;
539+ for ( const c of curated ) {
383540 valid . push ( {
384541 id : `PP${ String ( n ) . padStart ( 3 , "0" ) } ` ,
385542 fen : c . fen ,
0 commit comments