Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 63 additions & 8 deletions app/features/game_pages/router.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import re
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response
Expand All @@ -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
Expand Down Expand Up @@ -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'<kbd>{p}</kbd>' 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"(<li>(?:Controls|Move|Steer)[^<]*?)(?=\b)(.*?)(</li>)", 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"(?=<h2>)", html)
out = []
for part in parts:
m = re.match(r"<h2>(.*?)</h2>\n?(.*)", part, re.S)
if not m:
if part.strip():
out.append(f'<div class="gs-intro">{part}</div>')
continue
head, rest = m.group(1), m.group(2)
if kind == "faq":
out.append(f'<details class="gs-qa"><summary>{head}</summary><div>{rest}</div></details>')
else:
out.append(f'<section class="gs-card"><h2>{head}</h2>{rest}</section>')
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 "–"
Expand Down Expand Up @@ -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 = (
'<div class="game-tags" aria-label="Categories"><p class="eyebrow">Categories</p><ul>'
+ "".join(f'<li><a href="/categories/{_esc(s)}">{_esc(l)}</a></li>' for s, l in cats) + "</ul></div>"
) if cats else ""
chips = ('<ul class="gs-chips" aria-label="Categories">' + "".join(f'<li><a href="/categories/{_esc(s)}">{_esc(l)}</a></li>' for s, l in cats) + "</ul>") if cats else ""
others = "".join(f'<li><a href="/games/{game.name}/{k}">{_esc(gen.LABELS[k].format(t=title))}</a></li>' for k in kinds if k != kind)
tabs = "".join(
f'<a href="/games/{game.name}/{k}"' + (' aria-current="page"' if k == kind else "") + f">{_esc(gen.SHORT[k])}</a>" for k in gen.KINDS if k in kinds
)
a, b = colors_for(game.name)
cover = _cover_url(game)
art = f'<img src="{_esc(cover)}" alt="{_esc(title)} cover" decoding="async">' if cover else f"<span>{_esc(title[:1])}</span>"
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 = '<dl class="gs-facts">' + "".join(f"<div><dt>{_esc(k)}</dt><dd>{_esc(v)}</dd></div>" for k, v in facts) + "</dl>"
inner = (
'<main class="container game-sub">'
f'<p class="crumbs"><a href="/games">Games</a> › <a href="/games/{game.name}">{_esc(title)}</a> › {_esc(gen.SHORT[kind])}</p>'
'<header class="gs-hero">'
f'<div class="gs-art" style="background: linear-gradient(135deg, {a}, {b})">{art}</div>'
'<div class="gs-hero-body">'
f'<p class="eyebrow">{_esc(title)}</p><h1>{_esc(page.title)}</h1>'
f'<div class="game-sub-body">{gen.to_html(page.body)}</div>'
f'<p class="game-sub-play"><a class="btn" href="{_esc(game_url(game.name))}">Play {_esc(title)}</a> <a class="btn ghost" href="/games/{game.name}">{_esc(title)} on start.ng</a></p>'
+ (f'<p class="gs-lead">{_esc(page.description)}</p>' if page.description else "")
+ chips
+ f'<p class="game-sub-play"><a class="btn" href="{_esc(game_url(game.name))}">Play {_esc(title)}</a> <a class="btn ghost" href="/games/{game.name}">{_esc(title)} on start.ng</a></p>'
"</div></header>"
f'<nav class="gs-tabs" aria-label="Pages about {_esc(title)}">{tabs}</nav>'
f"{facts_html}"
f'<div class="game-sub-body">{_sections(_keycaps(gen.to_html(page.body)), kind)}</div>'
f"{_top_players(db, game, kind)}"
f'<nav class="game-sub-links" aria-label="More about {_esc(title)}"><h2>More about {_esc(title)}</h2><ul>{others}<li><a href="/games/{game.name}">{_esc(title)} leaderboard and details</a></li><li><a href="/categories">Browse games by category</a></li></ul></nav>'
f"{categories}{_more_games(db, game, by)}</main>"
f"{_more_games(db, game, by)}</main>"
)
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"})
Expand Down
14 changes: 14 additions & 0 deletions app/features/game_pages/tests/test_game_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<kbd>W</kbd>" in about and "A zombie survival game set in Lagos" in about
faq = client.get("/pages/games/lagos-apocalypto/faq").text
assert '<details class="gs-qa"><summary>' in faq and "FAQPage" in faq # questions open and close; the markup for search engines stays
35 changes: 34 additions & 1 deletion app/features/rooms/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

Messages (JSON text):
you -> server {type: "move", data: <the game's move>} | {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
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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()):
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions app/features/rooms/games/__init__.py
Original file line number Diff line number Diff line change
@@ -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)}
Loading
Loading