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'
    {part}
    ') + continue + head, rest = m.group(1), m.group(2) + if kind == "faq": + out.append(f'
    {head}
    {rest}
    ') + else: + out.append(f'

    {head}

    {rest}
    ') + return "".join(out) + + +def _since(game: Game) -> str: + d = getattr(game, "created_at", None) + return d.strftime("%B %Y") if d else "" + + def _stat(stat: dict, value) -> str: if value is None: return "–" @@ -151,20 +192,34 @@ def game_sub_page(name: str = GAME, kind: str = KIND, db: Session = Depends(get_ "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}})) cats = _category_links(db, game.name) - categories = ( - '

    Categories

    " - ) if cats else "" + chips = ('") if cats else "" others = "".join(f'
  • {_esc(gen.LABELS[k].format(t=title))}
  • ' 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'{_esc(title)} cover' 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 = '
    ' + "".join(f"
    {_esc(k)}
    {_esc(v)}
    " for k, v in facts) + "
    " inner = ( '
    ' f'

    Games › {_esc(title)} › {_esc(gen.SHORT[kind])}

    ' + '
    ' + f'
    {art}
    ' + '
    ' f'

    {_esc(title)}

    {_esc(page.title)}

    ' - f'
    {gen.to_html(page.body)}
    ' - f'

    Play {_esc(title)} {_esc(title)} on start.ng

    ' + + (f'

    {_esc(page.description)}

    ' if page.description else "") + + chips + + f'

    Play {_esc(title)} {_esc(title)} on start.ng

    ' + "
    " + f'' + f"{facts_html}" + f'
    {_sections(_keycaps(gen.to_html(page.body)), kind)}
    ' 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):