22 createContext , ReactNode , useCallback , useContext , useEffect , useMemo , useReducer , useRef , useState
33} from "react" ;
44import {
5- Color , GameState , Move , PieceType , Square , initialState , pieceAt
5+ Color , CustomPieceDef , GameState , Move , PieceType , Square , cloneState , initialState , pieceAt , positionKey
66} from "./engine/board" ;
77import {
88 forfeitMove , gameResult , inCheck , legalMovesFrom , makeMove
@@ -18,7 +18,7 @@ import {
1818} from "./engine/performance" ;
1919import { playSound } from "./engine/sound" ;
2020import {
21- load , Profile , recordResult , saveActiveGame , Settings , Store , updateSettings ,
21+ load , Profile , recordResult , saveActiveGame , SavedBoardLayout , SavedCustomGame , Settings , Store , updateSettings ,
2222 createProfile , deleteProfile , renameProfile ,
2323 loadActiveSession , saveActiveSession , clearActiveSession ,
2424 recordPuzzleSolved as storeRecordPuzzleSolved ,
@@ -28,7 +28,8 @@ import {
2828type Mode =
2929 | { kind : "two-player" }
3030 | { kind : "bot" ; level : number }
31- | { kind : "portal" ; opponent : "two-player" | { kind : "bot" ; level : number } ; creator : PieceType ; portalMax ?: number } ;
31+ | { kind : "portal" ; opponent : "two-player" | { kind : "bot" ; level : number } ; creator : PieceType ; portalMax ?: number }
32+ | { kind : "custom" ; customPiece ?: CustomPieceDef ; opponent : "two-player" | { kind : "bot" ; level : number } } ;
3233export interface Players { w : string ; b : string ; }
3334
3435interface RatedMoveFeedback extends MoveFeedback {
@@ -65,6 +66,8 @@ interface PlayerGamePerformance extends GamePerformanceSummary {
6566
6667interface NewGameOptions {
6768 timerSeconds ?: number ;
69+ boardLayout ?: SavedBoardLayout | null ;
70+ customGame ?: SavedCustomGame | null ;
6871}
6972
7073/** Build the initial state for Portal Chess (creator-type portals). */
@@ -76,6 +79,72 @@ function portalInitialState(creator: PieceType, portalMax = 2): GameState {
7679 return s ;
7780}
7881
82+ /** Build an initial state from a saved board layout or custom game (white mirrored to black). */
83+ function customBoardState ( layout : SavedBoardLayout | SavedCustomGame ) : GameState {
84+ const empty : ( import ( "./engine/board" ) . Piece | null ) [ ] [ ] =
85+ Array . from ( { length : 8 } , ( ) => Array ( 8 ) . fill ( null ) ) ;
86+ const customPieces = "customPieces" in layout && Array . isArray ( layout . customPieces )
87+ ? Object . fromEntries ( layout . customPieces . map ( ( piece ) => [ piece . id , piece . def ] ) )
88+ : undefined ;
89+ const legacyCustomId = "legacy-x1" ;
90+ for ( const square of layout . squares ) {
91+ const { rank, file, type } = square ;
92+ if ( rank < 0 || rank > 3 || file < 0 || file > 7 ) continue ;
93+ const customId =
94+ type === "X1"
95+ ? ( ( "customPieceId" in square && square . customPieceId ) || ( ( "customPieceDef" in layout && layout . customPieceDef ) ? legacyCustomId : undefined ) )
96+ : undefined ;
97+ empty [ rank ] [ file ] = { type, color : "w" , customId } ;
98+ empty [ 7 - rank ] [ file ] = { type, color : "b" , customId } ;
99+ }
100+ const wKok = empty [ 0 ] [ 4 ] ?. type === "K" && empty [ 0 ] [ 4 ] ?. color === "w" ;
101+ const bKok = empty [ 7 ] [ 4 ] ?. type === "K" && empty [ 7 ] [ 4 ] ?. color === "b" ;
102+ const state : GameState = {
103+ board : empty ,
104+ turn : "w" ,
105+ castling : {
106+ wK : wKok && empty [ 0 ] [ 7 ] ?. type === "R" ,
107+ wQ : wKok && empty [ 0 ] [ 0 ] ?. type === "R" ,
108+ bK : bKok && empty [ 7 ] [ 7 ] ?. type === "R" ,
109+ bQ : bKok && empty [ 7 ] [ 0 ] ?. type === "R"
110+ } ,
111+ enPassant : null ,
112+ halfmove : 0 ,
113+ fullmove : 1 ,
114+ history : [ ] ,
115+ forfeits : [ ] ,
116+ positionKeys : [ ]
117+ } ;
118+ if ( customPieces && Object . keys ( customPieces ) . length > 0 ) {
119+ state . customPieces = customPieces ;
120+ } else if ( "customPieceDef" in layout && layout . customPieceDef ) {
121+ state . customPiece = layout . customPieceDef ;
122+ state . customPieces = { [ legacyCustomId ] : layout . customPieceDef } ;
123+ }
124+ state . positionKeys . push ( positionKey ( state ) ) ;
125+ return state ;
126+ }
127+
128+ /** Swap all instances of `replaces` on the board to X1 and attach the def. */
129+ function withCustomPiece ( state : GameState , def : CustomPieceDef , replaces : PieceType ) : GameState {
130+ const ns = cloneState ( state ) ;
131+ const legacyCustomId = "legacy - x1 ";
132+ ns . customPiece = def ;
133+ ns . customPieces = { [ legacyCustomId ] : def } ;
134+ ns . replaces = replaces ;
135+ for ( let r = 0 ; r < 8 ; r ++ ) {
136+ for ( let f = 0 ; f < 8 ; f ++ ) {
137+ const p = ns . board [ r ] [ f ] ;
138+ if ( p && p . type === replaces ) {
139+ ns . board [ r ] [ f ] = { type : "X1" , color : p . color , customId : legacyCustomId } ;
140+ }
141+ }
142+ }
143+ // Recompute positionKey after board change
144+ ns . positionKeys = [ positionKey ( ns ) ] ;
145+ return ns ;
146+ }
147+
79148interface GameCtx {
80149 store : Store ;
81150 activeProfile : Profile | null ;
@@ -122,12 +191,15 @@ interface GameCtx {
122191 updateSetting < K extends keyof Settings > ( key : K , value : Settings [ K ] ) : void ;
123192}
124193
125- /** Returns the bot level if the mode is bot-driven (incl. portal+bot), else null. */
194+ /** Returns the bot level if the mode is bot-driven (incl. portal+bot, custom+bot ), else null. */
126195function botLevelOf ( m : Mode ) : number | null {
127196 if ( m . kind === "bot" ) return m . level ;
128197 if ( m . kind === "portal" && typeof m . opponent !== "string" && m . opponent . kind === "bot" ) {
129198 return m . opponent . level ;
130199 }
200+ if ( m . kind === "custom" && typeof m . opponent !== "string" && m . opponent . kind === "bot" ) {
201+ return m . opponent . level ;
202+ }
131203 return null ;
132204}
133205
@@ -414,7 +486,8 @@ export function GameProvider({ children }: { children: ReactNode }) {
414486 ? 600
415487 : 400 ;
416488 const t0 = performance . now ( ) ;
417- const move = await chooseBotMove ( state , lvl , { allowExternal : mode . kind === "bot" } ) ;
489+ const hasCustomPieces = Boolean ( state . customPiece || ( state . customPieces && Object . keys ( state . customPieces ) . length > 0 ) ) ;
490+ const move = await chooseBotMove ( state , lvl , { allowExternal : mode . kind === "bot" && ! hasCustomPieces } ) ;
418491 const elapsed = performance . now ( ) - t0 ;
419492 if ( elapsed < minThinkMs ) {
420493 await new Promise ( ( r ) => setTimeout ( r , minThinkMs - elapsed ) ) ;
@@ -650,9 +723,36 @@ export function GameProvider({ children }: { children: ReactNode }) {
650723 ? `Bot Lv ${ m . level } `
651724 : m . kind === "portal" && typeof m . opponent !== "string" && m . opponent . kind === "bot"
652725 ? `Bot Lv ${ m . opponent . level } `
653- : "Player 2" ;
726+ : m . kind === "custom" && typeof m . opponent !== "string" && m . opponent . kind === "bot"
727+ ? `Bot Lv ${ m . opponent . level } `
728+ : "Player 2" ;
654729 setPlayers ( { w : p ?. w ?? defaultW , b : p ?. b ?? defaultB } ) ;
655- const fresh = m . kind === "portal" ? portalInitialState ( m . creator , m . portalMax ?? 2 ) : initialState ( ) ;
730+ // Build initial board state
731+ let base : GameState ;
732+ if ( opts ?. customGame ) {
733+ base = customBoardState ( opts . customGame ) ;
734+ if ( m . kind === "portal" ) {
735+ base . portals = { w : [ ] , b : [ ] , max : m . portalMax ?? 2 } ;
736+ base . portalCreators = { w : m . creator , b : m . creator } ;
737+ base . portalAdjacencyRule = false ;
738+ }
739+ } else if ( opts ?. boardLayout ) {
740+ base = customBoardState ( opts . boardLayout ) ;
741+ // Portal mode with custom layout: add portal fields
742+ if ( m . kind === "portal" ) {
743+ base . portals = { w : [ ] , b : [ ] , max : m . portalMax ?? 2 } ;
744+ base . portalCreators = { w : m . creator , b : m . creator } ;
745+ base . portalAdjacencyRule = false ;
746+ }
747+ } else if ( m . kind === "portal" ) {
748+ base = portalInitialState ( m . creator , m . portalMax ?? 2 ) ;
749+ } else {
750+ base = initialState ( ) ;
751+ }
752+ // For custom mode, X1 pieces are already on the board from the designer
753+ const fresh = m . kind === "custom" && ! opts ?. customGame
754+ ? withCustomPiece ( base , m . customPiece ! , ( m as any ) . replaces ?? "N" )
755+ : base ;
656756 dispatch ( { type : "new" , initial : fresh } ) ;
657757 setSelected ( null ) ;
658758 setPaused ( false ) ;
@@ -733,14 +833,14 @@ export function GameProvider({ children }: { children: ReactNode }) {
733833 } , [ store ] ) ;
734834
735835 const updateSetting = useCallback ( < K extends keyof Settings > ( key : K , value : Settings [ K ] ) => {
736- const updated = cloneStore ( store ) ;
836+ const updated = cloneStore ( storeRef . current ) ;
737837 updateSettings ( updated , { [ key ] : value } as Partial < Settings > ) ;
738838 setStore ( updated ) ;
739839 // Keep ref in sync immediately so any synchronous code that runs right
740840 // after this (e.g. New Game screen's Start button: updateSetting → newGame)
741841 // sees the freshest settings.
742842 storeRef . current = updated ;
743- } , [ store ] ) ;
843+ } , [ ] ) ;
744844
745845 const recordPuzzleSolved = useCallback ( ( puzzleId : string ) => {
746846 if ( ! activeProfile ) return ;
@@ -802,6 +902,10 @@ function isHumanControlledColor(mode: Mode, color: Color): boolean {
802902 if ( typeof mode . opponent === "string") return true ;
803903 return color === "w ";
804904 }
905+ if ( mode . kind === "custom ") {
906+ if ( mode . opponent === "two - player ") return true ;
907+ return color === "w ";
908+ }
805909 return true ;
806910}
807911
0 commit comments