feat: added web view 1v1

This commit is contained in:
shahdlala66
2026-04-17 23:20:16 +02:00
commit 1828fa3275
80 changed files with 11876 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+43
View File
@@ -0,0 +1,43 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}
+7
View File
@@ -0,0 +1,7 @@
YOU CAN:
- Edit and use the asset in any commercial or non commercial project
- Use the asset in any commercial or non commercial project
YOU CAN'T:
- Resell or distribute the asset to others
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

+59
View File
@@ -0,0 +1,59 @@
# NowchessFrontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.24.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+105
View File
@@ -0,0 +1,105 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"nowchess-frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "ARABIAN CHESS",
"output": "/arabian-chess"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "nowchess-frontend:build:production"
},
"development": {
"buildTarget": "nowchess-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "ARABIAN CHESS",
"output": "/arabian-chess"
}
],
"styles": [
"src/styles.css"
]
}
}
}
}
}
}
+776
View File
@@ -0,0 +1,776 @@
openapi: 3.0.3
info:
title: NowChess API
description: |
REST API for the NowChess application. Designed to feel familiar to users
of the [lichess API](https://lichess.org/api).
## Authentication
Most endpoints require a Bearer token:
```
Authorization: Bearer <token>
```
Authentication is reserved for future implementation — endpoints are currently
open unless noted otherwise.
## Move notation
Moves are expressed in **UCI notation**: `{from}{to}[promotion]`
- Normal move: `e2e4`
- Capture: `d5e6`
- Promotion: `e7e8q` (q=queen, r=rook, b=bishop, n=knight)
- Castling: `e1g1` (kingside white), `e1c1` (queenside white)
## Streaming
Endpoints that support streaming return **NDJSON** (newline-delimited JSON).
Request them with:
```
Accept: application/x-ndjson
```
Each line of the response is a complete JSON object. Empty lines are
keep-alive heartbeats.
## Rate limiting
Requests that exceed the rate limit receive `429 Too Many Requests`.
Honour the `Retry-After` response header and wait before retrying.
version: 1.0.0
contact:
name: NowChess
license:
name: MIT
servers:
- url: http://localhost:8080
description: Local development server
tags:
- name: game
description: Create and manage chess games
- name: move
description: Make moves and navigate game history
- name: draw
description: Draw offers and claims
- name: import
description: Load a game from FEN or PGN
- name: export
description: Export a game as FEN or PGN
paths:
# ---------------------------------------------------------------------------
# Game lifecycle
# ---------------------------------------------------------------------------
/api/board/game:
post:
operationId: createGame
tags: [game]
summary: Create a new game
description: |
Creates a new chess game starting from the initial position.
Returns the full game state including the generated `gameId`.
security:
- bearerAuth: []
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/CreateGameRequest'
responses:
'201':
description: Game created
content:
application/json:
schema:
$ref: '#/components/schemas/GameFull'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/{gameId}:
get:
operationId: getGame
tags: [game]
summary: Get game state
description: Returns the full current state of a game.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
responses:
'200':
description: Current game state
content:
application/json:
schema:
$ref: '#/components/schemas/GameFull'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/{gameId}/stream:
get:
operationId: streamGame
tags: [game]
summary: Stream game events
description: |
Opens a persistent NDJSON stream for a game. The first object sent is
a `gameFull` event containing the complete game state. Subsequent
objects are `gameState` events sent whenever the game changes (move
made, draw offered, game over, etc.).
Empty lines are heartbeats to keep the connection alive.
Connect with:
```
Accept: application/x-ndjson
```
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
responses:
'200':
description: NDJSON event stream
content:
application/x-ndjson:
schema:
oneOf:
- $ref: '#/components/schemas/GameFullEvent'
- $ref: '#/components/schemas/GameStateEvent'
- $ref: '#/components/schemas/ErrorEvent'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/{gameId}/resign:
post:
operationId: resignGame
tags: [game]
summary: Resign the game
description: The active player resigns. The game ends immediately.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
responses:
'200':
description: Resignation accepted
content:
application/json:
schema:
$ref: '#/components/schemas/OkResponse'
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
# ---------------------------------------------------------------------------
# Move-making
# ---------------------------------------------------------------------------
/api/board/game/{gameId}/move/{uci}:
post:
operationId: makeMove
tags: [move]
summary: Make a move
description: |
Submit a move in UCI notation. The move must be legal for the side
currently to move.
For promotion moves include the target piece as the fifth character:
`e7e8q`, `a2a1r`, etc.
If the move results in a pawn reaching the back rank and no promotion
character is supplied, the game enters `promotionPending` status and
the move is not yet applied — resubmit with the promotion character.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
- name: uci
in: path
required: true
description: Move in UCI notation (e.g. `e2e4`, `e7e8q`)
schema:
type: string
pattern: '^[a-h][1-8][a-h][1-8][qrbn]?$'
example: e2e4
responses:
'200':
description: Move applied — returns updated game state
content:
application/json:
schema:
$ref: '#/components/schemas/GameState'
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/{gameId}/moves:
get:
operationId: getLegalMoves
tags: [move]
summary: Get legal moves
description: |
Returns all legal moves for the side currently to move.
Optionally filter to moves originating from a single square.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
- name: square
in: query
required: false
description: Filter to moves from this square (e.g. `e2`)
schema:
type: string
pattern: '^[a-h][1-8]$'
example: e2
responses:
'200':
description: List of legal moves
content:
application/json:
schema:
$ref: '#/components/schemas/LegalMovesResponse'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/{gameId}/undo:
post:
operationId: undoMove
tags: [move]
summary: Undo the last move
description: Reverts the most recent move. Returns the updated game state.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
responses:
'200':
description: Move undone
content:
application/json:
schema:
$ref: '#/components/schemas/GameState'
'400':
description: No moves to undo
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/{gameId}/redo:
post:
operationId: redoMove
tags: [move]
summary: Redo a previously undone move
description: Re-applies the next move in the undo stack. Returns the updated game state.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
responses:
'200':
description: Move redone
content:
application/json:
schema:
$ref: '#/components/schemas/GameState'
'400':
description: No moves to redo
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
# ---------------------------------------------------------------------------
# Draw handling
# ---------------------------------------------------------------------------
/api/board/game/{gameId}/draw/{action}:
post:
operationId: drawAction
tags: [draw]
summary: Offer, accept, decline, or claim a draw
description: |
Perform a draw-related action:
| Action | Description |
|-----------|-------------|
| `offer` | Offer a draw to the opponent |
| `accept` | Accept the opponent's draw offer |
| `decline` | Decline the opponent's draw offer |
| `claim` | Claim a draw under the fifty-move rule (only valid when `status` is `fiftyMoveAvailable`) |
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
- name: action
in: path
required: true
schema:
type: string
enum: [offer, accept, decline, claim]
responses:
'200':
description: Action accepted
content:
application/json:
schema:
$ref: '#/components/schemas/OkResponse'
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
/api/board/game/import/fen:
post:
operationId: importFen
tags: [import]
summary: Load a position from FEN
description: |
Creates a new game from a FEN string. The game starts at the position
described by the FEN; move history prior to that position is not
available.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ImportFenRequest'
responses:
'201':
description: Game created from FEN
content:
application/json:
schema:
$ref: '#/components/schemas/GameFull'
'400':
$ref: '#/components/responses/BadRequest'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/import/pgn:
post:
operationId: importPgn
tags: [import]
summary: Load a game from PGN
description: |
Creates a new game by replaying all moves in a PGN string. The game
starts at the position after the final move in the PGN; undo is
available for every replayed move.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ImportPgnRequest'
responses:
'201':
description: Game created from PGN
content:
application/json:
schema:
$ref: '#/components/schemas/GameFull'
'400':
$ref: '#/components/responses/BadRequest'
'429':
$ref: '#/components/responses/TooManyRequests'
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
/api/board/game/{gameId}/export/fen:
get:
operationId: exportFen
tags: [export]
summary: Export current position as FEN
description: Returns the FEN string representing the current board position.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
responses:
'200':
description: FEN string
content:
text/plain:
schema:
type: string
example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
/api/board/game/{gameId}/export/pgn:
get:
operationId: exportPgn
tags: [export]
summary: Export game as PGN
description: Returns the full PGN for the game including headers and move text.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/gameId'
responses:
'200':
description: PGN text
content:
application/x-chess-pgn:
schema:
type: string
example: |
[Event "NowChess game"]
[White "Player1"]
[Black "Player2"]
[Result "*"]
1. e4 e5 2. Nf3 *
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
# =============================================================================
# Components
# =============================================================================
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: 'Personal access token — `Authorization: Bearer <token>`'
parameters:
gameId:
name: gameId
in: path
required: true
description: 8-character alphanumeric game ID (e.g. `Qa7FJNk2`)
schema:
type: string
pattern: '^[A-Za-z0-9]{8}$'
example: Qa7FJNk2
responses:
BadRequest:
description: Invalid input
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
Unauthorized:
description: Missing or invalid authentication token
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
NotFound:
description: Game not found
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
TooManyRequests:
description: Rate limit exceeded — see `Retry-After` header
headers:
Retry-After:
description: Seconds to wait before retrying
schema:
type: integer
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
schemas:
# -------------------------------------------------------------------------
# Requests
# -------------------------------------------------------------------------
CreateGameRequest:
type: object
description: Parameters for creating a new game. All fields are optional.
properties:
white:
$ref: '#/components/schemas/PlayerInfo'
black:
$ref: '#/components/schemas/PlayerInfo'
ImportFenRequest:
type: object
required: [fen]
properties:
fen:
type: string
description: Complete FEN string (6 fields)
example: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
white:
$ref: '#/components/schemas/PlayerInfo'
black:
$ref: '#/components/schemas/PlayerInfo'
ImportPgnRequest:
type: object
required: [pgn]
properties:
pgn:
type: string
description: PGN text (headers and move list)
example: "1. e4 e5 2. Nf3 Nc6 *"
# -------------------------------------------------------------------------
# Game state
# -------------------------------------------------------------------------
GameFull:
type: object
description: Complete game information including players and current state.
required: [gameId, white, black, state]
properties:
gameId:
type: string
description: Unique 8-character game identifier
example: Qa7FJNk2
white:
$ref: '#/components/schemas/PlayerInfo'
black:
$ref: '#/components/schemas/PlayerInfo'
state:
$ref: '#/components/schemas/GameState'
GameState:
type: object
description: |
The current game state. Included in `GameFull` and returned by move
endpoints and stream events.
required: [fen, pgn, turn, status, moves, undoAvailable, redoAvailable]
properties:
fen:
type: string
description: FEN string for the current position
example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
pgn:
type: string
description: PGN move text for the full game so far
example: "1. e4"
turn:
type: string
enum: [white, black]
description: The side to move
status:
$ref: '#/components/schemas/GameStatus'
winner:
type: string
enum: [white, black]
description: Set when `status` is `checkmate` or `resign`
nullable: true
moves:
type: array
description: All moves played so far, in UCI notation
items:
type: string
example: [e2e4, e7e5, g1f3]
undoAvailable:
type: boolean
description: Whether `POST /undo` is currently valid
redoAvailable:
type: boolean
description: Whether `POST /redo` is currently valid
GameStatus:
type: string
description: |
Current game status:
| Value | Meaning |
|-------|---------|
| `started` | Game in progress, no special condition |
| `check` | Side to move is in check |
| `checkmate` | Side to move is checkmated — game over |
| `stalemate` | Side to move has no legal moves, not in check — game over (draw) |
| `resign` | A player resigned — game over |
| `draw` | Draw agreed or claimed — game over |
| `drawOffered` | Waiting for the opponent to accept or decline a draw offer |
| `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw |
| `promotionPending` | A pawn reached the back rank; awaiting promotion piece selection |
| `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) |
enum:
- started
- check
- checkmate
- stalemate
- resign
- draw
- drawOffered
- fiftyMoveAvailable
- promotionPending
- insufficientMaterial
# -------------------------------------------------------------------------
# Moves
# -------------------------------------------------------------------------
LegalMovesResponse:
type: object
required: [moves]
properties:
moves:
type: array
items:
$ref: '#/components/schemas/LegalMove'
LegalMove:
type: object
required: [from, to, uci, moveType]
properties:
from:
type: string
description: Origin square in algebraic notation
example: e2
to:
type: string
description: Destination square in algebraic notation
example: e4
uci:
type: string
description: Full move in UCI notation
example: e2e4
moveType:
$ref: '#/components/schemas/MoveType'
promotion:
type: string
enum: [queen, rook, bishop, knight]
description: Target piece for promotion moves
nullable: true
MoveType:
type: string
description: Classification of the move
enum:
- normal
- capture
- castleKingside
- castleQueenside
- enPassant
- promotion
# -------------------------------------------------------------------------
# Streaming events
# -------------------------------------------------------------------------
GameFullEvent:
type: object
description: |
First event on a game stream. Contains the complete game snapshot.
required: [type, game]
properties:
type:
type: string
enum: [gameFull]
game:
$ref: '#/components/schemas/GameFull'
GameStateEvent:
type: object
description: |
Emitted on a game stream whenever the game state changes (move played,
draw offered, game over, etc.).
required: [type, state]
properties:
type:
type: string
enum: [gameState]
state:
$ref: '#/components/schemas/GameState'
ErrorEvent:
type: object
description: Emitted on a game stream when an error occurs.
required: [type, error]
properties:
type:
type: string
enum: [error]
error:
$ref: '#/components/schemas/ApiError'
# -------------------------------------------------------------------------
# Shared types
# -------------------------------------------------------------------------
PlayerInfo:
type: object
required: [id, displayName]
properties:
id:
type: string
description: Unique player identifier
example: player1
displayName:
type: string
description: Human-readable display name
example: Alice
OkResponse:
type: object
required: [ok]
properties:
ok:
type: boolean
enum: [true]
ApiError:
type: object
required: [code, message]
properties:
code:
type: string
description: Machine-readable error code
example: INVALID_MOVE
message:
type: string
description: Human-readable error description
example: e2e5 is not a legal move
field:
type: string
description: Request field that caused the error, if applicable
example: uci
nullable: true
+9552
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "nowchess-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.24",
"@angular/cli": "^20.3.24",
"@angular/compiler-cli": "^20.3.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+14
View File
@@ -0,0 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(),
provideRouter(routes)
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+9
View File
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
import { GameComponent } from './pages/game/game.component';
import { WelcomeComponent } from './pages/welcome/welcome.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
{ path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }
];
+18
View File
@@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])]
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {
}
@@ -0,0 +1,59 @@
.board-shell {
width: min(100%, 82dvh, 760px);
max-width: calc(100dvw - 2rem);
margin: 0 auto;
}
.board-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
border: 2px solid #5A2C28;
border-radius: 10px 10px 0 0;
overflow: hidden;
background: #5A2C28;
}
.square {
position: relative;
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
overflow: hidden;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border: 0;
padding: 0;
cursor: pointer;
}
.square.light {
background-image: url('/arabian-chess/sprites/board/board_square_white.png');
}
.square.dark {
background-image: url('/arabian-chess/sprites/board/board_square_black.png');
}
.square.highlighted::after {
content: '';
position: absolute;
width: 28%;
height: 28%;
border-radius: 50%;
background: rgba(193, 158, 245, 0.75);
border: 2px solid #5A2C28;
}
.square.selected {
outline: 3px solid #BA6D4B;
outline-offset: -3px;
}
.board-bottom {
width: 100%;
display: block;
border: 2px solid #5A2C28;
border-top: 0;
border-radius: 0 0 10px 10px;
}
@@ -0,0 +1,20 @@
<div class="board-shell">
<div class="board-grid">
@for (square of squares; track trackByCoordinate($index, square)) {
<button
type="button"
class="square"
[class.light]="square.isLight"
[class.dark]="!square.isLight"
[class.selected]="isSelected(square)"
[class.highlighted]="isHighlighted(square)"
[attr.data-square]="square.coordinate"
(click)="onSquareClick(square)"
>
<app-chess-piece [pieceCode]="square.pieceCode" />
</button>
}
</div>
<img class="board-bottom" src="/arabian-chess/sprites/board/board_bottom.png" alt="Board frame" />
</div>
@@ -0,0 +1,90 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChessPieceComponent } from '../chess-piece/chess-piece.component';
interface BoardSquare {
coordinate: string;
isLight: boolean;
pieceCode: string | null;
}
@Component({
selector: 'app-chess-board',
standalone: true,
imports: [ChessPieceComponent],
templateUrl: './chess-board.component.html',
styleUrl: './chess-board.component.css'
})
export class ChessBoardComponent implements OnChanges {
@Input({ required: true }) fen = '';
@Input() selectedSquare: string | null = null;
@Input() highlightedSquares: string[] = [];
@Output() squareSelected = new EventEmitter<string>();
squares: BoardSquare[] = [];
private highlightedSquareSet = new Set<string>();
ngOnChanges(changes: SimpleChanges): void {
if (changes['fen']) {
this.squares = this.buildSquares(this.fen);
}
if (changes['highlightedSquares']) {
this.highlightedSquareSet = new Set(this.highlightedSquares);
}
}
trackByCoordinate(_index: number, square: BoardSquare): string {
return square.coordinate;
}
onSquareClick(square: BoardSquare): void {
this.squareSelected.emit(square.coordinate);
}
isSelected(square: BoardSquare): boolean {
return this.selectedSquare === square.coordinate;
}
isHighlighted(square: BoardSquare): boolean {
return this.highlightedSquareSet.has(square.coordinate);
}
private buildSquares(fen: string): BoardSquare[] {
const placement = fen.split(' ')[0] ?? '';
const rows = placement.split('/');
if (rows.length !== 8) {
return [];
}
const squares: BoardSquare[] = [];
rows.forEach((row, rowIndex) => {
let column = 0;
for (const char of row) {
if (char >= '1' && char <= '8') {
const emptyCount = Number(char);
for (let step = 0; step < emptyCount; step += 1) {
squares.push(this.createSquare(rowIndex, column, null));
column += 1;
}
} else {
squares.push(this.createSquare(rowIndex, column, char));
column += 1;
}
}
});
return squares;
}
private createSquare(rowIndex: number, column: number, pieceCode: string | null): BoardSquare {
const file = String.fromCharCode(97 + column);
const rank = String(8 - rowIndex);
return {
coordinate: `${file}${rank}`,
isLight: (rowIndex + column) % 2 === 0,
pieceCode
};
}
}
@@ -0,0 +1,7 @@
.piece {
width: 90%;
height: 90%;
display: block;
object-fit: contain;
pointer-events: none;
}
@@ -0,0 +1,3 @@
@if (pieceCode) {
<img class="piece" [src]="spriteUrl" [alt]="pieceCode" />
}
@@ -0,0 +1,40 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-chess-piece',
standalone: true,
templateUrl: './chess-piece.component.html',
styleUrl: './chess-piece.component.css'
})
export class ChessPieceComponent {
@Input({ required: true }) pieceCode: string | null = null;
get spriteUrl(): string {
if (!this.pieceCode) {
return '';
}
const color = this.pieceCode === this.pieceCode.toUpperCase() ? 'white' : 'black';
const pieceName = this.getPieceName(this.pieceCode.toLowerCase());
return `/arabian-chess/sprites/pieces/${color}_${pieceName}.png`;
}
private getPieceName(piece: string): string {
switch (piece) {
case 'k':
return 'king';
case 'q':
return 'queen';
case 'r':
return 'rook';
case 'b':
return 'bishop';
case 'n':
return 'knight';
case 'p':
return 'pawn';
default:
return 'pawn';
}
}
}
+71
View File
@@ -0,0 +1,71 @@
export type GameTurn = 'white' | 'black';
export type GameStatus =
| 'started'
| 'check'
| 'checkmate'
| 'stalemate'
| 'resign'
| 'draw'
| 'drawOffered'
| 'fiftyMoveAvailable'
| 'promotionPending'
| 'insufficientMaterial';
export interface PlayerInfo {
id: string;
displayName: string;
}
export interface GameState {
fen: string;
pgn: string;
turn: GameTurn;
status: GameStatus;
winner?: GameTurn | null;
moves: string[];
undoAvailable: boolean;
redoAvailable: boolean;
}
export interface GameFull {
gameId: string;
white: PlayerInfo;
black: PlayerInfo;
state: GameState;
}
export interface LegalMove {
from: string;
to: string;
uci: string;
moveType: string;
promotion?: 'queen' | 'rook' | 'bishop' | 'knight' | null;
}
export interface LegalMovesResponse {
moves: LegalMove[];
}
export interface ApiError {
code: string;
message: string;
field?: string | null;
}
export interface GameFullEvent {
type: 'gameFull';
game: GameFull;
}
export interface GameStateEvent {
type: 'gameState';
state: GameState;
}
export interface ErrorEvent {
type: 'error';
error: ApiError;
}
export type GameStreamEvent = GameFullEvent | GameStateEvent | ErrorEvent;
+119
View File
@@ -0,0 +1,119 @@
.game-shell {
height: 100dvh;
padding: clamp(0.75rem, 2vw, 1.5rem);
overflow: hidden;
}
.game-card {
max-width: 1100px;
height: 100%;
margin: 0 auto;
background: #F3C8A0;
border: 2px solid #5A2C28;
border-radius: 12px;
padding: clamp(0.75rem, 1.5vw, 1.25rem);
box-shadow: 0 8px 24px rgba(90, 44, 40, 0.2);
display: flex;
flex-direction: column;
min-height: 0;
}
header {
margin-bottom: 1rem;
}
h1,
h2 {
color: #5A2C28;
margin: 0 0 0.5rem;
}
.meta {
margin: 0;
}
.back-link {
display: inline-block;
margin-bottom: 0.5rem;
color: #5A2C28;
}
.top-section {
display: grid;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex: 0 0 auto;
}
.state-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.panel {
background: #B9C2DA;
border-radius: 10px;
border: 2px solid #5A2C28;
padding: 0.75rem;
}
.panel p {
margin: 0.3rem 0;
}
code {
display: block;
background: #E1EAA9;
border-radius: 8px;
padding: 0.5rem;
overflow-wrap: anywhere;
}
.move-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.board-section {
flex: 1 1 auto;
min-height: 0;
display: grid;
place-items: center;
padding: clamp(0.35rem, 1vw, 0.75rem);
border-radius: 10px;
border: 2px solid #5A2C28;
background: #B9DAD1;
overflow: hidden;
}
input {
border: 2px solid #5A2C28;
border-radius: 10px;
background: #B9DAD1;
padding: 0.6rem 0.75rem;
min-width: 180px;
}
button {
border: 2px solid #5A2C28;
border-radius: 10px;
background: #C19EF5;
color: #5A2C28;
padding: 0.6rem 1rem;
cursor: pointer;
}
button:hover {
background: #BA6D4B;
color: #F3C8A0;
}
.error {
margin-top: 0.5rem;
color: #5A2C28;
font-weight: 700;
}
+52
View File
@@ -0,0 +1,52 @@
<main class="game-shell">
<section class="game-card">
<header>
<a routerLink="/" class="back-link">Back</a>
<h1>1 vs 1 Game</h1>
<p class="meta">Game ID: <strong>{{ gameId }}</strong></p>
</header>
@if (loading) {
<p>Loading game state...</p>
} @else if (state) {
<section class="top-section">
<div class="state-grid">
<div class="panel">
<h2>Status</h2>
<p>Turn: <strong>{{ state.turn }}</strong></p>
<p>Status: <strong>{{ state.status }}</strong></p>
<p>FEN:</p>
<code>{{ state.fen }}</code>
</div>
<div class="panel">
<h2>Moves</h2>
<p>PGN: {{ state.pgn || 'No moves yet' }}</p>
<p>Played UCI: {{ state.moves.length ? state.moves.join(', ') : 'None' }}</p>
<p>Legal UCI: {{ legalMoveUciList.length ? legalMoveUciList.join(', ') : 'None' }}</p>
<p>Board: click your piece to highlight legal targets.</p>
</div>
</div>
<form class="move-form" (ngSubmit)="submitMove()">
<label for="uciMove">Play move (UCI)</label>
<input id="uciMove" name="uciMove" [(ngModel)]="moveInput" placeholder="e2e4" />
<button type="submit">Send Move</button>
</form>
</section>
<section class="board-section">
<app-chess-board
[fen]="state.fen"
[selectedSquare]="selectedSquare"
[highlightedSquares]="highlightedSquares"
(squareSelected)="onBoardSquareSelected($event)"
/>
</section>
}
@if (errorMessage) {
<p class="error">{{ errorMessage }}</p>
}
</section>
</main>
+250
View File
@@ -0,0 +1,250 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { interval, startWith, Subscription, switchMap } from 'rxjs';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
import { GameApiService } from '../../services/game-api.service';
@Component({
selector: 'app-game',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent],
templateUrl: './game.component.html',
styleUrl: './game.component.css'
})
export class GameComponent implements OnInit, OnDestroy {
gameId = '';
game: GameFull | null = null;
errorMessage = '';
moveInput = '';
legalMoves: LegalMove[] = [];
loading = true;
selectedSquare: string | null = null;
highlightedSquares: string[] = [];
private selectedSquareMoves: LegalMove[] = [];
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
private routeSubscription: Subscription | null = null;
constructor(
private readonly route: ActivatedRoute,
private readonly gameApi: GameApiService
) {}
get state(): GameState | null {
return this.game?.state ?? null;
}
get legalMoveUciList(): string[] {
return this.legalMoves.map((move) => move.uci);
}
ngOnInit(): void {
this.routeSubscription = this.route.paramMap.subscribe((paramMap) => {
const id = paramMap.get('gameId');
if (!id) {
this.errorMessage = 'Missing gameId in route.';
this.loading = false;
return;
}
this.gameId = id;
this.loadGame();
});
}
ngOnDestroy(): void {
this.routeSubscription?.unsubscribe();
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
}
onBoardSquareSelected(square: string): void {
if (!this.state) {
return;
}
if (this.selectedSquare && this.highlightedSquares.includes(square)) {
const selectedMove = this.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) {
this.moveInput = selectedMove.uci;
this.submitMove();
}
return;
}
const piece = this.getPieceAtSquare(this.state.fen, square);
if (!piece || !this.isCurrentTurnPiece(piece)) {
this.clearSelection();
return;
}
this.errorMessage = '';
this.gameApi.getLegalMoves(this.gameId, square).subscribe({
next: (response) => {
this.selectedSquare = square;
this.selectedSquareMoves = response.moves;
this.highlightedSquares = response.moves.map((move) => move.to);
},
error: () => {
this.clearSelection();
this.errorMessage = 'Could not load legal moves for selected square.';
}
});
}
submitMove(): void {
const uci = this.moveInput.trim();
if (!uci) {
return;
}
this.errorMessage = '';
this.gameApi.makeMove(this.gameId, uci).subscribe({
next: (state) => {
if (this.game) {
this.game = { ...this.game, state };
}
this.moveInput = '';
this.clearSelection();
this.loadLegalMoves();
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? 'Move rejected.';
}
});
}
private loadGame(): void {
this.loading = true;
this.errorMessage = '';
this.clearSelection();
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
this.gameApi.getGame(this.gameId).subscribe({
next: (game) => {
this.game = game;
this.loading = false;
this.loadLegalMoves();
this.startStream();
this.startPolling();
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? `Could not load game ${this.gameId}.`;
this.loading = false;
}
});
}
private loadLegalMoves(): void {
this.gameApi.getLegalMoves(this.gameId).subscribe({
next: (response) => {
this.legalMoves = response.moves;
},
error: () => {
this.legalMoves = [];
}
});
}
private startStream(): void {
this.streamSubscription = this.gameApi.streamGame(this.gameId).subscribe({
next: (event) => this.applyStreamEvent(event),
error: () => {
this.errorMessage = 'Live stream disconnected.';
}
});
}
private startPolling(): void {
this.pollSubscription = interval(1500)
.pipe(
startWith(0),
switchMap(() => this.gameApi.getGame(this.gameId))
)
.subscribe({
next: (game) => {
const previousMoves = this.game?.state.moves.join(',') ?? '';
this.game = game;
if (previousMoves !== game.state.moves.join(',')) {
this.clearSelection();
this.loadLegalMoves();
}
}
});
}
private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') {
this.game = event.game;
this.clearSelection();
this.loadLegalMoves();
return;
}
if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state };
if (event.state.moves.length !== moveCountBefore) {
this.clearSelection();
}
this.loadLegalMoves();
return;
}
if (event.type === 'error') {
this.errorMessage = event.error.message;
}
}
private clearSelection(): void {
this.selectedSquare = null;
this.selectedSquareMoves = [];
this.highlightedSquares = [];
}
private isCurrentTurnPiece(pieceCode: string): boolean {
if (!this.state) {
return false;
}
const isWhitePiece = pieceCode === pieceCode.toUpperCase();
return (this.state.turn === 'white' && isWhitePiece) || (this.state.turn === 'black' && !isWhitePiece);
}
private getPieceAtSquare(fen: string, targetSquare: string): string | null {
const placement = fen.split(' ')[0] ?? '';
const rows = placement.split('/');
if (rows.length !== 8 || targetSquare.length !== 2) {
return null;
}
const file = targetSquare.charCodeAt(0) - 97;
const rank = Number(targetSquare[1]);
const rowIndex = 8 - rank;
if (Number.isNaN(rank) || file < 0 || file > 7 || rowIndex < 0 || rowIndex > 7) {
return null;
}
let column = 0;
for (const symbol of rows[rowIndex]) {
if (symbol >= '1' && symbol <= '8') {
column += Number(symbol);
continue;
}
if (column === file) {
return symbol;
}
column += 1;
}
return null;
}
}
@@ -0,0 +1,69 @@
.welcome-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.welcome-card {
width: min(900px, 100%);
border-radius: 12px;
border: 2px solid #5A2C28;
background: #F3C8A0;
padding: 2rem;
box-shadow: 0 8px 24px rgba(90, 44, 40, 0.2);
}
h1 {
margin: 0 0 0.25rem;
color: #5A2C28;
}
p {
margin: 0 0 1.25rem;
}
.mode-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.mode {
border: 2px solid #5A2C28;
border-radius: 10px;
padding: 1rem;
text-align: left;
display: grid;
gap: 0.25rem;
}
.mode span {
font-size: 1.15rem;
color: #5A2C28;
}
.mode small {
color: #5A2C28;
opacity: 0.9;
}
.mode-active {
background: #B9DAD1;
cursor: pointer;
}
.mode-active:hover:enabled {
background: #B9C2DA;
}
.mode-disabled {
background: #E1EAA9;
opacity: 0.75;
}
.error {
color: #5A2C28;
font-weight: 700;
margin-top: 1rem;
}
@@ -0,0 +1,27 @@
<main class="welcome-shell">
<section class="welcome-card">
<h1>Welcome to NowChess</h1>
<p>Pick a mode to begin.</p>
<div class="mode-grid">
<button type="button" class="mode mode-disabled" disabled>
<span>Bot</span>
<small>Coming soon</small>
</button>
<button type="button" class="mode mode-active" (click)="startOneVsOne()" [disabled]="creating">
<span>1 vs 1</span>
<small>{{ creating ? 'Creating game...' : 'Start now' }}</small>
</button>
<button type="button" class="mode mode-disabled" disabled>
<span>Future Technique</span>
<small>Placeholder</small>
</button>
</div>
@if (errorMessage) {
<p class="error">{{ errorMessage }}</p>
}
</section>
</main>
@@ -0,0 +1,43 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { finalize } from 'rxjs';
import { GameApiService } from '../../services/game-api.service';
@Component({
selector: 'app-welcome',
standalone: true,
imports: [CommonModule],
templateUrl: './welcome.component.html',
styleUrl: './welcome.component.css'
})
export class WelcomeComponent {
creating = false;
errorMessage = '';
constructor(
private readonly router: Router,
private readonly gameApi: GameApiService
) {}
startOneVsOne(): void {
if (this.creating) {
return;
}
this.errorMessage = '';
this.creating = true;
this.gameApi
.createGame()
.pipe(finalize(() => (this.creating = false)))
.subscribe({
next: (game) => {
void this.router.navigate(['/game', game.gameId]);
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? 'Unable to create a game.';
}
});
}
}
+152
View File
@@ -0,0 +1,152 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import {
ErrorEvent,
GameFull,
GameState,
GameStreamEvent,
LegalMovesResponse
} from '../models/game.models';
@Injectable({ providedIn: 'root' })
export class GameApiService {
private readonly apiBase = environment.apiBaseUrl;
private readonly wsBase = environment.wsBaseUrl;
constructor(private readonly http: HttpClient) {}
createGame(): Observable<GameFull> {
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, {});
}
getGame(gameId: string): Observable<GameFull> {
return this.http.get<GameFull>(`${this.apiBase}/api/board/game/${gameId}`);
}
makeMove(gameId: string, uci: string): Observable<GameState> {
return this.http.post<GameState>(`${this.apiBase}/api/board/game/${gameId}/move/${uci}`, {});
}
getLegalMoves(gameId: string, square?: string): Observable<LegalMovesResponse> {
let params = new HttpParams();
if (square) {
params = params.set('square', square);
}
return this.http.get<LegalMovesResponse>(`${this.apiBase}/api/board/game/${gameId}/moves`, { params });
}
streamGame(gameId: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => {
const wsUrl = `${this.wsBase}/api/board/game/${gameId}/stream`;
const streamUrl = `${this.apiBase}/api/board/game/${gameId}/stream`;
const ws = new WebSocket(wsUrl);
const abortController = new AbortController();
let connected = false;
let fallbackActive = false;
const parseEvent = (raw: string): GameStreamEvent | null => {
if (!raw.trim()) {
return null;
}
try {
const parsed = JSON.parse(raw) as GameStreamEvent;
return parsed;
} catch {
return null;
}
};
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
type: 'error',
error: { code: 'STREAM_ERROR', message }
};
observer.next(errorEvent);
};
const startNdjsonFallback = async (): Promise<void> => {
if (fallbackActive) {
return;
}
fallbackActive = true;
try {
const response = await fetch(streamUrl, {
headers: { Accept: 'application/x-ndjson' },
signal: abortController.signal
});
if (!response.ok || !response.body) {
emitErrorEvent(`Unable to open stream: HTTP ${response.status}`);
observer.complete();
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const event = parseEvent(line);
if (event) {
observer.next(event);
}
}
}
observer.complete();
} catch (error) {
if ((error as Error).name !== 'AbortError') {
emitErrorEvent((error as Error).message);
observer.error(error);
}
}
};
ws.onopen = () => {
connected = true;
};
ws.onmessage = (message) => {
const payload = typeof message.data === 'string' ? message.data : '';
const event = parseEvent(payload);
if (event) {
observer.next(event);
}
};
ws.onerror = () => {
if (!connected) {
void startNdjsonFallback();
}
};
ws.onclose = () => {
if (!connected) {
void startNdjsonFallback();
} else {
observer.complete();
}
};
return () => {
abortController.abort();
ws.close();
};
});
}
}
@@ -0,0 +1,4 @@
export const environment = {
apiBaseUrl: 'http://localhost:8080',
wsBaseUrl: 'ws://localhost:8080'
};
+4
View File
@@ -0,0 +1,4 @@
export const environment = {
apiBaseUrl: 'http://localhost:8080',
wsBaseUrl: 'ws://localhost:8080'
};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>NowChess Frontend</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+27
View File
@@ -0,0 +1,27 @@
:root {
--warm-primary: #BA6D4B;
--warm-dark: #5A2C28;
--warm-light: #F3C8A0;
--cool-mint: #B9DAD1;
--cool-blue: #B9C2DA;
--cool-purple: #C19EF5;
--cool-lime: #E1EAA9;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
font-family: "Comic Sans MS", "Comic Sans", cursive;
background: linear-gradient(160deg, var(--warm-light), var(--cool-mint));
color: var(--warm-dark);
}
button,
input {
font: inherit;
}
+30
View File
@@ -0,0 +1,30 @@
/* Arabian Chess GUI Styles */
.root {
-fx-font-family: "Comic Sans MS", "Comic Sans", cursive;
-fx-background-color: #F3C8A0;
}
.button {
-fx-background-radius: 8;
-fx-padding: 8 16 8 16;
-fx-font-family: "Comic Sans MS", cursive;
-fx-font-size: 12px;
-fx-cursor: hand;
}
.button:hover {
-fx-opacity: 0.8;
}
.label {
-fx-font-family: "Comic Sans MS", cursive;
}
.dialog-pane {
-fx-background-color: #F3C8A0;
}
.dialog-pane .content {
-fx-font-family: "Comic Sans MS", cursive;
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+34
View File
@@ -0,0 +1,34 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}