@@ -67,98 +67,68 @@ describe("Portal Chess: portal creation", () => {
6767} ) ;
6868
6969describe ( "Portal Chess: teleport entry" , ( ) => {
70- it ( "Knight landing on a portal is forced to teleport" , ( ) => {
71- // Place a black portal at f3, white knight on g1, no other obstructions.
72- const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/6N1 w - - 0 1" ) ) ;
73- s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
74- const moves = legalMovesFrom ( s , parseSquare ( "g1" ) ) ;
75- const fMoves = moves . filter ( ( m ) => m . to . file === 5 && m . to . rank === 2 ) ;
76- expect ( fMoves . length ) . toBeGreaterThan ( 0 ) ;
77- // All these must be portal entries.
78- expect ( fMoves . every ( ( m ) => m . isPortalEntry && m . portalTo ) ) . toBe ( true ) ;
79- } ) ;
80-
81- it ( "Bishop teleport is restricted to same-colour squares as the portal" , ( ) => {
82- // Place a portal at e4 (light square). Bishop must teleport to a light square only.
83- const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/4B3 w - - 0 1" ) ) ;
84- s . portals = { w : [ ] , b : [ parseSquare ( "e4" ) ] , max : 1 } ;
85- // First move bishop e1 -> e4? bishops can't move file-only. Use diagonal.
86- // Place bishop at h1 and portal at a8 (both light)? Let's pick portal at d3 (dark).
87- s . portals = { w : [ ] , b : [ parseSquare ( "d3" ) ] , max : 1 } ;
88- s . board [ 0 ] [ 4 ] = null ;
89- s . board [ 0 ] [ 5 ] = { type : "B" , color : "w" } ; // Bf1
90- const moves = legalMovesFrom ( s , parseSquare ( "f1" ) ) . filter ( ( m ) => sqEq ( m . to , parseSquare ( "d3" ) ) ) ;
70+ it ( "Knight standing on its own portal has teleport options on next move" , ( ) => {
71+ const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/8 w - - 0 1" ) ) ;
72+ s . board [ 2 ] [ 5 ] = { type : "N" , color : "w" } ; // Nf3
73+ s . portals = { w : [ parseSquare ( "f3" ) ] , b : [ ] , max : 1 } ;
74+ const moves = legalMovesFrom ( s , parseSquare ( "f3" ) ) ;
75+ const tele = moves . filter ( ( m ) => m . isPortalEntry ) ;
76+ expect ( tele . length ) . toBeGreaterThan ( 0 ) ;
77+ expect ( tele . every ( ( m ) => m . portalTo && sqEq ( m . portalTo , m . to ) ) ) . toBe ( true ) ;
78+ } ) ;
79+
80+ it ( "Bishop on its own portal teleports only to same-colour squares" , ( ) => {
81+ const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/8 w - - 0 1" ) ) ;
82+ s . board [ 2 ] [ 3 ] = { type : "B" , color : "w" } ; // Bd3
83+ s . portals = { w : [ parseSquare ( "d3" ) ] , b : [ ] , max : 1 } ;
84+ const moves = legalMovesFrom ( s , parseSquare ( "d3" ) ) . filter ( ( m ) => m . isPortalEntry ) ;
9185 expect ( moves . length ) . toBeGreaterThan ( 0 ) ;
92- // d3 is a light square (( 3+2)%2=1 -> light). All teleport targets must be light .
86+ // d3 is light: ( 3+2)%2=1.
9387 for ( const m of moves ) {
94- expect ( m . portalTo ) . toBeDefined ( ) ;
95- const t = m . portalTo ! ;
96- expect ( ( t . file + t . rank ) % 2 ) . toBe ( 1 ) ; // light
88+ expect ( ( m . to . file + m . to . rank ) % 2 ) . toBe ( 1 ) ;
9789 }
9890 } ) ;
9991
10092 it ( "Teleport adjacency rule rejects targets next to any piece (when enabled)" , ( ) => {
101- // White king a1, white knight h4 (can reach portal at f3), pawn at b3,
102- // black king h8 (far). Adjacency rule on -> teleport target adjacent to
103- // b3 must be excluded; far empty squares like e6 must be allowed.
104- const s = asPortal ( parseFEN ( "7k/8/8/8/7N/1P6/8/K7 w - - 0 1" ) ) ;
105- s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
93+ const s = asPortal ( parseFEN ( "7k/8/8/8/8/1P6/8/K7 w - - 0 1" ) ) ;
94+ s . board [ 2 ] [ 5 ] = { type : "N" , color : "w" } ; // Nf3 on its own portal
95+ s . portals = { w : [ parseSquare ( "f3" ) ] , b : [ ] , max : 1 } ;
10696 s . portalAdjacencyRule = true ;
107- const moves = legalMovesFrom ( s , parseSquare ( "h4" ) )
108- . filter ( ( m ) => sqEq ( m . to , parseSquare ( "f3" ) ) && m . portalTo ) ;
109- const targets = moves . map ( ( m ) => m . portalTo ! ) ;
110- // Squares adjacent to b3 (and not to any other piece) -> excluded.
97+ const moves = legalMovesFrom ( s , parseSquare ( "f3" ) ) . filter ( ( m ) => m . isPortalEntry ) ;
98+ const targets = moves . map ( ( m ) => m . to ) ;
11199 for ( const name of [ "a2" , "a4" , "b2" , "b4" , "c2" , "c4" ] ) {
112100 expect ( targets . some ( ( t ) => sqEq ( t , parseSquare ( name ) ) ) ) . toBe ( false ) ;
113101 }
114- // A far empty square (no adjacent piece) is allowed.
115102 expect ( targets . some ( ( t ) => sqEq ( t , parseSquare ( "e6" ) ) ) ) . toBe ( true ) ;
116103 } ) ;
117104
118105 it ( "Adjacency rule is bypassed when the teleport delivers check" , ( ) => {
119- // Black king e8, black pawn e7, white knight h6, portal at g4,
120- // white king a1. Knight Nh6-g4 lands on portal; teleport target f6 is
121- // adjacent to the e7 pawn (forbidden by adjacency rule) but f6 delivers
122- // a knight check on e8, so the move IS legal.
123- const s = asPortal ( parseFEN ( "4k3/4p3/7N/8/8/8/8/K7 w - - 0 1" ) ) ;
124- s . portals = { w : [ ] , b : [ parseSquare ( "g4" ) ] , max : 1 } ;
106+ const s = asPortal ( parseFEN ( "4k3/4p3/8/8/8/8/8/K7 w - - 0 1" ) ) ;
107+ s . board [ 3 ] [ 6 ] = { type : "N" , color : "w" } ; // Ng4 on its own portal
108+ s . portals = { w : [ parseSquare ( "g4" ) ] , b : [ ] , max : 1 } ;
125109 s . portalAdjacencyRule = true ;
126- const moves = legalMovesFrom ( s , parseSquare ( "h6" ) )
127- . filter ( ( m ) => sqEq ( m . to , parseSquare ( "g4" ) ) && m . portalTo
128- && sqEq ( m . portalTo , parseSquare ( "f6" ) ) ) ;
110+ const moves = legalMovesFrom ( s , parseSquare ( "g4" ) )
111+ . filter ( ( m ) => m . isPortalEntry && sqEq ( m . to , parseSquare ( "f6" ) ) ) ;
129112 expect ( moves . length ) . toBe ( 1 ) ;
130113 } ) ;
131114
132115 it ( "With adjacency rule OFF (default), targets next to other pieces are allowed" , ( ) => {
133- const s = asPortal ( parseFEN ( "8/8/8/8/8/1P6/8/6N1 w - - 0 1" ) ) ;
134- s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
135- const targets = teleportTargets ( s , parseSquare ( "g1" ) , parseSquare ( "f3" ) , { type : "N" , color : "w" } ) ;
136- // c2 is adjacent to b3; with the rule off it's permitted.
116+ const s = asPortal ( parseFEN ( "8/8/8/8/8/1P6/8/8 w - - 0 1" ) ) ;
117+ s . board [ 2 ] [ 5 ] = { type : "N" , color : "w" } ; // Nf3 on its own portal
118+ s . portals = { w : [ parseSquare ( "f3" ) ] , b : [ ] , max : 1 } ;
119+ const targets = teleportTargets ( s , parseSquare ( "f3" ) , parseSquare ( "f3" ) , { type : "N" , color : "w" } ) ;
137120 expect ( targets . some ( ( t ) => sqEq ( t , parseSquare ( "c2" ) ) ) ) . toBe ( true ) ;
138121 } ) ;
139122
140123 it ( "Teleport that leaves own king in check is illegal" , ( ) => {
141- // White king on e1, white knight on g1, black rook on e8 pinning along e-file.
142- // Portal at f3 lets the knight teleport, but moving the knight to a non-e-file
143- // square is fine — the pin doesn't apply because the knight isn't blocking.
144- // Construct a real pin: White K e1, white N e2 (blocks check from black R e8),
145- // portal at f3 (offered by enemy). Knight Ne2 -> ... wait, Ne2 cannot reach f3
146- // in one move. Use a rook scenario.
147- // Easier: White K a1, white R a2 (only piece blocking black R a8). White R
148- // moving anywhere off the a-file would expose the king. Add portal at b2.
149- // Rook can move a2->b2 (which lands on portal). Teleport must not leave the
150- // king exposed -> all teleport targets are illegal because moving the rook
151- // off the a-file uncovers check.
152- const s = asPortal ( parseFEN ( "r6k/8/8/8/8/8/R7/K7 w - - 0 1" ) ) ;
153- s . portals = { w : [ ] , b : [ parseSquare ( "b2" ) ] , max : 1 } ;
154- const moves = legalMovesFrom ( s , parseSquare ( "a2" ) )
155- . filter ( ( m ) => sqEq ( m . to , parseSquare ( "b2" ) ) ) ;
156- // Any legal teleport must keep the rook on the a-file (file 0) so the
157- // black rook on a8 is still blocked from giving check.
124+ // Black rook a8 pins the white rook against the king on a1.
125+ const s = asPortal ( parseFEN ( "r6k/8/8/8/8/8/8/K7 w - - 0 1" ) ) ;
126+ s . board [ 1 ] [ 0 ] = { type : "R" , color : "w" } ; // Ra2 on its own portal
127+ s . portals = { w : [ parseSquare ( "a2" ) ] , b : [ ] , max : 1 } ;
128+ const moves = legalMovesFrom ( s , parseSquare ( "a2" ) ) . filter ( ( m ) => m . isPortalEntry ) ;
158129 expect ( moves . length ) . toBeGreaterThan ( 0 ) ;
159130 for ( const m of moves ) {
160- expect ( m . portalTo ) . toBeDefined ( ) ;
161- expect ( m . portalTo ! . file ) . toBe ( 0 ) ;
131+ expect ( m . to . file ) . toBe ( 0 ) ; // must stay on the a-file
162132 }
163133 } ) ;
164134} ) ;
@@ -178,40 +148,25 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
178148 expect ( ns . portals ?. w ) . toEqual ( [ ] ) ;
179149 } ) ;
180150
181- it ( "Knight captures a piece on a portal then teleports" , ( ) => {
182- // White N at g1, black portal at f3 with a black pawn sitting on f3.
151+ it ( "Knight capturing a piece on its own portal lands normally and may teleport later" , ( ) => {
183152 const s = asPortal ( parseFEN ( "8/8/8/8/8/5p2/8/6N1 w - - 0 1" ) ) ;
184- s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
185- // Pick a non-stay teleport variant so the portal is consumed.
186- const moves = legalMovesFrom ( s , parseSquare ( "g1" ) )
187- . filter ( ( m ) => sqEq ( m . to , parseSquare ( "f3" ) )
188- && m . portalTo && ! sqEq ( m . portalTo , parseSquare ( "f3" ) ) ) ;
189- expect ( moves . length ) . toBeGreaterThan ( 0 ) ;
190- expect ( moves [ 0 ] . captured ) . toBe ( "P" ) ;
191- expect ( moves [ 0 ] . isPortalEntry ) . toBe ( true ) ;
192- const ns = makeMove ( s , moves [ 0 ] ) ;
193- // Portal consumed.
194- expect ( ns . portals ?. b ) . toEqual ( [ ] ) ;
195- // Knight is at portalTo, NOT at f3.
196- expect ( ns . board [ 2 ] [ 5 ] ) . toBeNull ( ) ;
197- expect ( ns . board [ moves [ 0 ] . portalTo ! . rank ] [ moves [ 0 ] . portalTo ! . file ] ) . toEqual ( { type : "N" , color : "w" } ) ;
198- // The captured pawn is gone.
199- let pawnCount = 0 ;
200- for ( const row of ns . board ) for ( const p of row ) if ( p ?. type === "P" && p . color === "b" ) pawnCount ++ ;
201- expect ( pawnCount ) . toBe ( 0 ) ;
153+ s . portals = { w : [ parseSquare ( "f3" ) ] , b : [ ] , max : 1 } ;
154+ const cap = legalMovesFrom ( s , parseSquare ( "g1" ) ) . filter ( ( m ) => sqEq ( m . to , parseSquare ( "f3" ) ) ) ;
155+ expect ( cap . length ) . toBe ( 1 ) ;
156+ expect ( cap [ 0 ] . isPortalEntry ) . toBeFalsy ( ) ;
157+ expect ( cap [ 0 ] . captured ) . toBe ( "P" ) ;
158+ const ns = makeMove ( s , cap [ 0 ] ) ;
159+ expect ( ns . board [ 2 ] [ 5 ] ) . toEqual ( { type : "N" , color : "w" } ) ;
160+ expect ( ns . portals ?. w ) . toEqual ( [ parseSquare ( "f3" ) ] ) ;
202161 } ) ;
203162
204- it ( "Stay-in-place teleport: piece can choose portal square as target; portal stays active" , ( ) => {
205- const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/6N1 w - - 0 1" ) ) ;
206- s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
207- const stayMove = legalMovesFrom ( s , parseSquare ( "g1" ) )
208- . find ( ( m ) => m . isPortalEntry && m . portalTo && sqEq ( m . portalTo , parseSquare ( "f3" ) ) ) ;
209- expect ( stayMove ) . toBeDefined ( ) ;
210- const ns = makeMove ( s , stayMove ! ) ;
211- // Knight ends on f3 (the portal square).
212- expect ( ns . board [ 2 ] [ 5 ] ) . toEqual ( { type : "N" , color : "w" } ) ;
213- // Portal remains active.
214- expect ( ns . portals ?. b ) . toEqual ( [ parseSquare ( "f3" ) ] ) ;
163+ it ( "Piece on portal cannot teleport to its own portal square (must move off)" , ( ) => {
164+ const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/8 w - - 0 1" ) ) ;
165+ s . board [ 2 ] [ 5 ] = { type : "N" , color : "w" } ;
166+ s . portals = { w : [ parseSquare ( "f3" ) ] , b : [ ] , max : 1 } ;
167+ const stay = legalMovesFrom ( s , parseSquare ( "f3" ) )
168+ . find ( ( m ) => m . isPortalEntry && sqEq ( m . to , parseSquare ( "f3" ) ) ) ;
169+ expect ( stay ) . toBeUndefined ( ) ;
215170 } ) ;
216171} ) ;
217172
@@ -240,14 +195,14 @@ describe("Portal Chess: creator life", () => {
240195} ) ;
241196
242197describe ( "Portal Chess: legal-move enumeration" , ( ) => {
243- it ( "allLegalMoves expands portal entries into per-target moves" , ( ) => {
244- const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/6N1 w - - 0 1" ) ) ;
245- s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
198+ it ( "allLegalMoves includes per-target teleport moves from a piece on its portal" , ( ) => {
199+ const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/8 w - - 0 1" ) ) ;
200+ s . board [ 2 ] [ 5 ] = { type : "N" , color : "w" } ;
201+ s . portals = { w : [ parseSquare ( "f3" ) ] , b : [ ] , max : 1 } ;
246202 const moves = allLegalMoves ( s ) ;
247- const onPortal = moves . filter ( ( m ) => sqEq ( m . to , parseSquare ( "f3" ) ) ) ;
248- // Multiple teleport targets => multiple legal moves landing on f3.
249- expect ( onPortal . length ) . toBeGreaterThan ( 1 ) ;
250- expect ( onPortal . every ( ( m ) => m . isPortalEntry && m . portalTo ) ) . toBe ( true ) ;
203+ const tele = moves . filter ( ( m ) => m . isPortalEntry && sqEq ( m . from , parseSquare ( "f3" ) ) ) ;
204+ expect ( tele . length ) . toBeGreaterThan ( 1 ) ;
205+ expect ( tele . every ( ( m ) => m . portalTo ) ) . toBe ( true ) ;
251206 } ) ;
252207
253208 it ( "Pawn landing on a portal is a single regular move (no teleport variants)" , ( ) => {
0 commit comments