diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.css b/src/app/components/promotion-dialog/promotion-dialog.component.css
new file mode 100644
index 0000000..578f349
--- /dev/null
+++ b/src/app/components/promotion-dialog/promotion-dialog.component.css
@@ -0,0 +1,131 @@
+.promotion-dialog-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.2s, visibility 0.2s;
+ z-index: 1000;
+
+ &.open {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
+.promotion-dialog {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+ max-width: 400px;
+ width: 90%;
+ animation: slideUp 0.3s ease;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.promotion-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ border-bottom: 1px solid #e0e0e0;
+
+ h3 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #333;
+ }
+
+ .close-btn {
+ background: none;
+ border: none;
+ font-size: 24px;
+ cursor: pointer;
+ color: #999;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s;
+
+ &:hover {
+ color: #333;
+ }
+ }
+}
+
+.promotion-body {
+ padding: 20px;
+}
+
+.promotion-prompt {
+ margin: 0 0 20px 0;
+ text-align: center;
+ color: #666;
+ font-size: 14px;
+}
+
+.promotion-options {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.promotion-button {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 16px;
+ background: #f5f5f5;
+ border: 2px solid #ddd;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-family: inherit;
+
+ &:hover {
+ background: #e8e8e8;
+ border-color: #999;
+ transform: translateY(-2px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ &:active {
+ transform: translateY(0);
+ box-shadow: none;
+ }
+
+ .piece-symbol {
+ font-size: 32px;
+ line-height: 1;
+ }
+
+ .piece-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: #666;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+}
diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.html b/src/app/components/promotion-dialog/promotion-dialog.component.html
new file mode 100644
index 0000000..7cdb899
--- /dev/null
+++ b/src/app/components/promotion-dialog/promotion-dialog.component.html
@@ -0,0 +1,26 @@
+
diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.ts b/src/app/components/promotion-dialog/promotion-dialog.component.ts
new file mode 100644
index 0000000..279339a
--- /dev/null
+++ b/src/app/components/promotion-dialog/promotion-dialog.component.ts
@@ -0,0 +1,39 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+type PromotionPieceType = 'queen' | 'rook' | 'bishop' | 'knight';
+interface PromotionPieceOption {
+ type: PromotionPieceType;
+ label: string;
+ symbol: string;
+}
+
+@Component({
+ selector: 'app-promotion-dialog',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './promotion-dialog.component.html',
+ styleUrl: './promotion-dialog.component.css'
+})
+export class PromotionDialogComponent {
+ @Input() isOpen = false;
+ @Output() promotionSelected = new EventEmitter();
+ @Output() closed = new EventEmitter();
+
+ promotionPieces: PromotionPieceOption[] = [
+ { type: 'queen', label: 'Queen', symbol: '♕' },
+ { type: 'rook', label: 'Rook', symbol: '♖' },
+ { type: 'bishop', label: 'Bishop', symbol: '♗' },
+ { type: 'knight', label: 'Knight', symbol: '♘' }
+ ];
+
+ selectPromotion(type: PromotionPieceType): void {
+ this.promotionSelected.emit(type);
+ this.isOpen = false;
+ }
+
+ close(): void {
+ this.closed.emit();
+ this.isOpen = false;
+ }
+}
diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html
index 8f2b10a..04c93b2 100644
--- a/src/app/pages/game/game.component.html
+++ b/src/app/pages/game/game.component.html
@@ -1,4 +1,10 @@
+
+
Back
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts
index f7e4271..307aed4 100644
--- a/src/app/pages/game/game.component.ts
+++ b/src/app/pages/game/game.component.ts
@@ -4,13 +4,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
- import { InputCardComponent } from '../../components/input-card/input-card.component';
+import { InputCardComponent } from '../../components/input-card/input-card.component';
+import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade';
@Component({
selector: 'app-game',
standalone: true,
- imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent],
+ imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent],
providers: [GameFacade],
templateUrl: './game.component.html',
styleUrl: './game.component.css'
diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts
index 78b1729..d31ee04 100644
--- a/src/app/pages/game/game.facade.ts
+++ b/src/app/pages/game/game.facade.ts
@@ -1,7 +1,7 @@
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { getErrorMessage } from '../../core/http/error-message.util';
-import { GameFull, GameState, GameStreamEvent } from '../../models/game.models';
+import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
import { GameApiService } from '../../services/game-api.service';
import { BotMoveService } from '../../services/bot-move.service';
import { GameCompletionService } from '../../services/game-completion.service';
@@ -20,12 +20,14 @@ export class GameFacade implements OnDestroy {
loading = true;
gameCompletionMessage = '';
isGameFinished = false;
+ isPromotionDialogOpen = false;
private boardSelection: BoardSelection = {
selectedSquare: null,
highlightedSquares: [],
selectedSquareMoves: []
};
+ private pendingPromotionMoves: LegalMove[] = [];
private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef);
@@ -66,6 +68,15 @@ export class GameFacade implements OnDestroy {
if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) {
const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) {
+ // If multiple promotion outcomes exist for the target, ask player to choose one.
+ const promotionMoves = this.boardSelection.selectedSquareMoves.filter(
+ (move) => move.to === square && !!move.promotion
+ );
+ if (promotionMoves.length > 0) {
+ this.pendingPromotionMoves = promotionMoves;
+ this.isPromotionDialogOpen = true;
+ return;
+ }
this.moveInput = selectedMove.uci;
this.submitMove();
}
@@ -134,6 +145,28 @@ export class GameFacade implements OnDestroy {
});
}
+ onPromotionSelected(promotionPiece: 'queen' | 'rook' | 'bishop' | 'knight'): void {
+ const selectedPromotionMove = this.pendingPromotionMoves.find((move) => move.promotion === promotionPiece);
+ if (!selectedPromotionMove) {
+ this.errorMessage = 'Selected promotion move is unavailable.';
+ this.isPromotionDialogOpen = false;
+ this.pendingPromotionMoves = [];
+ return;
+ }
+
+ this.moveInput = selectedPromotionMove.uci;
+ this.isPromotionDialogOpen = false;
+ this.boardSelection = this.boardSelectionService.clearSelection();
+ this.pendingPromotionMoves = [];
+ this.submitMove();
+ }
+
+ onPromotionClosed(): void {
+ this.isPromotionDialogOpen = false;
+ this.boardSelection = this.boardSelectionService.clearSelection();
+ this.pendingPromotionMoves = [];
+ }
+
importFen(): void {
this.errorMessage = '';
this.importService.importFen(