diff --git a/app/features/game_pages/router.py b/app/features/game_pages/router.py
index 1fdb617..17743a5 100644
--- a/app/features/game_pages/router.py
+++ b/app/features/game_pages/router.py
@@ -1,4 +1,5 @@
import json
+import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response
@@ -14,7 +15,7 @@
from ...routers.games import _cover_url, _player_name
from ...routers.games import leaderboard as game_leaderboard
from ...routers.pages import SHARE_IMAGE, _esc, _get_shell, _page, _site, _title
-from ...server_html import chrome, esc, game_card
+from ...server_html import chrome, colors_for, esc, game_card
from ...storage import game_url
from . import generate as gen
from . import service
@@ -46,6 +47,46 @@ def _category_links(db: Session, name: str) -> list[tuple[str, str]]:
return tags.links_for(db, name)
+_KEY = r"(?:[A-Z]|Space|Shift|Enter|Esc|Tab)"
+_KEYS = re.compile(rf"\b{_KEY}(?:\s*/\s*{_KEY})+\b|\bWASD\b")
+
+
+def _keycaps(html: str) -> str:
+ """Show keys as keys: "A / D" and "WASD" in the controls lines become little key caps (only lines that start with "Controls" or "Move",
+ so an "A" at the start of a sentence is never touched). The words stay the same."""
+ def caps(m: re.Match) -> str:
+ parts = list("WASD") if m.group(0) == "WASD" else [p.strip() for p in m.group(0).split("/")]
+ return " ".join(f'{p}' for p in parts)
+
+ def line(m: re.Match) -> str:
+ return m.group(1) + _KEYS.sub(caps, m.group(2)) + m.group(3)
+
+ return re.sub(r"(
(?:Controls|Move|Steer)[^<]*?)(?=\b)(.*?)(
)", lambda m: m.group(1) + _KEYS.sub(caps, m.group(2)) + m.group(3), html)
+
+
+def _sections(html: str, kind: str) -> str:
+ """The page text as cards, one for each heading. A FAQ is a list of questions that open and close. Same words, same order."""
+ parts = re.split(r"(?=
)", html)
+ out = []
+ for part in parts:
+ m = re.match(r"
(.*?)
\n?(.*)", part, re.S)
+ if not m:
+ if part.strip():
+ out.append(f'
' for k in kinds if k != kind)
+ tabs = "".join(
+ f'{_esc(gen.SHORT[k])}" for k in gen.KINDS if k in kinds
+ )
+ a, b = colors_for(game.name)
+ cover = _cover_url(game)
+ art = f'' if cover else f"{_esc(title[:1])}"
+ ranked = [str(c.get("label") or c["key"]) for c in (game.leaderboard or []) if isinstance(c, dict) and c.get("key")]
+ facts = [("Made by", by)] + ([("On start.ng since", _since(game))] if _since(game) else []) + ([("Ranked by", ranked[0] + (f" and {len(ranked) - 1} more" if len(ranked) > 1 else ""))] if ranked else [])
+ facts_html = '
'
f"{_top_players(db, game, kind)}"
f''
- f"{categories}{_more_games(db, game, by)}"
+ f"{_more_games(db, game, by)}"
)
html = _page(shell, title=f"{page.title} · start.ng", description=page.description or f"{page.title} on start.ng", path=path, image=image, body=chrome(inner), extra_head="\n ".join(extra))
return Response(html, media_type="text/html; charset=utf-8", headers={"Cache-Control": "public, max-age=60"})
diff --git a/app/features/game_pages/tests/test_game_pages.py b/app/features/game_pages/tests/test_game_pages.py
index 550121b..8c00160 100644
--- a/app/features/game_pages/tests/test_game_pages.py
+++ b/app/features/game_pages/tests/test_game_pages.py
@@ -810,3 +810,17 @@ def test_the_agent_never_replaces_a_page_a_person_took_down(env, monkeypatch):
service.unapprove(db, page.id)
assert client.put(url, json=GOOD, headers=AGENT).status_code == 409
assert "lagos-apocalypto" not in {t["game"] for t in client.get("/agent/game-pages/todo", headers=AGENT).json()}
+
+
+# ---- the page layout: a hero, tabs, facts and cards, with the same words as before
+
+def test_a_game_page_has_a_hero_tabs_facts_and_cards(env, monkeypatch):
+ client, _, Session, store, _ = env
+ monkeypatch.setattr(settings, "game_pages_auto_approve", True)
+ with Session() as db:
+ service.generate_for(db, store, db.get(Game, "lagos-apocalypto"))
+ about = client.get("/pages/games/lagos-apocalypto/about").text
+ assert 'class="gs-hero"' in about and 'class="gs-tabs"' in about and 'aria-current="page">About<' in about and 'class="gs-facts"' in about
+ assert 'class="gs-card"' in about and "W" in about and "A zombie survival game set in Lagos" in about
+ faq = client.get("/pages/games/lagos-apocalypto/faq").text
+ assert '' in faq and "FAQPage" in faq # questions open and close; the markup for search engines stays
diff --git a/app/features/rooms/core.py b/app/features/rooms/core.py
index d0465f3..7a083fb 100644
--- a/app/features/rooms/core.py
+++ b/app/features/rooms/core.py
@@ -6,11 +6,13 @@
Messages (JSON text):
you -> server {type: "move", data: } | {type: "resign"} | {type: "bot"} | {type: "ping"}
+ {type: "rematch"} after a game ends: play again in the same room (see rematch())
hosted games (a quiz, bingo; the creator of the room is the host, seat 0): {type: "setup", data: {...}} (up to 64 KB,
before the game starts) and {type: "start"}
server -> you {type: "welcome", you: seat, room: CODE, game, players: [player], status, state, turn, result, host, now}
{type: "state", state, turn, result, status, now} after every move (state is that player's own view)
{type: "setup_ok"} the host's setup was accepted
+ {type: "rematch", seats: [seat]} who has asked to play again so far
(now is the server's clock in milliseconds, so a page can show a countdown that matches the server)
{type: "join", player} | {type: "away", seat, grace} | {type: "back", seat}
{type: "waiting"} {type: "no_match"} matchmaking: no one yet / offer to play a bot
@@ -152,6 +154,7 @@ class Room:
options: object = None # what the host set up (hosted games)
shared: object = None # a Relay room's shared state, and its version (see set_state)
version: int = 0
+ rematch: set = field(default_factory=set) # seats that asked to play again after the game ended
rooms: dict[str, Room] = {}
@@ -390,6 +393,34 @@ async def host_start(room: Room, conn: Conn) -> None:
await bots_play(room)
+async def rematch(room: Room, conn: Conn) -> None:
+ """After a game ends, play again in the same room. Everyone hears who has asked; once every person still in a seat has asked
+ (bots always agree), a new game starts with the seats moved round by one, so a different player goes first. In a game with
+ a host (a quiz, bingo) only the host asks, and the room goes back to waiting for them to start it, with the same setup."""
+ if room.status != "over" or relay(room.rules) or (hosted(room.rules) and conn.seat != 0):
+ return
+ room.rematch.add(conn.seat)
+ people = {i for i, s in enumerate(room.seats) if not s.bot}
+ if not hosted(room.rules) and not people <= room.rematch:
+ await tell_all(room, {"type": "rematch", "seats": sorted(room.rematch)})
+ return
+ room.rematch = set()
+ room.result, room.finished_at, room.last_move = None, None, clock()
+ if hosted(room.rules):
+ room.status, room.state = "waiting", None
+ else:
+ room.seats = room.seats[1:] + room.seats[:1]
+ for i, seat in enumerate(room.seats):
+ if seat.conn is not None:
+ seat.conn.seat = i
+ room.status = "playing"
+ room.state = room.rules.new_state(len(room.seats), room.options) if startable(room.rules) else room.rules.new_state(len(room.seats))
+ for seat in room.seats:
+ if seat.conn is not None:
+ await welcome(room, seat.conn)
+ await bots_play(room)
+
+
async def tick() -> None:
"""Hosted games run on timers (a question's time, the next number to call): ask each one whether its time has come."""
for room in list(rooms.values()):
@@ -540,6 +571,8 @@ async def pump(conn: Conn) -> None:
await host_setup(room, conn, msg.get("data"))
elif kind == "start" and room is not None:
await host_start(room, conn)
+ elif kind == "rematch" and room is not None:
+ await rematch(room, conn)
elif kind == "resign" and room is not None and room.status == "playing" and not hosted(room.rules) and not relay(room.rules) and len(room.seats) == 2:
others = [i for i in range(len(room.seats)) if i != conn.seat]
await finish(room, {"winner": others[0] if len(others) == 1 else None, "reason": "resigned"})
@@ -598,7 +631,7 @@ async def sweep() -> None:
await bots_play(room)
if now - room.created > MAX_AGE:
await close_room(room, "too_old")
- elif room.status == "waiting" and now - room.created > WAIT_FOR_FRIEND:
+ elif room.status == "waiting" and now - room.last_move > WAIT_FOR_FRIEND: # last_move: made, or sent back to waiting by a rematch
await close_room(room, "nobody_came")
elif room.status == "playing" and now - room.last_move > IDLE:
await close_room(room, "inactive")
diff --git a/app/features/rooms/games/__init__.py b/app/features/rooms/games/__init__.py
index 66c7267..8f81820 100644
--- a/app/features/rooms/games/__init__.py
+++ b/app/features/rooms/games/__init__.py
@@ -1,4 +1,4 @@
"""The games the rooms core can referee. Add a module here (see base.py) and list it below."""
-from . import ayo, bingo, connect4, draughts, ludo, quiz, tictactoe, whot
+from . import ayo, bingo, chess, connect4, draughts, ludo, quiz, tictactoe, whot
-GAMES = {m.name: m for m in (tictactoe, connect4, draughts, ayo, whot, ludo, quiz, bingo)}
+GAMES = {m.name: m for m in (tictactoe, chess, connect4, draughts, ayo, whot, ludo, quiz, bingo)}
diff --git a/app/features/rooms/games/chess.py b/app/features/rooms/games/chess.py
new file mode 100644
index 0000000..26bccc0
--- /dev/null
+++ b/app/features/rooms/games/chess.py
@@ -0,0 +1,320 @@
+"""Chess, refereed by the server (our own rules code, so there is no library licence to worry about).
+
+Seat 0 is White and moves first; seat 1 is Black. A move is {"from": "e2", "to": "e4"}, plus "promotion": "q" | "r" | "b" | "n" when
+a pawn reaches the last rank (a queen when it is left out). Squares are numbered a1 = 0 ... h1 = 7, a2 = 8 ... h8 = 63.
+
+State (JSON-able): {board: [64 x "wP"|"bK"|... | None], turn: 0|1, castling: "KQkq" (what is left), ep: square | None (the square a
+pawn may be captured on en passant), halfmove, fullmove, positions: {position key: times seen}, last: {from, to} | None}.
+Draws that happen by themselves: stalemate, only kings (or a king and one minor piece) left, the 50 move rule, and the same position
+three times. A draw by agreement is not offered yet.
+"""
+import random
+
+name = "chess"
+min_players = 2
+max_players = 2
+
+FILES = "abcdefgh"
+KNIGHT = [(1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2)]
+KING = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
+ROOK = [(1, 0), (0, 1), (-1, 0), (0, -1)]
+BISHOP = [(1, 1), (-1, 1), (-1, -1), (1, -1)]
+COLOR = "wb"
+VALUE = {"P": 100, "N": 320, "B": 330, "R": 500, "Q": 900, "K": 0}
+START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
+
+
+def sq(name_: str) -> int | None:
+ if len(name_) != 2 or name_[0] not in FILES or name_[1] not in "12345678":
+ return None
+ return int(name_[1]) * 8 - 8 + FILES.index(name_[0])
+
+
+def label(i: int) -> str:
+ return f"{FILES[i % 8]}{i // 8 + 1}"
+
+
+def from_fen(fen: str) -> dict:
+ parts = fen.split()
+ board: list = [None] * 64
+ for r, row in enumerate(parts[0].split("/")):
+ f = 0
+ for ch in row:
+ if ch.isdigit():
+ f += int(ch)
+ else:
+ board[(7 - r) * 8 + f] = ("w" if ch.isupper() else "b") + ch.upper()
+ f += 1
+ state = {
+ "board": board, "turn": 0 if parts[1] == "w" else 1, "castling": "" if parts[2] == "-" else parts[2],
+ "ep": None if parts[3] == "-" else sq(parts[3]), "halfmove": int(parts[4]) if len(parts) > 4 else 0,
+ "fullmove": int(parts[5]) if len(parts) > 5 else 1, "positions": {}, "last": None,
+ }
+ state["positions"][_key(state)] = 1
+ return state
+
+
+def new_state(players: int) -> dict:
+ return from_fen(START)
+
+
+def _key(state: dict) -> str:
+ """The position for the repetition rule: pieces, who moves, castling rights, and the en passant square."""
+ return ",".join(p or "-" for p in state["board"]) + f"|{state['turn']}|{state['castling']}|{state['ep']}"
+
+
+def _attacked(board: list, target: int, by: int) -> bool:
+ """Is the square attacked by a piece of colour `by` (0 white, 1 black)?"""
+ c = COLOR[by]
+ tf, tr = target % 8, target // 8
+ # pawns attack diagonally forward: a white pawn attacks from one rank below
+ pr = tr - 1 if by == 0 else tr + 1
+ if 0 <= pr < 8:
+ for df in (-1, 1):
+ pf = tf + df
+ if 0 <= pf < 8 and board[pr * 8 + pf] == c + "P":
+ return True
+ for df, dr in KNIGHT:
+ f, r = tf + df, tr + dr
+ if 0 <= f < 8 and 0 <= r < 8 and board[r * 8 + f] == c + "N":
+ return True
+ for df, dr in KING:
+ f, r = tf + df, tr + dr
+ if 0 <= f < 8 and 0 <= r < 8 and board[r * 8 + f] == c + "K":
+ return True
+ for dirs, kinds in ((ROOK, "RQ"), (BISHOP, "BQ")):
+ for df, dr in dirs:
+ f, r = tf + df, tr + dr
+ while 0 <= f < 8 and 0 <= r < 8:
+ piece = board[r * 8 + f]
+ if piece:
+ if piece[0] == c and piece[1] in kinds:
+ return True
+ break
+ f, r = f + df, r + dr
+ return False
+
+
+def _king(board: list, color: int) -> int:
+ return board.index(COLOR[color] + "K")
+
+
+def in_check(state: dict, color: int | None = None) -> bool:
+ color = state["turn"] if color is None else color
+ return _attacked(state["board"], _king(state["board"], color), 1 - color)
+
+
+def _pseudo(state: dict) -> list[tuple]:
+ """Moves that follow the piece rules but may leave the king in check: (from, to, promotion, kind)."""
+ board, turn = state["board"], state["turn"]
+ me = COLOR[turn]
+ moves: list[tuple] = []
+ for i, piece in enumerate(board):
+ if not piece or piece[0] != me:
+ continue
+ kind, f, r = piece[1], i % 8, i // 8
+ if kind == "P":
+ step = 1 if turn == 0 else -1
+ start_rank = 1 if turn == 0 else 6
+ last_rank = 7 if turn == 0 else 0
+ ahead = (r + step) * 8 + f
+ if 0 <= r + step < 8 and board[ahead] is None:
+ if r + step == last_rank:
+ moves += [(i, ahead, p, "move") for p in "qrbn"]
+ else:
+ moves.append((i, ahead, None, "move"))
+ two = (r + 2 * step) * 8 + f
+ if r == start_rank and board[two] is None:
+ moves.append((i, two, None, "double"))
+ for df in (-1, 1):
+ if not (0 <= f + df < 8 and 0 <= r + step < 8):
+ continue
+ t = (r + step) * 8 + f + df
+ if board[t] and board[t][0] != me:
+ if r + step == last_rank:
+ moves += [(i, t, p, "capture") for p in "qrbn"]
+ else:
+ moves.append((i, t, None, "capture"))
+ elif t == state["ep"]:
+ moves.append((i, t, None, "ep"))
+ elif kind in "NK":
+ for df, dr in (KNIGHT if kind == "N" else KING):
+ nf, nr = f + df, r + dr
+ if 0 <= nf < 8 and 0 <= nr < 8:
+ t = nr * 8 + nf
+ if board[t] is None or board[t][0] != me:
+ moves.append((i, t, None, "move"))
+ if kind == "K":
+ rights = state["castling"]
+ home = 4 if turn == 0 else 60
+ if i == home and not _attacked(board, home, 1 - turn):
+ k_right, q_right = ("K", "Q") if turn == 0 else ("k", "q")
+ if k_right in rights and board[home + 1] is None and board[home + 2] is None and board[home + 3] == me + "R" \
+ and not _attacked(board, home + 1, 1 - turn) and not _attacked(board, home + 2, 1 - turn):
+ moves.append((i, home + 2, None, "castle"))
+ if q_right in rights and board[home - 1] is None and board[home - 2] is None and board[home - 3] is None \
+ and board[home - 4] == me + "R" and not _attacked(board, home - 1, 1 - turn) \
+ and not _attacked(board, home - 2, 1 - turn):
+ moves.append((i, home - 2, None, "castle"))
+ else:
+ for df, dr in (ROOK if kind == "R" else BISHOP if kind == "B" else ROOK + BISHOP):
+ nf, nr = f + df, r + dr
+ while 0 <= nf < 8 and 0 <= nr < 8:
+ t = nr * 8 + nf
+ if board[t] is None:
+ moves.append((i, t, None, "move"))
+ else:
+ if board[t][0] != me:
+ moves.append((i, t, None, "capture"))
+ break
+ nf, nr = nf + df, nr + dr
+ return moves
+
+
+def _make(state: dict, move: tuple) -> dict:
+ """The state after a move (the move is assumed legal). Repetition counts are not updated here (see apply)."""
+ a, b, promo, kind = move
+ board = list(state["board"])
+ piece = board[a]
+ turn = state["turn"]
+ captured = board[b]
+ board[b], board[a] = piece, None
+ if promo:
+ board[b] = piece[0] + promo.upper()
+ if kind == "ep":
+ board[b - 8 if turn == 0 else b + 8] = None
+ captured = "P"
+ if kind == "castle":
+ if b > a:
+ board[a + 1], board[a + 3] = board[a + 3], None
+ else:
+ board[a - 1], board[a - 4] = board[a - 4], None
+ rights = state["castling"]
+ for square, right in ((0, "Q"), (7, "K"), (56, "q"), (63, "k")):
+ if a == square or b == square:
+ rights = rights.replace(right, "")
+ if piece[1] == "K":
+ rights = "".join(c for c in rights if c.isupper() != (turn == 0)) # the mover loses both of their rights
+ return {
+ "board": board, "turn": 1 - turn, "castling": rights,
+ "ep": (a + b) // 2 if kind == "double" else None,
+ "halfmove": 0 if piece[1] == "P" or captured else state["halfmove"] + 1,
+ "fullmove": state["fullmove"] + (1 if turn == 1 else 0),
+ "positions": state["positions"], "last": {"from": label(a), "to": label(b)},
+ }
+
+
+def legal_moves(state: dict) -> list[tuple]:
+ out = []
+ mover = state["turn"]
+ for move in _pseudo(state):
+ after = _make(state, move)
+ if not _attacked(after["board"], _king(after["board"], mover), 1 - mover):
+ out.append(move)
+ return out
+
+
+def turn(state: dict) -> int | None:
+ return None if result(state) else state["turn"]
+
+
+def apply(state: dict, seat: int, move) -> tuple[dict, str | None]:
+ if result(state):
+ return state, "The game is over."
+ if seat != state["turn"]:
+ return state, "It's not your turn."
+ if not isinstance(move, dict):
+ return state, "That isn't a move."
+ a, b = sq(str(move.get("from", ""))), sq(str(move.get("to", "")))
+ if a is None or b is None:
+ return state, "Use squares like e2 and e4."
+ promo = str(move.get("promotion") or "").lower() or None
+ if promo not in (None, "q", "r", "b", "n"):
+ return state, "Promote to q, r, b or n."
+ options = [m for m in legal_moves(state) if m[0] == a and m[1] == b]
+ if not options:
+ return state, "That move isn't allowed."
+ chosen = next((m for m in options if m[2] == (promo or "q")), None) if options[0][2] else options[0]
+ if chosen is None:
+ return state, "That move isn't allowed."
+ after = _make(state, chosen)
+ positions = dict(state["positions"])
+ key = _key(after)
+ positions[key] = positions.get(key, 0) + 1
+ after["positions"] = positions
+ return after, None
+
+
+def _insufficient(board: list) -> bool:
+ pieces = [p for p in board if p and p[1] != "K"]
+ if not pieces:
+ return True
+ return len(pieces) == 1 and pieces[0][1] in "BN"
+
+
+def result(state: dict) -> dict | None:
+ if not legal_moves(state):
+ if in_check(state):
+ return {"winner": 1 - state["turn"], "reason": "checkmate"}
+ return {"winner": None, "reason": "stalemate"}
+ if _insufficient(state["board"]):
+ return {"winner": None, "reason": "insufficient_material"}
+ if state["halfmove"] >= 100:
+ return {"winner": None, "reason": "fifty_moves"}
+ if state["positions"].get(_key(state), 0) >= 3:
+ return {"winner": None, "reason": "repetition"}
+ return None
+
+
+def view(state: dict, seat: int) -> dict:
+ """Everything is public. `legal` lists where each piece of the side to move can go, for highlighting."""
+ legal: dict[str, list[str]] = {}
+ for a, b, p, _ in legal_moves(state):
+ legal.setdefault(label(a), [])
+ if label(b) not in legal[label(a)]:
+ legal[label(a)].append(label(b))
+ return {
+ "board": state["board"], "turn": state["turn"], "check": in_check(state), "last": state["last"], "legal": legal,
+ "fullmove": state["fullmove"],
+ }
+
+
+def _material(board: list, color: int) -> int:
+ total = 0
+ for p in board:
+ if p:
+ total += VALUE[p[1]] if p[0] == COLOR[color] else -VALUE[p[1]]
+ return total
+
+
+def _search(state: dict, depth: int, alpha: int, beta: int, me: int) -> int:
+ """Negamax with alpha-beta over material, from the side to move's point of view."""
+ moves = legal_moves(state)
+ if not moves:
+ return -100000 if in_check(state) else 0
+ if depth == 0:
+ return _material(state["board"], state["turn"])
+ best = -10 ** 9
+ for m in sorted(moves, key=lambda m: m[3] != "capture"):
+ score = -_search(_make(state, m), depth - 1, -beta, -alpha, me)
+ best = max(best, score)
+ alpha = max(alpha, score)
+ if alpha >= beta:
+ break
+ return best
+
+
+def bot_move(state: dict, seat: int, level: str = "medium") -> dict | None:
+ moves = legal_moves(state)
+ if not moves:
+ return None
+ if level == "easy":
+ chosen = random.choice([m for m in moves if m[3] == "capture"] or moves)
+ else:
+ scored = [(-_search(_make(state, m), 1, -10 ** 9, 10 ** 9, seat), m) for m in moves]
+ top = max(s for s, _ in scored)
+ chosen = random.choice([m for s, m in scored if s == top])
+ out = {"from": label(chosen[0]), "to": label(chosen[1])}
+ if chosen[2]:
+ out["promotion"] = chosen[2]
+ return out
diff --git a/app/features/rooms/tests/test_chess.py b/app/features/rooms/tests/test_chess.py
new file mode 100644
index 0000000..0fe42d5
--- /dev/null
+++ b/app/features/rooms/tests/test_chess.py
@@ -0,0 +1,123 @@
+import pytest
+
+from app.features.rooms.games import chess as c
+
+
+def play(state, *moves):
+ """Plays moves like "e2e4" (or "e7e8q") for whoever's turn it is; each must be legal."""
+ for m in moves:
+ move = {"from": m[:2], "to": m[2:4]}
+ if len(m) == 5:
+ move["promotion"] = m[4]
+ state, error = c.apply(state, state["turn"], move)
+ assert error is None, f"{m}: {error}"
+ return state
+
+
+def test_perft_counts_match_the_standard_ones():
+ def perft(state, depth):
+ moves = c.legal_moves(state)
+ return len(moves) if depth == 1 else sum(perft(c._make(state, m), depth - 1) for m in moves)
+
+ assert [perft(c.new_state(2), d) for d in (1, 2, 3)] == [20, 400, 8902]
+ kiwipete = c.from_fen("r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1")
+ assert [perft(kiwipete, d) for d in (1, 2)] == [48, 2039] # castling, pins, en passant all at once
+ pos3 = c.from_fen("8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1")
+ assert [perft(pos3, d) for d in (1, 2, 3)] == [14, 191, 2812]
+ promo = c.from_fen("r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1")
+ assert [perft(promo, d) for d in (1, 2)] == [6, 264]
+
+
+def test_turns_and_illegal_moves_are_refused():
+ s = c.new_state(2)
+ assert c.apply(s, 1, {"from": "e7", "to": "e5"})[1] == "It's not your turn."
+ assert c.apply(s, 0, {"from": "e2", "to": "e5"})[1] == "That move isn't allowed." # a pawn can't jump three
+ assert c.apply(s, 0, {"from": "a1", "to": "a3"})[1] == "That move isn't allowed." # the rook is blocked
+ assert c.apply(s, 0, {"from": "z9", "to": "e4"})[1] == "Use squares like e2 and e4."
+ assert c.apply(s, 0, "e2e4")[1] == "That isn't a move."
+ s, error = c.apply(s, 0, {"from": "e2", "to": "e4"})
+ assert error is None and s["turn"] == 1 and s["ep"] == c.sq("e3")
+
+
+def test_scholars_mate():
+ s = play(c.new_state(2), "e2e4", "e7e5", "d1h5", "b8c6", "f1c4", "g8f6", "h5f7")
+ assert c.result(s) == {"winner": 0, "reason": "checkmate"} and c.turn(s) is None
+ assert c.apply(s, 1, {"from": "a7", "to": "a6"})[1] == "The game is over."
+
+
+def test_a_pinned_piece_cannot_move_and_check_must_be_answered():
+ s = c.from_fen("4k3/8/8/8/8/8/4r3/4K2R w - - 0 1") # the white king is in check from the rook on e2
+ assert c.in_check(s)
+ assert c.apply(s, 0, {"from": "h1", "to": "h2"})[1] == "That move isn't allowed."
+ assert c.apply(s, 0, {"from": "e1", "to": "e2"})[1] is None # capturing the checking rook
+ pinned = c.from_fen("4k3/4r3/8/8/8/8/4B3/4K3 w - - 0 1") # the bishop is pinned to its king
+ assert c.apply(pinned, 0, {"from": "e2", "to": "d3"})[1] == "That move isn't allowed."
+
+
+def test_stalemate_is_a_draw():
+ s = c.from_fen("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1")
+ assert c.result(s) == {"winner": None, "reason": "stalemate"}
+
+
+def test_castling_both_ways_and_its_rules():
+ s = c.from_fen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
+ kingside = play(s, "e1g1")
+ assert kingside["board"][c.sq("g1")] == "wK" and kingside["board"][c.sq("f1")] == "wR" and kingside["castling"] == "kq"
+ queenside = play(s, "e1c1")
+ assert queenside["board"][c.sq("c1")] == "wK" and queenside["board"][c.sq("d1")] == "wR"
+ # not through check, not out of check, not after the rook has moved
+ guarded = c.from_fen("r3k2r/8/8/8/8/8/5r2/R3K2R w KQkq - 0 1")
+ assert c.apply(guarded, 0, {"from": "e1", "to": "g1"})[1] == "That move isn't allowed."
+ moved = play(s, "h1h2", "a8a7", "h2h1", "a7a8")
+ assert c.apply(moved, 0, {"from": "e1", "to": "g1"})[1] == "That move isn't allowed."
+ assert c.apply(moved, 0, {"from": "e1", "to": "c1"})[1] is None
+
+
+def test_en_passant_only_straight_after_the_double_step():
+ s = play(c.new_state(2), "e2e4", "a7a6", "e4e5", "d7d5")
+ taken = play(s, "e5d6")
+ assert taken["board"][c.sq("d5")] is None and taken["board"][c.sq("d6")] == "wP"
+ late = play(s, "a2a3", "a6a5")
+ assert c.apply(late, 0, {"from": "e5", "to": "d6"})[1] == "That move isn't allowed."
+
+
+def test_promotion_defaults_to_a_queen_and_can_be_chosen():
+ s = c.from_fen("8/P6k/8/8/8/8/8/K7 w - - 0 1")
+ assert play(s, "a7a8")["board"][c.sq("a8")] == "wQ"
+ assert play(s, "a7a8n")["board"][c.sq("a8")] == "wN"
+ assert c.apply(s, 0, {"from": "a7", "to": "a8", "promotion": "k"})[1] == "Promote to q, r, b or n."
+
+
+def test_draws_that_happen_by_themselves():
+ assert c.result(c.from_fen("8/8/4k3/8/8/4K3/8/8 w - - 0 1"))["reason"] == "insufficient_material"
+ assert c.result(c.from_fen("8/8/4k3/8/8/4K3/6N1/8 w - - 0 1"))["reason"] == "insufficient_material"
+ assert c.result(c.from_fen("8/8/4k3/8/8/4K3/6R1/8 w - - 0 1")) is None
+ assert c.result(c.from_fen("8/8/4k3/8/8/4K3/6R1/8 w - - 100 80"))["reason"] == "fifty_moves"
+ s = c.new_state(2)
+ for _ in range(2):
+ s = play(s, "g1f3", "g8f6", "f3g1", "f6g8")
+ assert c.result(s) == {"winner": None, "reason": "repetition"}
+
+
+def test_view_lists_where_pieces_can_go():
+ v = c.view(c.new_state(2), 0)
+ assert sorted(v["legal"]["e2"]) == ["e3", "e4"] and sorted(v["legal"]["g1"]) == ["f3", "h3"] and "e7" not in v["legal"]
+ assert v["check"] is False and v["turn"] == 0
+
+
+@pytest.mark.parametrize("level", ["easy", "medium"])
+def test_the_bot_plays_legal_moves(level):
+ s = c.new_state(2)
+ for _ in range(6):
+ move = c.bot_move(s, s["turn"], level)
+ s, error = c.apply(s, s["turn"], move)
+ assert error is None
+
+
+def test_the_medium_bot_takes_a_free_queen_and_mates_in_one():
+ s = c.from_fen("4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1")
+ assert c.bot_move(s, 0)["to"] == "d5"
+ mate = c.from_fen("6k1/5ppp/8/8/8/8/8/R3K3 w - - 0 1")
+ move = c.bot_move(mate, 0)
+ s, error = c.apply(mate, 0, move)
+ assert error is None and c.result(s) == {"winner": 0, "reason": "checkmate"}
diff --git a/app/features/rooms/tests/test_rooms.py b/app/features/rooms/tests/test_rooms.py
index 6e66443..2e81fc1 100644
--- a/app/features/rooms/tests/test_rooms.py
+++ b/app/features/rooms/tests/test_rooms.py
@@ -46,6 +46,7 @@ def override_db():
db.add_all(users)
db.flush()
db.add(Game(name=GAME, owner_id=users[0].id, repo_url="https://github.com/ada/ttt"))
+ db.add(Game(name="chess", owner_id=users[0].id, repo_url="https://github.com/ada/chess"))
db.commit()
ids = {u.name.lower(): u.id for u in users}
with TestClient(app) as client:
@@ -286,6 +287,7 @@ def test_the_game_list(env):
listed = client.get("/rooms/games").json()
assert listed == [{"game": g.name, "min": g.min_players, "max": g.max_players} for g in GAMES.values()]
assert {"game": GAME, "min": 2, "max": 2} in listed
+ assert {"game": "chess", "min": 2, "max": 2} in listed
def test_after_a_game_ends_new_and_match_start_fresh(env):
@@ -301,3 +303,90 @@ def test_after_a_game_ends_new_and_match_start_fresh(env):
assert again["status"] == "waiting" and again["room"] != first
with client.websocket_connect(url(ids["tunde"], "match")) as tunde2:
assert tunde2.receive_json() == {"type": "waiting"}
+
+
+def test_a_chess_game_in_a_room(env):
+ client, ids, _ = env
+ with client.websocket_connect(url(ids["ada"], game="chess")) as ada:
+ code = ada.receive_json()["room"]
+ with client.websocket_connect(url(ids["tunde"], code, game="chess")) as tunde:
+ ada.receive_json(), ada.receive_json(), tunde.receive_json()
+ for who, (a, b) in ((ada, ("f2", "f3")), (tunde, ("e7", "e5")), (ada, ("g2", "g4")), (tunde, ("d8", "h4"))):
+ who.send_json({"type": "move", "data": {"from": a, "to": b}})
+ x, y = ada.receive_json(), tunde.receive_json()
+ assert x["result"] == {"winner": 1, "reason": "checkmate"} and y["status"] == "over" # fool's mate
+ assert y["state"]["last"] == {"from": "d8", "to": "h4"} and y["state"]["check"] is True
+
+
+def _finished_game(client, ids):
+ """Ada makes a room, Tunde joins, Ada resigns. Returns the two open connections (a context manager pair)."""
+ ada = client.websocket_connect(url(ids["ada"])).__enter__()
+ code = ada.receive_json()["room"]
+ tunde = client.websocket_connect(url(ids["tunde"], code)).__enter__()
+ ada.receive_json(), ada.receive_json(), tunde.receive_json()
+ ada.send_json({"type": "resign"})
+ assert ada.receive_json()["status"] == tunde.receive_json()["status"] == "over"
+ return ada, tunde, code
+
+
+def test_a_rematch_starts_when_both_ask_and_the_other_player_goes_first(env):
+ client, ids, _ = env
+ ada, tunde, code = _finished_game(client, ids)
+ try:
+ ada.send_json({"type": "rematch"})
+ assert ada.receive_json() == tunde.receive_json() == {"type": "rematch", "seats": [0]}
+ tunde.send_json({"type": "rematch"})
+ a, t = ada.receive_json(), tunde.receive_json()
+ assert a["type"] == t["type"] == "welcome" and a["room"] == t["room"] == code
+ assert a["status"] == "playing" and a["result"] is None and a["state"]["board"] == [None] * 9
+ assert (a["you"], t["you"]) == (1, 0) and a["turn"] == 0 # seats moved round: Tunde goes first now
+ tunde.send_json({"type": "move", "data": {"cell": 4}})
+ assert ada.receive_json()["state"]["board"][4] is not None
+ finally:
+ tunde.__exit__(None, None, None)
+ ada.__exit__(None, None, None)
+
+
+def test_rematch_only_after_the_game_ends(env):
+ client, ids, _ = env
+ with client.websocket_connect(url(ids["ada"])) as ada:
+ code = ada.receive_json()["room"]
+ with client.websocket_connect(url(ids["tunde"], code)) as tunde:
+ ada.receive_json(), ada.receive_json(), tunde.receive_json()
+ ada.send_json({"type": "rematch"})
+ ada.send_json({"type": "move", "data": {"cell": 0}})
+ assert ada.receive_json()["type"] == "state" # the rematch was ignored: the next message is the move
+
+
+def test_a_rematch_against_the_bot_starts_straight_away(env):
+ client, ids, _ = env
+ with client.websocket_connect(url(ids["ada"])) as ada:
+ ada.receive_json()
+ ada.send_json({"type": "bot"})
+ ada.receive_json(), ada.receive_json() # the bot joins, then the game starts
+ ada.send_json({"type": "resign"})
+ assert ada.receive_json()["status"] == "over"
+ ada.send_json({"type": "rematch"})
+ again = ada.receive_json()
+ while again["type"] != "welcome":
+ again = ada.receive_json()
+ assert again["status"] == "playing" and again["you"] == 1
+
+
+def test_a_hosts_rematch_waits_again_and_is_not_closed_for_the_first_wait(env):
+ client, ids, now = env
+ room = core.make_room("quiz")
+ room.seats = [core.Seat(user_id=ids["ada"], name="Ada", style=None), core.Seat(user_id=ids["tunde"], name="Tunde", style=None)]
+ room.status, room.result, room.options = "over", {"winner": 0}, {"questions": []}
+ now[0] += core.WAIT_FOR_FRIEND + 60 # a long quiz: well past the room's first 15 minutes
+ host = core.Conn(user_id=ids["ada"], name="Ada", style=None, ws=None, game="quiz", seat=0)
+ guest = core.Conn(user_id=ids["tunde"], name="Tunde", style=None, ws=None, game="quiz", seat=1)
+ client.portal.call(core.rematch, room, guest) # only the host can ask in a game with a host
+ assert room.status == "over"
+ client.portal.call(core.rematch, room, host)
+ assert room.status == "waiting" and room.result is None and room.options == {"questions": []}
+ client.portal.call(core.sweep)
+ assert room.code in core.rooms # still open: the wait starts again from the rematch
+ now[0] += core.WAIT_FOR_FRIEND + 1
+ client.portal.call(core.sweep)
+ assert room.code not in core.rooms
diff --git a/app/features/tags/service.py b/app/features/tags/service.py
index 4021460..0584072 100644
--- a/app/features/tags/service.py
+++ b/app/features/tags/service.py
@@ -251,7 +251,8 @@ def public_tags(db: Session) -> list[tuple[Tag, int]]:
def links_for(db: Session, game_name: str) -> list[tuple[str, str]]:
"""(slug, label) of a game's approved tags that have a public page: what its page links to."""
public = {t.slug for t, _ in public_tags(db)}
- return [(t.slug, t.label) for t in tags_of(db, game_name) if t.slug in public]
+ # The search-word tags ("Web games", "Browser games"...) are on every game, so they say nothing about this one: no chips for them.
+ return [(t.slug, t.label) for t in tags_of(db, game_name) if t.slug in public and not t.everywhere]
def games_of(db: Session, slug: str, page: int = 1) -> tuple[Tag, int, list[Game]]:
diff --git a/app/features/tags/tests/test_tags.py b/app/features/tags/tests/test_tags.py
index 1a6aea4..00d18da 100644
--- a/app/features/tags/tests/test_tags.py
+++ b/app/features/tags/tests/test_tags.py
@@ -126,7 +126,7 @@ def test_a_games_own_tags_link_only_to_tags_that_have_a_page(env):
approve_all(client, tokens, "nigerian-themed", NAMES[:3])
approve_all(client, tokens, "lagos", NAMES[:1]) # only one game: no page yet
slugs = [t["slug"] for t in client.get(f"/games/{NAMES[0]}/tags").json()]
- assert "nigerian-themed" in slugs and "web-games" in slugs and "lagos" not in slugs
+ assert "nigerian-themed" in slugs and "web-games" not in slugs and "lagos" not in slugs # the every-game search words are not chips
def test_related_tags_are_the_others_in_the_same_group(env):
@@ -258,7 +258,7 @@ def test_the_server_written_game_page_links_to_the_games_categories(env, monkeyp
approve_all(client, tokens, "lagos", NAMES[:1]) # one game only: no page, so no link
html = client.get(f"/pages/games/{NAMES[0]}").text
assert 'aria-label="Categories"' in html and 'href="/categories/nigerian-themed"' in html and ">Nigerian themed<" in html
- assert 'href="/categories/web-games"' in html and "/categories/lagos" not in html
+ assert 'href="/categories/web-games"' not in html and "/categories/lagos" not in html
def test_a_game_page_has_no_categories_block_while_the_flag_is_off(env, monkeypatch):