@@ -4,7 +4,7 @@ import { allLegalMoves, legalMovesFrom, makeMove, teleportTargets } from "../rul
44
55/** Wrap a state into Portal Chess mode with given creator type. */
66function asPortal ( s : GameState , creator : "Q" | "R" | "B" | "N" | "K" = "Q" ) : GameState {
7- s . portals = { w : null , b : null } ;
7+ s . portals = { w : [ ] , b : [ ] , max : 1 } ;
88 s . portalCreators = { w : creator , b : creator } ;
99 return s ;
1010}
@@ -29,10 +29,10 @@ describe("Portal Chess: portal creation", () => {
2929 it ( "Queen drops a portal under herself on her first move" , ( ) => {
3030 let s = asPortal ( initialState ( ) ) ;
3131 s = play ( s , "d2" , "d3" ) ; // pawn move, no portal
32- expect ( s . portals ?. w ) . toBeNull ( ) ;
32+ expect ( s . portals ?. w ) . toEqual ( [ ] ) ;
3333 s = play ( s , "e7" , "e6" ) ; // black pawn
3434 s = play ( s , "d1" , "d2" ) ; // Qd2 legal
35- expect ( s . portals ?. w ) . toEqual ( { file : 3 , rank : 1 } ) ;
35+ expect ( s . portals ?. w ) . toEqual ( [ { file : 3 , rank : 1 } ] ) ;
3636 } ) ;
3737
3838 it ( "Queen does NOT drop a second portal while one is active" , ( ) => {
@@ -42,18 +42,18 @@ describe("Portal Chess: portal creation", () => {
4242 s = play ( s , "d1" , "d2" ) ; // first portal at d2
4343 s = play ( s , "e6" , "e5" ) ; // black tempo
4444 s = play ( s , "d2" , "e3" ) ; // queen moves; no new portal because one is active
45- expect ( s . portals ?. w ) . toEqual ( { file : 3 , rank : 1 } ) ; // still at d2
45+ expect ( s . portals ?. w ) . toEqual ( [ { file : 3 , rank : 1 } ] ) ; // still at d2
4646 } ) ;
4747
4848 it ( "Pawn landing on a portal does not consume it and does not teleport" , ( ) => {
4949 // Custom position: white pawn at e4, black portal at e5, black to move skipped.
5050 const s = asPortal ( parseFEN ( "8/8/8/4p3/4P3/8/8/8 w - - 0 1" ) ) ;
5151 // Manually place a black portal at e5 via state hack (no creator move yet).
52- s . portals = { w : null , b : parseSquare ( "e5" ) } ;
52+ s . portals = { w : [ ] , b : [ parseSquare ( "e5" ) ] , max : 1 } ;
5353 s . turn = "w" ;
5454 // White pawn at e4 cannot capture e5 (same file). Switch to a portal at d5
5555 // so the pawn captures into it.
56- s . portals = { w : null , b : parseSquare ( "d5" ) } ;
56+ s . portals = { w : [ ] , b : [ parseSquare ( "d5" ) ] , max : 1 } ;
5757 s . board [ 4 ] [ 3 ] = { type : "P" , color : "b" } ;
5858 const moves = legalMovesFrom ( s , parseSquare ( "e4" ) ) ;
5959 const cap = moves . filter ( ( m ) => m . to . file === 3 && m . to . rank === 4 ) ;
@@ -62,15 +62,15 @@ describe("Portal Chess: portal creation", () => {
6262 expect ( cap . every ( ( m ) => ! m . isPortalEntry ) ) . toBe ( true ) ;
6363 // The portal also persists after the pawn move.
6464 const ns = makeMove ( s , cap [ 0 ] ) ;
65- expect ( ns . portals ?. b ) . toEqual ( parseSquare ( "d5" ) ) ;
65+ expect ( ns . portals ?. b ) . toEqual ( [ parseSquare ( "d5" ) ] ) ;
6666 } ) ;
6767} ) ;
6868
6969describe ( "Portal Chess: teleport entry" , ( ) => {
7070 it ( "Knight landing on a portal is forced to teleport" , ( ) => {
7171 // Place a black portal at f3, white knight on g1, no other obstructions.
7272 const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/6N1 w - - 0 1" ) ) ;
73- s . portals = { w : null , b : parseSquare ( "f3" ) } ;
73+ s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
7474 const moves = legalMovesFrom ( s , parseSquare ( "g1" ) ) ;
7575 const fMoves = moves . filter ( ( m ) => m . to . file === 5 && m . to . rank === 2 ) ;
7676 expect ( fMoves . length ) . toBeGreaterThan ( 0 ) ;
@@ -81,10 +81,10 @@ describe("Portal Chess: teleport entry", () => {
8181 it ( "Bishop teleport is restricted to same-colour squares as the portal" , ( ) => {
8282 // Place a portal at e4 (light square). Bishop must teleport to a light square only.
8383 const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/4B3 w - - 0 1" ) ) ;
84- s . portals = { w : null , b : parseSquare ( "e4" ) } ;
84+ s . portals = { w : [ ] , b : [ parseSquare ( "e4" ) ] , max : 1 } ;
8585 // First move bishop e1 -> e4? bishops can't move file-only. Use diagonal.
8686 // Place bishop at h1 and portal at a8 (both light)? Let's pick portal at d3 (dark).
87- s . portals = { w : null , b : parseSquare ( "d3" ) } ;
87+ s . portals = { w : [ ] , b : [ parseSquare ( "d3" ) ] , max : 1 } ;
8888 s . board [ 0 ] [ 4 ] = null ;
8989 s . board [ 0 ] [ 5 ] = { type : "B" , color : "w" } ; // Bf1
9090 const moves = legalMovesFrom ( s , parseSquare ( "f1" ) ) . filter ( ( m ) => sqEq ( m . to , parseSquare ( "d3" ) ) ) ;
@@ -102,7 +102,7 @@ describe("Portal Chess: teleport entry", () => {
102102 // black king h8 (far). Adjacency rule on -> teleport target adjacent to
103103 // b3 must be excluded; far empty squares like e6 must be allowed.
104104 const s = asPortal ( parseFEN ( "7k/8/8/8/7N/1P6/8/K7 w - - 0 1" ) ) ;
105- s . portals = { w : null , b : parseSquare ( "f3" ) } ;
105+ s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
106106 s . portalAdjacencyRule = true ;
107107 const moves = legalMovesFrom ( s , parseSquare ( "h4" ) )
108108 . filter ( ( m ) => sqEq ( m . to , parseSquare ( "f3" ) ) && m . portalTo ) ;
@@ -121,7 +121,7 @@ describe("Portal Chess: teleport entry", () => {
121121 // adjacent to the e7 pawn (forbidden by adjacency rule) but f6 delivers
122122 // a knight check on e8, so the move IS legal.
123123 const s = asPortal ( parseFEN ( "4k3/4p3/7N/8/8/8/8/K7 w - - 0 1" ) ) ;
124- s . portals = { w : null , b : parseSquare ( "g4" ) } ;
124+ s . portals = { w : [ ] , b : [ parseSquare ( "g4" ) ] , max : 1 } ;
125125 s . portalAdjacencyRule = true ;
126126 const moves = legalMovesFrom ( s , parseSquare ( "h6" ) )
127127 . filter ( ( m ) => sqEq ( m . to , parseSquare ( "g4" ) ) && m . portalTo
@@ -131,7 +131,7 @@ describe("Portal Chess: teleport entry", () => {
131131
132132 it ( "With adjacency rule OFF (default), targets next to other pieces are allowed" , ( ) => {
133133 const s = asPortal ( parseFEN ( "8/8/8/8/8/1P6/8/6N1 w - - 0 1" ) ) ;
134- s . portals = { w : null , b : parseSquare ( "f3" ) } ;
134+ s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
135135 const targets = teleportTargets ( s , parseSquare ( "g1" ) , parseSquare ( "f3" ) , { type : "N" , color : "w" } ) ;
136136 // c2 is adjacent to b3; with the rule off it's permitted.
137137 expect ( targets . some ( ( t ) => sqEq ( t , parseSquare ( "c2" ) ) ) ) . toBe ( true ) ;
@@ -150,7 +150,7 @@ describe("Portal Chess: teleport entry", () => {
150150 // king exposed -> all teleport targets are illegal because moving the rook
151151 // off the a-file uncovers check.
152152 const s = asPortal ( parseFEN ( "r6k/8/8/8/8/8/R7/K7 w - - 0 1" ) ) ;
153- s . portals = { w : null , b : parseSquare ( "b2" ) } ;
153+ s . portals = { w : [ ] , b : [ parseSquare ( "b2" ) ] , max : 1 } ;
154154 const moves = legalMovesFrom ( s , parseSquare ( "a2" ) )
155155 . filter ( ( m ) => sqEq ( m . to , parseSquare ( "b2" ) ) ) ;
156156 // Any legal teleport must keep the rook on the a-file (file 0) so the
@@ -167,21 +167,21 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
167167 it ( "The creator (Queen) does not teleport when she lands on a portal" , ( ) => {
168168 // Place black portal at e4, white queen at e1, clear file.
169169 const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/4Q3 w - - 0 1" ) ) ;
170- s . portals = { w : null , b : parseSquare ( "e4" ) } ;
170+ s . portals = { w : [ ] , b : [ parseSquare ( "e4" ) ] , max : 1 } ;
171171 const moves = legalMovesFrom ( s , parseSquare ( "e1" ) ) . filter ( ( m ) => sqEq ( m . to , parseSquare ( "e4" ) ) ) ;
172172 expect ( moves . length ) . toBe ( 1 ) ;
173173 expect ( moves [ 0 ] . isPortalEntry ) . toBeFalsy ( ) ;
174174 // After the move: enemy portal STILL active (queen passed through).
175175 const ns = makeMove ( s , moves [ 0 ] ) ;
176- expect ( ns . portals ?. b ) . toEqual ( parseSquare ( "e4" ) ) ;
176+ expect ( ns . portals ?. b ) . toEqual ( [ parseSquare ( "e4" ) ] ) ;
177177 // No new white portal because there's already a portal at her landing square.
178- expect ( ns . portals ?. w ) . toBeNull ( ) ;
178+ expect ( ns . portals ?. w ) . toEqual ( [ ] ) ;
179179 } ) ;
180180
181181 it ( "Knight captures a piece on a portal then teleports" , ( ) => {
182182 // White N at g1, black portal at f3 with a black pawn sitting on f3.
183183 const s = asPortal ( parseFEN ( "8/8/8/8/8/5p2/8/6N1 w - - 0 1" ) ) ;
184- s . portals = { w : null , b : parseSquare ( "f3" ) } ;
184+ s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
185185 // Pick a non-stay teleport variant so the portal is consumed.
186186 const moves = legalMovesFrom ( s , parseSquare ( "g1" ) )
187187 . filter ( ( m ) => sqEq ( m . to , parseSquare ( "f3" ) )
@@ -191,7 +191,7 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
191191 expect ( moves [ 0 ] . isPortalEntry ) . toBe ( true ) ;
192192 const ns = makeMove ( s , moves [ 0 ] ) ;
193193 // Portal consumed.
194- expect ( ns . portals ?. b ) . toBeNull ( ) ;
194+ expect ( ns . portals ?. b ) . toEqual ( [ ] ) ;
195195 // Knight is at portalTo, NOT at f3.
196196 expect ( ns . board [ 2 ] [ 5 ] ) . toBeNull ( ) ;
197197 expect ( ns . board [ moves [ 0 ] . portalTo ! . rank ] [ moves [ 0 ] . portalTo ! . file ] ) . toEqual ( { type : "N" , color : "w" } ) ;
@@ -203,15 +203,15 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
203203
204204 it ( "Stay-in-place teleport: piece can choose portal square as target; portal stays active" , ( ) => {
205205 const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/6N1 w - - 0 1" ) ) ;
206- s . portals = { w : null , b : parseSquare ( "f3" ) } ;
206+ s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
207207 const stayMove = legalMovesFrom ( s , parseSquare ( "g1" ) )
208208 . find ( ( m ) => m . isPortalEntry && m . portalTo && sqEq ( m . portalTo , parseSquare ( "f3" ) ) ) ;
209209 expect ( stayMove ) . toBeDefined ( ) ;
210210 const ns = makeMove ( s , stayMove ! ) ;
211211 // Knight ends on f3 (the portal square).
212212 expect ( ns . board [ 2 ] [ 5 ] ) . toEqual ( { type : "N" , color : "w" } ) ;
213213 // Portal remains active.
214- expect ( ns . portals ?. b ) . toEqual ( parseSquare ( "f3" ) ) ;
214+ expect ( ns . portals ?. b ) . toEqual ( [ parseSquare ( "f3" ) ] ) ;
215215 } ) ;
216216} ) ;
217217
@@ -220,7 +220,7 @@ describe("Portal Chess: creator life", () => {
220220 // White Q at d1, black R at d8. Black plays Rxd1 (captures the white Q).
221221 const s = asPortal ( parseFEN ( "3r4/8/8/8/8/8/8/3Q4 b - - 0 1" ) ) ;
222222 const ns = play ( s , "d8" , "d1" ) ;
223- expect ( ns . portals ?. w ) . toBeNull ( ) ; // no white portal placed by capture
223+ expect ( ns . portals ?. w ) . toEqual ( [ ] ) ; // no white portal placed by capture
224224 // Confirm white has no Q left, so subsequent white moves don't drop portals.
225225 let wQ = 0 ;
226226 for ( const row of ns . board ) for ( const p of row ) if ( p ?. type === "Q" && p . color === "w" ) wQ ++ ;
@@ -235,14 +235,14 @@ describe("Portal Chess: creator life", () => {
235235 expect ( moves . length ) . toBe ( 1 ) ;
236236 const ns = makeMove ( s , moves [ 0 ] ) ;
237237 // The promoted Q just moved -> portal drops at a8.
238- expect ( ns . portals ?. w ) . toEqual ( parseSquare ( "a8" ) ) ;
238+ expect ( ns . portals ?. w ) . toEqual ( [ parseSquare ( "a8" ) ] ) ;
239239 } ) ;
240240} ) ;
241241
242242describe ( "Portal Chess: legal-move enumeration" , ( ) => {
243243 it ( "allLegalMoves expands portal entries into per-target moves" , ( ) => {
244244 const s = asPortal ( parseFEN ( "8/8/8/8/8/8/8/6N1 w - - 0 1" ) ) ;
245- s . portals = { w : null , b : parseSquare ( "f3" ) } ;
245+ s . portals = { w : [ ] , b : [ parseSquare ( "f3" ) ] , max : 1 } ;
246246 const moves = allLegalMoves ( s ) ;
247247 const onPortal = moves . filter ( ( m ) => sqEq ( m . to , parseSquare ( "f3" ) ) ) ;
248248 // Multiple teleport targets => multiple legal moves landing on f3.
@@ -252,7 +252,7 @@ describe("Portal Chess: legal-move enumeration", () => {
252252
253253 it ( "Pawn landing on a portal is a single regular move (no teleport variants)" , ( ) => {
254254 const s = asPortal ( parseFEN ( "8/8/8/8/3p4/2P5/8/8 w - - 0 1" ) ) ;
255- s . portals = { w : null , b : parseSquare ( "d4" ) } ;
255+ s . portals = { w : [ ] , b : [ parseSquare ( "d4" ) ] , max : 1 } ;
256256 const moves = legalMovesFrom ( s , parseSquare ( "c3" ) ) . filter ( ( m ) => sqEq ( m . to , parseSquare ( "d4" ) ) ) ;
257257 expect ( moves . length ) . toBe ( 1 ) ;
258258 expect ( moves [ 0 ] . isPortalEntry ) . toBeFalsy ( ) ;
@@ -264,8 +264,10 @@ describe("Portal Chess: position keys differ by portal location", () => {
264264 it ( "Same board with different portal positions yields different keys" , ( ) => {
265265 const s1 = asPortal ( initialState ( ) ) ;
266266 const s2 = asPortal ( initialState ( ) ) ;
267- s1 . portals = { w : parseSquare ( "d4" ) , b : null } ;
268- s2 . portals = { w : parseSquare ( "e4" ) , b : null } ;
267+ s1 . portals = { w : [ parseSquare ( "d4" ) ] , b : [ ] , max : 1 } ;
268+ s2 . portals = { w : [ parseSquare ( "e4" ) ] , b : [ ] , max : 1 } ;
269269 expect ( positionKey ( s1 ) ) . not . toBe ( positionKey ( s2 ) ) ;
270270 } ) ;
271271} ) ;
272+
273+
0 commit comments