diff --git a/AGENTS.md b/AGENTS.md index 22696c27..3c84c3b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -23 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +25 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40023:40000-40023 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40024:40000-40024 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40023:40000-40023 \ +docker run -d -p 8101:8101 -p 40000-40024:40000-40024 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40023` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40024` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41023:40000-40023 webharbor:dev + -p 8201:8101 -p 41000-41024:40000-40024 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41023); do +for p in $(seq 41000 41024); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 12e3cb0f..ad0e2fd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40023`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41023`). +If a container is already running on `:8101` / `:40000-40024`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41024`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f36384c..1029fa9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40023:40000-40023 webharbor:dev + -p 8101:8101 -p 40000-40024:40000-40024 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index 86c17615..deab9600 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 24 Flask mirror sites + control plane on :8101. +# 25 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -72,6 +72,6 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40023 +EXPOSE 8101 40000-40024 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index a05d266a..b1188e84 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 24 sites today, scaling to 100+ together +- **Community-driven** — 25 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40023:40000-40023 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40024:40000-40024 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40023` to explore 24 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, and Walmart Careers`. +Then point your agent at `http://localhost:40000` through `http://localhost:40024` to explore 25 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, and BoardGameGeek`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -65,7 +65,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ## 🤝 Contribute -We have built 23 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 25 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: diff --git a/agent_demo/README.md b/agent_demo/README.md index 44c25e60..c3d75c28 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -19,7 +19,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40023:40000-40023 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40024:40000-40024 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index 7df0d9ee..0f4e956d 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', + 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', 'boardgamegeek', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/boardgamegeek/_health.py b/sites/boardgamegeek/_health.py new file mode 100644 index 00000000..309e2719 --- /dev/null +++ b/sites/boardgamegeek/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "boardgamegeek"} diff --git a/sites/boardgamegeek/app.py b/sites/boardgamegeek/app.py new file mode 100644 index 00000000..3398289a --- /dev/null +++ b/sites/boardgamegeek/app.py @@ -0,0 +1,1509 @@ +"""BoardGameGeek mirror — Flask application. + +Mirrors the look + feel + feature surface of boardgamegeek.com: + +- Top 1000 ranked games (/browse/boardgame[/page/N]) +- Hot list (/hotness) +- Game item page (/boardgame//) with description, stats, polls, + designers, artists, publishers, categories, mechanics, expansions, ratings +- Per-game ratings/reviews page (/boardgame///ratings) +- Per-game credits (/boardgame///credits) +- Browse by mechanic / category / designer / artist / publisher +- Forums + threads + posts (/forums, /forum/, /thread/) +- GeekLists (/geeklists, /geeklist/) +- User profile / collection / wishlist / plays (/user/, /collection/) +- Search (games / users / geeklists) +- Auth (login / register / logout) +- Rate, comment, add-to-collection, wishlist, reply-to-thread, write-review + +Real data comes from sites/boardgamegeek/scraped_data/bgg.json (BGG api.geekdo.com). +Loaded by seed_data.py — idempotent. +""" +import os +import re +import sys +from datetime import datetime, timedelta +from urllib.parse import urlparse + +from flask import (Flask, render_template, request, redirect, url_for, + flash, abort, jsonify, send_from_directory, Response) +from flask_sqlalchemy import SQLAlchemy +from flask_login import (LoginManager, UserMixin, login_user, logout_user, + login_required, current_user) +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect +from flask_bcrypt import Bcrypt +from wtforms import (StringField, PasswordField, TextAreaField, HiddenField, + IntegerField, SelectField, BooleanField, FloatField) +from wtforms.validators import DataRequired, Length, Optional, Email, NumberRange +from sqlalchemy import or_, and_, desc, asc, func, text +from markupsafe import Markup, escape + + +# seed_data imports the model classes from ``app``. When this file is started +# directly, publish the running module under that name so Flask-SQLAlchemy is +# initialized only once. +if __name__ == '__main__': + sys.modules.setdefault('app', sys.modules[__name__]) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, 'instance')) +app.config['SECRET_KEY'] = 'boardgamegeek-mirror-secret-key' +app.config['SQLALCHEMY_DATABASE_URI'] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'boardgamegeek.db')}" +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None + +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Sign in to access that page.' +csrf = CSRFProtect(app) + + +# Pinned reference "now" so that time_ago strings and join dates are stable +# across rebuilds. The byte-identical reset invariant depends on this. +MIRROR_NOW = datetime(2026, 5, 26, 12, 0, 0) + + +# ----- Models ----- + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + email = db.Column(db.String(200), unique=True, nullable=True, index=True) + password_hash = db.Column(db.String(255), nullable=False) + real_name = db.Column(db.String(200), default='') + country = db.Column(db.String(80), default='') + state = db.Column(db.String(80), default='') + city = db.Column(db.String(80), default='') + isocountry = db.Column(db.String(8), default='') + avatar_filename = db.Column(db.String(200), default='') + about = db.Column(db.Text, default='') + joined_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW) + last_login = db.Column(db.DateTime, default=lambda: MIRROR_NOW) + geekgold = db.Column(db.Integer, default=0) + is_supporter = db.Column(db.Boolean, default=False) + is_admin = db.Column(db.Boolean, default=False) + + @property + def display_location(self): + bits = [self.city, self.state, self.country] + return ', '.join(b for b in bits if b) + + +# Many-to-many association tables (designer/artist/publisher/category/mechanic/family). +game_designers = db.Table('game_designers', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('person_id', db.Integer, db.ForeignKey('people.id'), primary_key=True)) +game_artists = db.Table('game_artists', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('person_id', db.Integer, db.ForeignKey('people.id'), primary_key=True)) +game_publishers = db.Table('game_publishers', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('publisher_id', db.Integer, db.ForeignKey('publishers.id'), primary_key=True)) +game_categories = db.Table('game_categories', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('category_id', db.Integer, db.ForeignKey('categories.id'), primary_key=True)) +game_mechanics = db.Table('game_mechanics', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('mechanic_id', db.Integer, db.ForeignKey('mechanics.id'), primary_key=True)) +game_families = db.Table('game_families', + db.Column('game_id', db.Integer, db.ForeignKey('games.id'), primary_key=True), + db.Column('family_id', db.Integer, db.ForeignKey('families.id'), primary_key=True)) + + +class Person(db.Model): + """Designer / artist (BGG conflates these under linkdata).""" + __tablename__ = 'people' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(200), index=True) + + +class Publisher(db.Model): + __tablename__ = 'publishers' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(200), index=True) + + +class Category(db.Model): + __tablename__ = 'categories' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(120), nullable=False, index=True) + slug = db.Column(db.String(120), index=True) + + +class Mechanic(db.Model): + __tablename__ = 'mechanics' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(120), nullable=False, index=True) + slug = db.Column(db.String(120), index=True) + + +class Family(db.Model): + """BGG 'family' groupings — Crowdfunding:Kickstarter, Components:Miniatures, etc.""" + __tablename__ = 'families' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(200), index=True) + + +class Game(db.Model): + __tablename__ = 'games' + id = db.Column(db.Integer, primary_key=True) + bgg_id = db.Column(db.Integer, unique=True, nullable=False, index=True) + name = db.Column(db.String(500), nullable=False, index=True) + slug = db.Column(db.String(500), nullable=False, index=True) + subtype = db.Column(db.String(40), default='boardgame') # or boardgameexpansion + year_published = db.Column(db.Integer, default=0, index=True) + minplayers = db.Column(db.Integer, default=0) + maxplayers = db.Column(db.Integer, default=0) + minplaytime = db.Column(db.Integer, default=0) + maxplaytime = db.Column(db.Integer, default=0) + minage = db.Column(db.Integer, default=0) + short_description = db.Column(db.Text, default='') + description_html = db.Column(db.Text, default='') + image_filename = db.Column(db.String(200), default='') + thumb_filename = db.Column(db.String(200), default='') + + # Cached stats + avg_rating = db.Column(db.Float, default=0.0, index=True) + bayes_average = db.Column(db.Float, default=0.0, index=True) + weight = db.Column(db.Float, default=0.0, index=True) + weight_votes = db.Column(db.Integer, default=0) + num_ratings = db.Column(db.Integer, default=0) + num_owners = db.Column(db.Integer, default=0) + num_wishing = db.Column(db.Integer, default=0) + num_comments = db.Column(db.Integer, default=0) + overall_rank = db.Column(db.Integer, default=0, index=True) + best_player_count = db.Column(db.String(40), default='') # e.g. '3-4' + recommended_player_count = db.Column(db.String(40), default='') + suggested_age = db.Column(db.String(20), default='') + language_dependence = db.Column(db.String(200), default='') + + # Featured flag for the homepage carousel + featured = db.Column(db.Boolean, default=False) + + designers = db.relationship('Person', secondary=game_designers, backref='designed_games') + artists = db.relationship('Person', secondary=game_artists, backref='illustrated_games') + publishers = db.relationship('Publisher', secondary=game_publishers, backref='games') + categories = db.relationship('Category', secondary=game_categories, backref='games') + mechanics = db.relationship('Mechanic', secondary=game_mechanics, backref='games') + families = db.relationship('Family', secondary=game_families, backref='games') + + @property + def players_str(self): + if self.minplayers == self.maxplayers and self.minplayers: + return f"{self.minplayers}" + if self.minplayers and self.maxplayers: + return f"{self.minplayers}–{self.maxplayers}" + if self.minplayers: + return f"{self.minplayers}+" + return "—" + + @property + def time_str(self): + if self.minplaytime == self.maxplaytime and self.minplaytime: + return f"{self.minplaytime} min" + if self.minplaytime and self.maxplaytime: + return f"{self.minplaytime}–{self.maxplaytime} min" + if self.minplaytime: + return f"{self.minplaytime}+ min" + return "—" + + @property + def weight_label(self): + w = self.weight or 0 + if w == 0: + return 'Unrated' + if w < 1.5: return 'Light' + if w < 2.5: return 'Medium Light' + if w < 3.5: return 'Medium' + if w < 4.2: return 'Medium Heavy' + return 'Heavy' + + @property + def detail_url(self): + return url_for('game_detail', oid=self.bgg_id, slug=self.slug) + + +# Expansions / integrations link table (game -> game) +class GameLink(db.Model): + __tablename__ = 'game_links' + id = db.Column(db.Integer, primary_key=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), index=True, nullable=False) + other_id = db.Column(db.Integer, db.ForeignKey('games.id'), index=True, nullable=False) + kind = db.Column(db.String(40), nullable=False, index=True) + # kind: expansion (other is an expansion of game), + # integration / reimplementation / containedin / contains + + +class Rating(db.Model): + """Numeric rating, optionally with a text review.""" + __tablename__ = 'ratings' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False, index=True) + value = db.Column(db.Float, nullable=False, index=True) # 1-10 + review_html = db.Column(db.Text, default='') # may be empty + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + num_thumbs = db.Column(db.Integer, default=0) # reviewer thumbs + + __table_args__ = (db.UniqueConstraint('user_id', 'game_id'),) + + user = db.relationship('User', backref='ratings') + game = db.relationship('Game', backref='ratings') + + @property + def is_review(self): + return bool(self.review_html and self.review_html.strip()) + + +class Collection(db.Model): + """User's collection entry per game (own/want/wishlist/etc).""" + __tablename__ = 'collections' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False, index=True) + own = db.Column(db.Boolean, default=False) + prevowned = db.Column(db.Boolean, default=False) + want_to_play = db.Column(db.Boolean, default=False) + want_to_buy = db.Column(db.Boolean, default=False) + wishlist = db.Column(db.Boolean, default=False) + wishlist_priority = db.Column(db.Integer, default=0) + preordered = db.Column(db.Boolean, default=False) + for_trade = db.Column(db.Boolean, default=False) + comment = db.Column(db.Text, default='') + acquired_on = db.Column(db.String(40), default='') + updated_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW) + + __table_args__ = (db.UniqueConstraint('user_id', 'game_id'),) + user = db.relationship('User', backref='collection') + game = db.relationship('Game', backref='collected_by') + + +class Play(db.Model): + __tablename__ = 'plays' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False, index=True) + played_on = db.Column(db.Date, default=lambda: MIRROR_NOW.date(), index=True) + quantity = db.Column(db.Integer, default=1) + length_minutes = db.Column(db.Integer, default=0) + num_players = db.Column(db.Integer, default=0) + location = db.Column(db.String(200), default='') + comments = db.Column(db.Text, default='') + incomplete = db.Column(db.Boolean, default=False) + no_winstats = db.Column(db.Boolean, default=False) + + user = db.relationship('User', backref='plays') + game = db.relationship('Game', backref='plays') + + +# ----- Forums ----- + +class Forum(db.Model): + __tablename__ = 'forums' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False, index=True) + description = db.Column(db.Text, default='') + section = db.Column(db.String(80), index=True) # 'general', 'reviews', 'strategy', etc. + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=True, index=True) + sort_order = db.Column(db.Integer, default=100) + num_threads = db.Column(db.Integer, default=0) + num_posts = db.Column(db.Integer, default=0) + game = db.relationship('Game', backref='forums') + + +class Thread(db.Model): + __tablename__ = 'threads' + id = db.Column(db.Integer, primary_key=True) + forum_id = db.Column(db.Integer, db.ForeignKey('forums.id'), nullable=False, index=True) + subject = db.Column(db.String(300), nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + is_pinned = db.Column(db.Boolean, default=False, index=True) + is_locked = db.Column(db.Boolean, default=False) + is_hot = db.Column(db.Boolean, default=False) + num_posts = db.Column(db.Integer, default=1) + num_views = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + last_post_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + + forum = db.relationship('Forum', backref='threads') + author = db.relationship('User', backref='threads') + + +class Post(db.Model): + __tablename__ = 'posts' + id = db.Column(db.Integer, primary_key=True) + thread_id = db.Column(db.Integer, db.ForeignKey('threads.id'), nullable=False, index=True) + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + body_html = db.Column(db.Text, default='') + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + edited_at = db.Column(db.DateTime, nullable=True) + thumbs = db.Column(db.Integer, default=0) + + thread = db.relationship('Thread', backref='posts') + author = db.relationship('User', backref='posts') + + +# ----- GeekLists ----- + +class GeekList(db.Model): + __tablename__ = 'geeklists' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(300), nullable=False, index=True) + description_html = db.Column(db.Text, default='') + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + created_at = db.Column(db.DateTime, default=lambda: MIRROR_NOW, index=True) + num_thumbs = db.Column(db.Integer, default=0, index=True) + num_items = db.Column(db.Integer, default=0) + + author = db.relationship('User', backref='geeklists') + + +class GeekListItem(db.Model): + __tablename__ = 'geeklist_items' + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey('geeklists.id'), nullable=False, index=True) + game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=True, index=True) + body_html = db.Column(db.Text, default='') + position = db.Column(db.Integer, default=0) + num_thumbs = db.Column(db.Integer, default=0) + + geeklist = db.relationship('GeekList', backref='items') + game = db.relationship('Game') + + +# ----- Thumbs (likes on posts/reviews/geeklists) ----- + +class Thumb(db.Model): + __tablename__ = 'thumbs' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + kind = db.Column(db.String(20), nullable=False, index=True) # post|rating|geeklist|geeklist_item + target_id = db.Column(db.Integer, nullable=False, index=True) + __table_args__ = (db.UniqueConstraint('user_id', 'kind', 'target_id'),) + + +# ----- Forms ----- + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(2, 80)]) + password = PasswordField('Password', validators=[DataRequired()]) + + +class RegisterForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(3, 80)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(3, 200)]) + password = PasswordField('Password', validators=[DataRequired(), Length(6, 128)]) + real_name = StringField('Real name', validators=[Optional(), Length(0, 200)]) + country = StringField('Country', validators=[Optional(), Length(0, 80)]) + + +class ProfileForm(FlaskForm): + real_name = StringField('Real name', validators=[Optional(), Length(0, 200)]) + country = StringField('Country', validators=[Optional(), Length(0, 80)]) + state = StringField('State', validators=[Optional(), Length(0, 80)]) + city = StringField('City', validators=[Optional(), Length(0, 80)]) + about = TextAreaField('About', validators=[Optional(), Length(0, 4000)]) + + +class RatingForm(FlaskForm): + value = FloatField('Rating', validators=[DataRequired(), NumberRange(1, 10)]) + review = TextAreaField('Review', validators=[Optional(), Length(0, 20000)]) + + +class CollectionForm(FlaskForm): + own = BooleanField('Own', default=False) + prevowned = BooleanField('Previously Owned', default=False) + want_to_play = BooleanField('Want To Play', default=False) + want_to_buy = BooleanField('Want To Buy', default=False) + wishlist = BooleanField('Wishlist', default=False) + wishlist_priority = SelectField('Priority', choices=[ + ('0','Not on wishlist'), + ('1','Must have'),('2','Love to have'), + ('3','Like to have'),('4','Thinking about it'), + ('5','Dont buy this'), + ], default='0') + preordered = BooleanField('Pre-ordered', default=False) + for_trade = BooleanField('For Trade', default=False) + comment = TextAreaField('Comment', validators=[Optional(), Length(0, 4000)]) + acquired_on = StringField('Acquired on', validators=[Optional(), Length(0, 40)]) + + +class ThreadForm(FlaskForm): + subject = StringField('Subject', validators=[DataRequired(), Length(2, 300)]) + body = TextAreaField('Post', validators=[DataRequired(), Length(1, 20000)]) + + +class PostForm(FlaskForm): + body = TextAreaField('Reply', validators=[DataRequired(), Length(1, 20000)]) + parent_id = HiddenField() + + +class GeekListForm(FlaskForm): + title = StringField('Title', validators=[DataRequired(), Length(3, 300)]) + description = TextAreaField('Description', validators=[Optional(), Length(0, 8000)]) + + +class PlayForm(FlaskForm): + played_on = StringField('Date (YYYY-MM-DD)', validators=[DataRequired(), Length(10, 10)]) + quantity = IntegerField('Quantity', validators=[Optional(), NumberRange(1, 50)], default=1) + length_minutes = IntegerField('Length (min)', validators=[Optional(), NumberRange(0, 1200)], default=0) + num_players = IntegerField('Players', validators=[Optional(), NumberRange(0, 20)], default=0) + location = StringField('Location', validators=[Optional(), Length(0, 200)]) + comments = TextAreaField('Comments', validators=[Optional(), Length(0, 4000)]) + + +# ----- Auth ----- + +@login_manager.user_loader +def load_user(uid): + return db.session.get(User, int(uid)) + + +# ----- Helpers ----- + +STOP_WORDS = {'the','a','an','in','on','at','to','for','of','and','or', + 'is','it','by','with','as','be','this','that','are','was', + 'were','from','how','what','why','we','i','you','they', + 'about','vs','game'} + + +def tokenize(query: str): + return [t.lower() for t in re.split(r'\W+', query or '') + if t.lower() not in STOP_WORDS and len(t) > 1] + + +def _safe_next(target: str | None, fallback: str) -> str: + if not target: + return fallback + parsed = urlparse(target) + if parsed.scheme or parsed.netloc: + return fallback + if not target.startswith('/'): + return fallback + return target + + +def slugify(s: str) -> str: + s = s.lower() + s = re.sub(r'[^a-z0-9]+', '-', s).strip('-') + return s or 'item' + + +def _time_ago(dt) -> str: + if not dt: + return '' + if isinstance(dt, str): + try: + dt = datetime.fromisoformat(dt) + except Exception: + return dt + delta = MIRROR_NOW - dt + secs = int(delta.total_seconds()) + if secs < 60: + return 'just now' + if secs < 3600: + m = secs // 60 + return f"{m} minute{'s' if m != 1 else ''} ago" + if secs < 86400: + h = secs // 3600 + return f"{h} hour{'s' if h != 1 else ''} ago" + d = secs // 86400 + if d < 30: + return f"{d} day{'s' if d != 1 else ''} ago" + if d < 365: + mo = d // 30 + return f"{mo} month{'s' if mo != 1 else ''} ago" + y = d // 365 + return f"{y} year{'s' if y != 1 else ''} ago" + + +@app.template_filter('time_ago') +def _tpl_time_ago(dt): + return _time_ago(dt) + + +@app.template_filter('rating_color') +def _tpl_rating_color(value): + """Hex color for a BGG-style rating chip (1-10).""" + try: + v = float(value or 0) + except (TypeError, ValueError): + v = 0 + if v >= 9.0: return '#249563' + if v >= 8.0: return '#2fc482' + if v >= 7.0: return '#1d8acd' + if v >= 6.0: return '#5369a2' + if v >= 5.0: return '#5d69a3' + if v >= 4.0: return '#df4751' + if v >= 3.0: return '#df4751' + if v >= 2.0: return '#db303f' + if v >= 1.0: return '#8c2317' + return '#a0a0a0' + + +@app.template_filter('safe_html') +def _tpl_safe_html(text): + # We trust the seed data (it's static), so render as-is. + return Markup(text or '') + + +@app.template_filter('one_decimal') +def _tpl_one_decimal(v): + try: + return f"{float(v):.1f}" + except (TypeError, ValueError): + return '—' + + +@app.template_filter('two_decimal') +def _tpl_two_decimal(v): + try: + return f"{float(v):.2f}" + except (TypeError, ValueError): + return '—' + + +@app.template_filter('thousands') +def _tpl_thousands(v): + try: + return f"{int(v):,}" + except (TypeError, ValueError): + return '0' + + +@app.context_processor +def inject_globals(): + nav_hot_games = (Game.query.filter(Game.featured == True) + .order_by(Game.overall_rank.asc()).limit(12).all()) + if not nav_hot_games: + nav_hot_games = (Game.query.filter(Game.overall_rank > 0) + .order_by(Game.overall_rank.asc()).limit(12).all()) + return { + 'site_name': 'BoardGameGeek', + 'mirror_now': MIRROR_NOW, + 'current_year': MIRROR_NOW.year, + 'nav_hot_games': nav_hot_games, + } + + +def _scored_game_search(query: str, page: int = 1, per_page: int = 30): + tokens = tokenize(query) + if not tokens: + return [], 0 + conds = [] + for t in tokens: + like = f"%{t}%" + conds.append(Game.name.ilike(like)) + conds.append(Game.short_description.ilike(like)) + base = Game.query.filter(or_(*conds)) + cands = base.limit(3000).all() + scored = [] + for g in cands: + hay_title = (g.name or '').lower() + hay_other = (g.short_description or '').lower() + score = 0 + for t in tokens: + if t in hay_title: + score += 5 + hay_title.count(t) + if t in hay_other: + score += hay_other.count(t) + if g.bayes_average: + score += min(int(g.bayes_average), 10) * 0.1 + if score > 0: + scored.append((g, score)) + scored.sort(key=lambda x: -x[1]) + total = len(scored) + start = (page - 1) * per_page + items = [s[0] for s in scored[start:start + per_page]] + return items, total + + +# ----- Routes: home / browse ----- + +@app.route('/') +def index(): + hot_games = Game.query.filter(Game.featured == True).order_by(Game.overall_rank.asc()).limit(15).all() + if len(hot_games) < 12: + # Fallback: top by rank + extra = Game.query.filter(Game.overall_rank > 0).order_by(Game.overall_rank.asc()).limit(15).all() + seen = {g.id for g in hot_games} + for g in extra: + if g.id not in seen: + hot_games.append(g) + if len(hot_games) >= 15: + break + top_overall = Game.query.filter(Game.overall_rank > 0) \ + .order_by(Game.overall_rank.asc()).limit(25).all() + recent_lists = GeekList.query.order_by(GeekList.created_at.desc()).limit(10).all() + active_threads = Thread.query.order_by(Thread.last_post_at.desc()).limit(15).all() + recent_reviews = Rating.query.filter(Rating.review_html != '') \ + .order_by(Rating.created_at.desc()).limit(10).all() + return render_template('index.html', + hot_games=hot_games, + top_overall=top_overall, + recent_lists=recent_lists, + active_threads=active_threads, + recent_reviews=recent_reviews) + + +@app.route('/browse/boardgame') +@app.route('/browse/boardgame/page/') +def browse(page=1): + sort = request.args.get('sort', 'rank') + direction = request.args.get('dir', 'asc') + page = max(1, page) + per_page = 100 + q = Game.query.filter(Game.subtype == 'boardgame') + if sort == 'rank': + q = q.filter(Game.overall_rank > 0) + q = q.order_by(Game.overall_rank.asc() if direction == 'asc' else Game.overall_rank.desc()) + elif sort == 'name': + q = q.order_by(Game.name.asc() if direction == 'asc' else Game.name.desc()) + elif sort == 'year': + q = q.order_by(Game.year_published.desc() if direction == 'desc' else Game.year_published.asc()) + elif sort == 'average': + q = q.order_by(Game.avg_rating.desc() if direction == 'desc' else Game.avg_rating.asc()) + elif sort == 'numvoters': + q = q.order_by(Game.num_ratings.desc() if direction == 'desc' else Game.num_ratings.asc()) + elif sort == 'weight': + q = q.order_by(Game.weight.desc() if direction == 'desc' else Game.weight.asc()) + else: + q = q.order_by(Game.overall_rank.asc()) + total = q.count() + games = q.limit(per_page).offset((page - 1) * per_page).all() + start_rank = (page - 1) * per_page + 1 + return render_template('browse.html', + games=games, page=page, per_page=per_page, + total=total, sort=sort, direction=direction, + start_rank=start_rank, + has_next=page * per_page < total, + has_prev=page > 1) + + +@app.route('/hotness') +@app.route('/hot') +def hotness(): + games = Game.query.filter(Game.featured == True) \ + .order_by(Game.overall_rank.asc()).limit(50).all() + if not games: + games = Game.query.filter(Game.overall_rank > 0) \ + .order_by(Game.overall_rank.asc()).limit(50).all() + return render_template('hotness.html', games=games) + + +# ----- Routes: game detail + sub-pages ----- + +def _get_game_or_404(oid: int, slug: str | None): + g = Game.query.filter_by(bgg_id=oid).first() + if not g: + abort(404) + # Don't enforce slug match — agents may navigate by id alone. + return g + + +@app.route('/boardgame/') +@app.route('/boardgame//') +def game_detail(oid, slug=None): + g = _get_game_or_404(oid, slug) + # Build expansions + integrations from GameLink + expansions = [] + for link in GameLink.query.filter_by(game_id=g.id, kind='expansion').all(): + other = db.session.get(Game, link.other_id) + if other: + expansions.append(other) + integrations = [] + for link in GameLink.query.filter_by(game_id=g.id, kind='integration').all(): + other = db.session.get(Game, link.other_id) + if other: + integrations.append(other) + reviews = (Rating.query.filter_by(game_id=g.id) + .filter(Rating.review_html != '') + .order_by(Rating.num_thumbs.desc(), Rating.value.desc()) + .limit(5).all()) + related_forums = Forum.query.filter_by(game_id=g.id).order_by(Forum.sort_order).all() + threads = [] + if related_forums: + forum_ids = [f.id for f in related_forums] + threads = (Thread.query.filter(Thread.forum_id.in_(forum_ids)) + .order_by(Thread.last_post_at.desc()).limit(8).all()) + # Cached user state + my_rating = None + my_collection = None + if current_user.is_authenticated: + my_rating = Rating.query.filter_by(user_id=current_user.id, game_id=g.id).first() + my_collection = Collection.query.filter_by(user_id=current_user.id, game_id=g.id).first() + return render_template('item.html', g=g, + expansions=expansions, integrations=integrations, + reviews=reviews, related_forums=related_forums, + threads=threads, + my_rating=my_rating, my_collection=my_collection, + rating_form=RatingForm(), + collection_form=CollectionForm()) + + +@app.route('/boardgame///ratings') +@app.route('/boardgame//ratings') +def game_ratings(oid, slug=None): + g = _get_game_or_404(oid, slug) + sort = request.args.get('sort', 'rating') + page = max(1, request.args.get('p', 1, type=int)) + per_page = 50 + q = Rating.query.filter_by(game_id=g.id) + if sort == 'rating': + q = q.order_by(Rating.value.desc(), Rating.num_thumbs.desc()) + elif sort == 'lowest': + q = q.order_by(Rating.value.asc()) + elif sort == 'recent': + q = q.order_by(Rating.created_at.desc()) + elif sort == 'thumbs': + q = q.order_by(Rating.num_thumbs.desc(), Rating.value.desc()) + else: + q = q.order_by(Rating.value.desc()) + total = q.count() + ratings = q.limit(per_page).offset((page - 1) * per_page).all() + # Histogram + histogram = {i: 0 for i in range(1, 11)} + for r in Rating.query.filter_by(game_id=g.id).all(): + bucket = max(1, min(10, int(round(r.value or 0)))) + histogram[bucket] += 1 + return render_template('ratings.html', g=g, ratings=ratings, + sort=sort, page=page, per_page=per_page, + total=total, histogram=histogram, + has_next=page * per_page < total, + has_prev=page > 1) + + +@app.route('/boardgame///credits') +@app.route('/boardgame//credits') +def game_credits(oid, slug=None): + g = _get_game_or_404(oid, slug) + return render_template('credits.html', g=g) + + +@app.route('/boardgame///expansions') +@app.route('/boardgame//expansions') +def game_expansions(oid, slug=None): + g = _get_game_or_404(oid, slug) + rows = [] + for link in GameLink.query.filter_by(game_id=g.id, kind='expansion').all(): + other = db.session.get(Game, link.other_id) + if other: + rows.append(other) + rows.sort(key=lambda o: (o.year_published or 9999, o.name)) + return render_template('expansions.html', g=g, expansions=rows) + + +@app.route('/boardgame///forums') +@app.route('/boardgame//forums') +def game_forums(oid, slug=None): + g = _get_game_or_404(oid, slug) + forums = Forum.query.filter_by(game_id=g.id).order_by(Forum.sort_order).all() + return render_template('game_forums.html', g=g, forums=forums) + + +# ----- Browse by category / mechanic / designer / artist / publisher / family ----- + +def _browse_by(entity_query, page_url_name, header_label, slug_url=None, + entity=None, page=1, per_page=50, sort='rank'): + games = entity.games if entity else [] + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999, -(g.bayes_average or 0))) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'name': + games = sorted(games, key=lambda g: g.name.lower()) + total = len(games) + start = (page - 1) * per_page + page_items = games[start:start + per_page] + return page_items, total + + +@app.route('/boardgamecategory/') +@app.route('/boardgamecategory//') +def category_detail(cid, slug=None): + c = Category.query.filter_by(bgg_id=cid).first_or_404() + page = max(1, request.args.get('p', 1, type=int)) + sort = request.args.get('sort', 'rank') + games, total = _browse_by(None, 'category_detail', 'Category', + entity=c, page=page, sort=sort) + return render_template('property.html', kind='category', + entity=c, games=games, page=page, total=total, + sort=sort, has_next=page * 50 < total) + + +@app.route('/boardgamemechanic/') +@app.route('/boardgamemechanic//') +def mechanic_detail(mid, slug=None): + m = Mechanic.query.filter_by(bgg_id=mid).first_or_404() + page = max(1, request.args.get('p', 1, type=int)) + sort = request.args.get('sort', 'rank') + games, total = _browse_by(None, 'mechanic_detail', 'Mechanism', + entity=m, page=page, sort=sort) + return render_template('property.html', kind='mechanic', + entity=m, games=games, page=page, total=total, + sort=sort, has_next=page * 50 < total) + + +@app.route('/boardgamedesigner/') +@app.route('/boardgamedesigner//') +def designer_detail(pid, slug=None): + p = Person.query.filter_by(bgg_id=pid).first_or_404() + games = p.designed_games + sort = request.args.get('sort', 'rank') + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + elif sort == 'name': + games = sorted(games, key=lambda g: g.name.lower()) + return render_template('person.html', kind='designer', person=p, + games=games, sort=sort) + + +@app.route('/boardgameartist/') +@app.route('/boardgameartist//') +def artist_detail(pid, slug=None): + p = Person.query.filter_by(bgg_id=pid).first_or_404() + games = p.illustrated_games + sort = request.args.get('sort', 'rank') + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + elif sort == 'name': + games = sorted(games, key=lambda g: g.name.lower()) + return render_template('person.html', kind='artist', person=p, + games=games, sort=sort) + + +@app.route('/boardgamepublisher/') +@app.route('/boardgamepublisher//') +def publisher_detail(pid, slug=None): + p = Publisher.query.filter_by(bgg_id=pid).first_or_404() + games = p.games + sort = request.args.get('sort', 'rank') + if sort == 'rank': + games = sorted(games, key=lambda g: (g.overall_rank or 99999)) + elif sort == 'year': + games = sorted(games, key=lambda g: -(g.year_published or 0)) + elif sort == 'average': + games = sorted(games, key=lambda g: -(g.avg_rating or 0)) + return render_template('publisher.html', publisher=p, games=games, sort=sort) + + +# ----- Index pages for taxonomies ----- + +@app.route('/boardgamecategory') +def categories_index(): + cats = Category.query.order_by(Category.name).all() + return render_template('taxonomy_index.html', kind='category', + title='Board Game Categories', + items=cats, url_name='category_detail') + + +@app.route('/boardgamemechanic') +def mechanics_index(): + mechs = Mechanic.query.order_by(Mechanic.name).all() + return render_template('taxonomy_index.html', kind='mechanic', + title='Board Game Mechanisms', + items=mechs, url_name='mechanic_detail') + + +@app.route('/boardgamedesigner') +def designers_index(): + q = (request.args.get('q') or '').strip() + query = Person.query + if q: + query = query.filter(Person.name.ilike(f'%{q}%')) + people = query.order_by(Person.name).all() + return render_template('taxonomy_index.html', kind='designer', + title='Board Game Designers', + items=people, url_name='designer_detail', + filter_q=q) + + +@app.route('/boardgamepublisher') +def publishers_index(): + q = (request.args.get('q') or '').strip() + query = Publisher.query + if q: + query = query.filter(Publisher.name.ilike(f'%{q}%')) + pubs = query.order_by(Publisher.name).all() + return render_template('taxonomy_index.html', kind='publisher', + title='Board Game Publishers', + items=pubs, url_name='publisher_detail', + filter_q=q) + + +# ----- Search ----- + +@app.route('/search') +def search(): + q = (request.args.get('q') or '').strip() + tab = request.args.get('type', 'boardgame') + page = max(1, request.args.get('p', 1, type=int)) + per_page = 30 + if not q: + return render_template('search.html', q='', tab=tab, total=0, + games=[], users=[], lists=[], page=page, has_next=False) + if tab == 'user': + like = f"%{q}%" + base = User.query.filter(or_(User.username.ilike(like), + User.real_name.ilike(like))) + total = base.count() + users = base.order_by(User.username.asc()).limit(per_page).offset((page-1)*per_page).all() + return render_template('search.html', q=q, tab=tab, total=total, + games=[], users=users, lists=[], + page=page, has_next=page * per_page < total) + if tab == 'geeklist': + like = f"%{q}%" + base = GeekList.query.filter(GeekList.title.ilike(like)) + total = base.count() + lists = base.order_by(GeekList.num_thumbs.desc()).limit(per_page).offset((page-1)*per_page).all() + return render_template('search.html', q=q, tab=tab, total=total, + games=[], users=[], lists=lists, + page=page, has_next=page * per_page < total) + # default: boardgame + games, total = _scored_game_search(q, page=page, per_page=per_page) + return render_template('search.html', q=q, tab=tab, total=total, + games=games, users=[], lists=[], + page=page, has_next=page * per_page < total) + + +@app.route('/geeksearch.php') +def legacy_search(): + return redirect(url_for('search', q=request.args.get('q', ''), type=request.args.get('action','boardgame'))) + + +# ----- Forums ----- + +@app.route('/forums') +def forums_index(): + # Group forums by section + sections = {} + for f in Forum.query.filter(Forum.game_id.is_(None)).order_by(Forum.sort_order).all(): + sections.setdefault(f.section or 'General', []).append(f) + return render_template('forums_index.html', sections=sections) + + +@app.route('/forum/') +def forum_detail(fid): + f = db.session.get(Forum, fid) or abort(404) + page = max(1, request.args.get('p', 1, type=int)) + per_page = 50 + q = Thread.query.filter_by(forum_id=fid) \ + .order_by(Thread.is_pinned.desc(), Thread.last_post_at.desc()) + total = q.count() + threads = q.limit(per_page).offset((page - 1) * per_page).all() + return render_template('forum.html', forum=f, threads=threads, + page=page, total=total, + has_next=page * per_page < total) + + +@app.route('/thread/') +def thread_detail(tid): + t = db.session.get(Thread, tid) or abort(404) + posts = Post.query.filter_by(thread_id=tid) \ + .order_by(Post.created_at.asc()).all() + return render_template('thread.html', thread=t, posts=posts, + reply_form=PostForm()) + + +@app.route('/thread//reply', methods=['POST']) +@login_required +def thread_reply(tid): + t = db.session.get(Thread, tid) or abort(404) + form = PostForm() + if form.validate_on_submit() and not t.is_locked: + p = Post(thread_id=tid, author_id=current_user.id, + body_html=escape_paragraphs(form.body.data), + created_at=MIRROR_NOW) + db.session.add(p) + t.num_posts = (t.num_posts or 0) + 1 + t.last_post_at = MIRROR_NOW + f = db.session.get(Forum, t.forum_id) + if f: + f.num_posts = (f.num_posts or 0) + 1 + db.session.commit() + flash('Reply posted.', 'success') + elif t.is_locked: + flash('Thread is locked.', 'error') + else: + flash('Reply text is required.', 'error') + return redirect(url_for('thread_detail', tid=tid) + f'#post-{Post.query.order_by(Post.id.desc()).first().id}' if Post.query.filter_by(thread_id=tid).count() else url_for('thread_detail', tid=tid)) + + +@app.route('/forum//new', methods=['GET', 'POST']) +@login_required +def thread_new(fid): + f = db.session.get(Forum, fid) or abort(404) + form = ThreadForm() + if form.validate_on_submit(): + t = Thread(forum_id=fid, subject=form.subject.data.strip(), + author_id=current_user.id, + created_at=MIRROR_NOW, last_post_at=MIRROR_NOW, num_posts=1) + db.session.add(t) + db.session.flush() + p = Post(thread_id=t.id, author_id=current_user.id, + body_html=escape_paragraphs(form.body.data), + created_at=MIRROR_NOW) + db.session.add(p) + f.num_threads = (f.num_threads or 0) + 1 + f.num_posts = (f.num_posts or 0) + 1 + db.session.commit() + flash('Thread posted.', 'success') + return redirect(url_for('thread_detail', tid=t.id)) + return render_template('thread_new.html', forum=f, form=form) + + +# ----- GeekLists ----- + +@app.route('/geeklists') +def geeklists_index(): + sort = request.args.get('sort', 'recent') + page = max(1, request.args.get('p', 1, type=int)) + per_page = 20 + q = GeekList.query + if sort == 'thumbs': + q = q.order_by(GeekList.num_thumbs.desc()) + elif sort == 'items': + q = q.order_by(GeekList.num_items.desc()) + else: + q = q.order_by(GeekList.created_at.desc()) + total = q.count() + items = q.limit(per_page).offset((page - 1) * per_page).all() + return render_template('geeklists.html', lists=items, page=page, + total=total, sort=sort, + has_next=page * per_page < total) + + +@app.route('/geeklist/') +def geeklist_detail(lid): + l = db.session.get(GeekList, lid) or abort(404) + items = GeekListItem.query.filter_by(list_id=lid) \ + .order_by(GeekListItem.position.asc()).all() + return render_template('geeklist.html', l=l, items=items) + + +@app.route('/geeklist/new', methods=['GET', 'POST']) +@login_required +def geeklist_new(): + form = GeekListForm() + if form.validate_on_submit(): + l = GeekList(title=form.title.data.strip(), + description_html=escape_paragraphs(form.description.data or ''), + author_id=current_user.id, created_at=MIRROR_NOW, + num_items=0, num_thumbs=0) + db.session.add(l) + db.session.commit() + flash('GeekList created.', 'success') + return redirect(url_for('geeklist_detail', lid=l.id)) + return render_template('geeklist_new.html', form=form) + + +@app.route('/geeklist//add', methods=['POST']) +@login_required +def geeklist_add_item(lid): + l = db.session.get(GeekList, lid) or abort(404) + if l.author_id != current_user.id: + abort(403) + bgg_id = request.form.get('bgg_id', type=int) + body = request.form.get('body', '').strip() + g = Game.query.filter_by(bgg_id=bgg_id).first() + if not g: + flash('Game not found.', 'error') + return redirect(url_for('geeklist_detail', lid=lid)) + pos = (l.num_items or 0) + 1 + item = GeekListItem(list_id=lid, game_id=g.id, body_html=escape_paragraphs(body), + position=pos, num_thumbs=0) + db.session.add(item) + l.num_items = pos + db.session.commit() + flash('Added to list.', 'success') + return redirect(url_for('geeklist_detail', lid=lid)) + + +# ----- User ----- + +@app.route('/user/') +def user_profile(username): + u = User.query.filter_by(username=username).first_or_404() + own = Collection.query.filter_by(user_id=u.id, own=True).count() + want = Collection.query.filter_by(user_id=u.id, want_to_buy=True).count() + wishlist = Collection.query.filter_by(user_id=u.id, wishlist=True).count() + plays_count = Play.query.filter_by(user_id=u.id).count() + rated = Rating.query.filter_by(user_id=u.id).count() + reviews = Rating.query.filter_by(user_id=u.id).filter(Rating.review_html != '').count() + recent_plays = Play.query.filter_by(user_id=u.id).order_by(Play.played_on.desc()).limit(5).all() + top_rated = Rating.query.filter_by(user_id=u.id).order_by(Rating.value.desc()).limit(10).all() + geeklists_query = GeekList.query.filter_by(author_id=u.id) + geeklists_count = geeklists_query.count() + geeklists = geeklists_query.order_by(GeekList.created_at.desc()).limit(5).all() + return render_template('user.html', u=u, + own=own, want=want, wishlist=wishlist, + plays_count=plays_count, rated=rated, reviews=reviews, + recent_plays=recent_plays, top_rated=top_rated, + geeklists=geeklists, + geeklists_count=geeklists_count) + + +@app.route('/collection/') +def collection_detail(username): + u = User.query.filter_by(username=username).first_or_404() + status = request.args.get('status', 'own') + sort = request.args.get('sort', 'name') + q = Collection.query.filter_by(user_id=u.id) + if status == 'own': + q = q.filter_by(own=True) + elif status == 'prevowned': + q = q.filter_by(prevowned=True) + elif status == 'wishlist': + q = q.filter_by(wishlist=True) + elif status == 'wanttoplay': + q = q.filter_by(want_to_play=True) + elif status == 'wanttobuy': + q = q.filter_by(want_to_buy=True) + elif status == 'preorder': + q = q.filter_by(preordered=True) + elif status == 'fortrade': + q = q.filter_by(for_trade=True) + elif status == 'rated': + rated_game_ids = [r.game_id for r in Rating.query.filter_by(user_id=u.id).all()] + q = q.filter(Collection.game_id.in_(rated_game_ids)) + entries = q.all() + # Join with Game for sorting/display + games_by_entry = [] + for e in entries: + g = db.session.get(Game, e.game_id) + if g: + rating = Rating.query.filter_by(user_id=u.id, game_id=g.id).first() + games_by_entry.append({'entry': e, 'game': g, 'rating': rating}) + if sort == 'name': + games_by_entry.sort(key=lambda x: x['game'].name.lower()) + elif sort == 'rating': + games_by_entry.sort(key=lambda x: -(x['rating'].value if x['rating'] else 0)) + elif sort == 'rank': + games_by_entry.sort(key=lambda x: x['game'].overall_rank or 99999) + elif sort == 'year': + games_by_entry.sort(key=lambda x: -(x['game'].year_published or 0)) + elif sort == 'recent': + # Most-recently-updated collection entry first. + games_by_entry.sort(key=lambda x: x['entry'].updated_at or MIRROR_NOW, reverse=True) + elif sort == 'acquired': + games_by_entry.sort(key=lambda x: x['entry'].acquired_on or '', reverse=True) + return render_template('collection.html', u=u, entries=games_by_entry, + status=status, sort=sort, total=len(games_by_entry)) + + +@app.route('/plays/') +def plays_detail(username): + u = User.query.filter_by(username=username).first_or_404() + plays = Play.query.filter_by(user_id=u.id).order_by(Play.played_on.desc()).limit(200).all() + plays_with_game = [(p, db.session.get(Game, p.game_id)) for p in plays] + return render_template('plays.html', u=u, plays=plays_with_game) + + +@app.route('/account', methods=['GET', 'POST']) +@login_required +def account(): + form = ProfileForm() + if request.method == 'POST' and form.validate_on_submit(): + current_user.real_name = form.real_name.data + current_user.country = form.country.data + current_user.state = form.state.data + current_user.city = form.city.data + current_user.about = form.about.data + db.session.commit() + flash('Profile updated.', 'success') + return redirect(url_for('user_profile', username=current_user.username)) + form.real_name.data = current_user.real_name + form.country.data = current_user.country + form.state.data = current_user.state + form.city.data = current_user.city + form.about.data = current_user.about + return render_template('account.html', form=form) + + +# ----- Rate / collection mutations ----- + +@app.route('/rate/', methods=['POST']) +@login_required +def rate(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + form = RatingForm() + if not form.validate_on_submit(): + flash('Rating must be between 1.0 and 10.0.', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + r = Rating.query.filter_by(user_id=current_user.id, game_id=g.id).first() + previous_value = r.value if r else None + previous_average = g.avg_rating or 0.0 + previous_count = g.num_ratings or 0 + if not r: + r = Rating(user_id=current_user.id, game_id=g.id, + value=form.value.data, review_html=escape_paragraphs(form.review.data or ''), + created_at=MIRROR_NOW) + db.session.add(r) + else: + r.value = form.value.data + r.review_html = escape_paragraphs(form.review.data or '') + r.created_at = MIRROR_NOW + # The seeded average represents the full upstream population. Fold this + # user's mutation into that aggregate exactly once instead of replacing it + # with the handful of local benchmark ratings. + if previous_value is None: + g.num_ratings = previous_count + 1 + g.avg_rating = ( + (previous_average * previous_count) + form.value.data + ) / g.num_ratings + elif previous_count: + g.avg_rating = ( + (previous_average * previous_count) - previous_value + form.value.data + ) / previous_count + else: + g.avg_rating = form.value.data + db.session.commit() + flash(f'You rated {g.name}: {form.value.data:.1f}.', 'success') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + + +@app.route('/collection/save/', methods=['POST']) +@login_required +def collection_save(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + form = CollectionForm() + if not form.validate_on_submit(): + flash('Bad form.', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + e = Collection.query.filter_by(user_id=current_user.id, game_id=g.id).first() + if not e: + e = Collection(user_id=current_user.id, game_id=g.id) + db.session.add(e) + e.own = form.own.data + e.prevowned = form.prevowned.data + e.want_to_play = form.want_to_play.data + e.want_to_buy = form.want_to_buy.data + e.wishlist = form.wishlist.data + e.wishlist_priority = int(form.wishlist_priority.data or 0) + e.preordered = form.preordered.data + e.for_trade = form.for_trade.data + e.comment = form.comment.data + e.acquired_on = form.acquired_on.data + e.updated_at = MIRROR_NOW + db.session.commit() + flash(f'Collection updated for {g.name}.', 'success') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + + +@app.route('/collection/remove/', methods=['POST']) +@login_required +def collection_remove(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + e = Collection.query.filter_by(user_id=current_user.id, game_id=g.id).first() + if e: + db.session.delete(e) + db.session.commit() + flash(f'Removed {g.name} from your collection.', 'success') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + + +@app.route('/plays/log/', methods=['POST']) +@login_required +def play_log(oid): + g = Game.query.filter_by(bgg_id=oid).first_or_404() + form = PlayForm() + if not form.validate_on_submit(): + flash('Date is required (YYYY-MM-DD).', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + try: + d = datetime.strptime(form.played_on.data, '%Y-%m-%d').date() + except ValueError: + flash('Date must be YYYY-MM-DD.', 'error') + return redirect(url_for('game_detail', oid=oid, slug=g.slug)) + p = Play(user_id=current_user.id, game_id=g.id, played_on=d, + quantity=form.quantity.data or 1, + length_minutes=form.length_minutes.data or 0, + num_players=form.num_players.data or 0, + location=form.location.data, comments=form.comments.data) + db.session.add(p) + db.session.commit() + flash(f'Logged play of {g.name}.', 'success') + return redirect(url_for('plays_detail', username=current_user.username)) + + +# ----- Auth ----- + +@app.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm() + if form.validate_on_submit(): + u = User.query.filter_by(username=form.username.data.strip()).first() + if u and bcrypt.check_password_hash(u.password_hash, form.password.data): + login_user(u) + u.last_login = MIRROR_NOW + db.session.commit() + nxt = _safe_next(request.args.get('next'), url_for('user_profile', username=u.username)) + return redirect(nxt) + flash('Invalid username or password.', 'error') + return render_template('login.html', form=form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + form = RegisterForm() + if form.validate_on_submit(): + username = form.username.data.strip() + if User.query.filter_by(username=username).first(): + flash('That username is already taken.', 'error') + return render_template('register.html', form=form) + if User.query.filter_by(email=form.email.data.strip()).first(): + flash('That email is already registered.', 'error') + return render_template('register.html', form=form) + u = User(username=username, email=form.email.data.strip(), + password_hash=bcrypt.generate_password_hash(form.password.data).decode(), + real_name=form.real_name.data, country=form.country.data, + joined_at=MIRROR_NOW, last_login=MIRROR_NOW) + db.session.add(u) + db.session.commit() + login_user(u) + flash(f'Welcome, {u.username}!', 'success') + return redirect(url_for('user_profile', username=u.username)) + return render_template('register.html', form=form) + + +@app.route('/logout', methods=['GET', 'POST']) +def logout(): + logout_user() + return redirect(url_for('index')) + + +@app.route('/forgot', methods=['GET', 'POST']) +def forgot(): + msg = None + if request.method == 'POST': + msg = ('If an account with that email exists, a reset link is on its way. ' + '(Mirror site — no email is actually sent.)') + return render_template('forgot.html', msg=msg) + + +# ----- Static pages ----- + +@app.route('/wiki/page/About') +def about(): + return render_template('about.html') + + +@app.route('/help') +def help_page(): + return render_template('help.html') + + +# ----- Thumbs (lightweight likes) ----- + +@app.route('/thumb', methods=['POST']) +@login_required +def thumb(): + kind = request.form.get('kind') + tid = request.form.get('id', type=int) + if kind not in ('post', 'rating', 'geeklist', 'geeklist_item') or not tid: + abort(400) + existing = Thumb.query.filter_by(user_id=current_user.id, kind=kind, target_id=tid).first() + if existing: + db.session.delete(existing) + delta = -1 + else: + db.session.add(Thumb(user_id=current_user.id, kind=kind, target_id=tid)) + delta = 1 + if kind == 'post': + p = db.session.get(Post, tid) + if p: + p.thumbs = max(0, (p.thumbs or 0) + delta) + elif kind == 'rating': + r = db.session.get(Rating, tid) + if r: + r.num_thumbs = max(0, (r.num_thumbs or 0) + delta) + elif kind == 'geeklist': + l = db.session.get(GeekList, tid) + if l: + l.num_thumbs = max(0, (l.num_thumbs or 0) + delta) + elif kind == 'geeklist_item': + i = db.session.get(GeekListItem, tid) + if i: + i.num_thumbs = max(0, (i.num_thumbs or 0) + delta) + db.session.commit() + nxt = _safe_next(request.form.get('next'), url_for('index')) + return redirect(nxt) + + +# ----- Health ----- + +@app.route('/_health') +def health(): + try: + return jsonify({ + 'ok': True, 'site': 'boardgamegeek', + 'games': Game.query.count(), + 'users': User.query.count(), + 'ratings': Rating.query.count(), + 'threads': Thread.query.count(), + 'geeklists': GeekList.query.count(), + }) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}), 500 + + +# ----- Utilities ----- + +def escape_paragraphs(text: str) -> str: + """User-submitted text: escape, then convert \\n\\n to

breaks, autolink URLs.""" + if not text: + return '' + s = str(escape(text)) + parts = re.split(r'\n\s*\n', s) + url_re = re.compile(r'(https?://[^\s<>"]+)') + paragraphs = [] + for para in parts: + para = url_re.sub(r'\1', para) + paragraphs.append('

' + para.replace('\n', '
') + '

') + return ''.join(paragraphs) + + +# ----- Bootstrap ----- + +with app.app_context(): + db.create_all() + try: + from seed_data import seed_database, seed_benchmark_users + seed_database(db, app) + seed_benchmark_users(db, app, bcrypt) + except Exception as e: + import traceback + traceback.print_exc() + print(f"[boardgamegeek] seed error: {e}") + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/boardgamegeek/requirements.txt b/sites/boardgamegeek/requirements.txt new file mode 100644 index 00000000..134f4ece --- /dev/null +++ b/sites/boardgamegeek/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-Bcrypt==1.0.1 +Flask-WTF==1.2.2 +WTForms==3.2.1 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 +markupsafe==3.0.2 diff --git a/sites/boardgamegeek/scrape_bgg.py b/sites/boardgamegeek/scrape_bgg.py new file mode 100644 index 00000000..63c3b87b --- /dev/null +++ b/sites/boardgamegeek/scrape_bgg.py @@ -0,0 +1,359 @@ +"""Scrape real BoardGameGeek data via api.geekdo.com (no auth) + Playwright (for the +rank browse list which needs the CF challenge). + +Outputs sites/boardgamegeek/scraped_data/bgg.json containing: +- top_games: list of {objectid, name, rank, year, avg, num_voters, thumbnail_url} from + /browse/boardgame?page=1..N +- items: dict[objectid -> geekitem_payload] (full game metadata) +- dyn: dict[objectid -> dynamicinfo_payload] (rank, polls, weight, stats) +- reviews: dict[objectid -> list[review]] (top text reviews) +- hot: hotness list (top 50 trending right now) +- users: dict[username -> user payload] (top contributors + reviewers) + +Real images (covers + thumbnails) are written to scraped_data/images/.jpg. +The seed_data.py step will read this JSON and load it into the SQLite seed. +""" +import concurrent.futures +import functools +import json +import os +import pathlib +import re +import sys +import time +from urllib.parse import urlparse + +import httpx + +# Make print() unbuffered when stdout is redirected to a file so we see live +# progress instead of a single dump at the end. +print = functools.partial(print, flush=True) + +OUT = pathlib.Path(__file__).parent / "scraped_data" +OUT.mkdir(parents=True, exist_ok=True) +(IMG_DIR := OUT / "images").mkdir(exist_ok=True) + +# Tuning knobs — keep them visible so it's easy to scale up if needed. +TOP_PAGES = int(os.environ.get("BGG_PAGES", "10")) # 100 games per page +REVIEWS_PER_GAME = int(os.environ.get("BGG_REVIEWS", "30")) +USERS_TO_FETCH = int(os.environ.get("BGG_USERS", "200")) +CONCURRENCY = int(os.environ.get("BGG_CONC", "10")) + +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") +HEADERS = {"User-Agent": UA, "Accept": "application/json,text/html;q=0.9"} + +client = httpx.Client(headers=HEADERS, timeout=30.0, + limits=httpx.Limits(max_connections=CONCURRENCY)) +API = "https://api.geekdo.com/api" + + +# --------- step 1: top game ids + rank table via Playwright --------- + +def scrape_top_pages(pages: int) -> list[dict]: + from playwright.sync_api import sync_playwright + rows = [] + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + for pg in range(1, pages + 1): + ctx = browser.new_context(user_agent=UA, viewport={"width": 1400, "height": 1200}) + page = ctx.new_page() + url = f"https://boardgamegeek.com/browse/boardgame/page/{pg}" + try: + page.goto(url, wait_until="domcontentloaded", timeout=60000) + page.wait_for_selector("table.collection_table td.collection_rank", + timeout=25000) + except Exception as e: + print(f" ! page {pg} load: {e}", file=sys.stderr) + ctx.close() + time.sleep(3) + continue + data = page.eval_on_selector_all( + "table.collection_table tr", + """rows => rows.filter(tr => + tr.querySelector('td.collection_rank') && tr.querySelector('a[href^="/boardgame/"]') + ).map(tr => { + const cells = tr.querySelectorAll('td'); + const rankAnchor = tr.querySelector('td.collection_rank a[name]'); + const rank = rankAnchor ? rankAnchor.getAttribute('name') : ''; + const titleLink = tr.querySelector('td.collection_objectname a[href^="/boardgame/"]'); + const href = titleLink ? titleLink.getAttribute('href') : ''; + const m = href.match(/^\\/boardgame\\/(\\d+)/); + const objectid = m ? m[1] : null; + const name = (titleLink?.innerText || '').trim(); + const yearEl = tr.querySelector('td.collection_objectname span.smallerfont'); + const year = (yearEl?.innerText || '').replace(/[()]/g,'').trim(); + const thumb = tr.querySelector('td.collection_thumbnail img')?.src || ''; + const numericCells = Array.from(tr.querySelectorAll('td.collection_bggrating')).map(c => c.innerText.trim()); + const [geek_rating, avg_rating, num_voters] = numericCells.length >= 3 + ? numericCells.slice(0,3) + : ['','','']; + return {rank, objectid, name, year, thumb, + geek_rating, avg_rating, num_voters}; + })""" + ) + valid = [r for r in data if r.get("objectid")] + rows.extend(valid) + print(f" page {pg}: {len(valid)} rows total={len(rows)}") + ctx.close() + time.sleep(1.5) + browser.close() + return rows + + +# --------- step 2: per-game JSON endpoints --------- + +def fetch_json(url: str, retries: int = 2) -> dict | None: + for attempt in range(retries + 1): + try: + r = client.get(url) + if r.status_code == 200: + return r.json() + if r.status_code in (429, 502, 503, 504) and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + return None + except Exception as e: + if attempt < retries: + time.sleep(1.0 * (attempt + 1)) + continue + print(f" ! {url[:80]}: {e}", file=sys.stderr) + return None + return None + + +def fetch_geekitem(oid: str) -> dict | None: + return fetch_json(f"{API}/geekitems?objectid={oid}&objecttype=thing&subtype=boardgame&type=thing") + + +def fetch_dyn(oid: str) -> dict | None: + return fetch_json(f"{API}/dynamicinfo?objectid={oid}&objecttype=thing&subtype=boardgame&type=thing") + + +def fetch_reviews(oid: str, count: int) -> list[dict]: + out = [] + page = 1 + while len(out) < count and page <= 5: + url = (f"{API}/collections?ajax=1&objectid={oid}&objecttype=thing" + f"&pageid={page}&showcount=50&require_review=true&sort=rating") + data = fetch_json(url) + items = (data or {}).get("items") or [] + if not items: + break + out.extend(items) + page += 1 + if len(items) < 50: + break + # Slim down each review: keep what we render + slim = [] + for it in out[:count]: + tf = it.get("textfield") or {} + comment_obj = tf.get("comment") if isinstance(tf.get("comment"), dict) else None + comment = None + if comment_obj: + comment = comment_obj.get("rendered") or comment_obj.get("value") + rating_field = it.get("rating") + rating = None + if isinstance(rating_field, dict): + sub = rating_field.get("rating") + if isinstance(sub, dict): + rating = sub.get("value") + else: + rating = sub + elif isinstance(rating_field, (int, float, str)): + rating = rating_field + slim.append({ + "collid": it.get("collid"), + "username": (it.get("user") or {}).get("username") if isinstance(it.get("user"), dict) else None, + "country": (it.get("user") or {}).get("country") if isinstance(it.get("user"), dict) else None, + "rating": rating, + "comment_html": comment, + "tstamp": it.get("status_tstamp"), + }) + return slim + + +def fetch_user(username: str) -> dict | None: + # /user?username=... returns a list[user] or a dict {user: ...} + base = fetch_json(f"{API}/user?username={username}") + if not base: + return None + if isinstance(base, list): + entry = base[0] if base else None + elif isinstance(base, dict): + entry = base.get("user") or base + else: + entry = None + if not isinstance(entry, dict): + return None + uid = entry.get("userid") or entry.get("id") + if not uid: + return None + profile = fetch_json(f"{API}/user/{uid}/profile") or {} + return {"base": entry, "profile": profile, "userid": uid} + + +# --------- step 3: image fetch --------- + +def fetch_image(url: str, oid: str, suffix: str = "") -> str | None: + if not url: + return None + try: + dest = IMG_DIR / f"{oid}{suffix}.jpg" + if dest.exists() and dest.stat().st_size > 500: + return dest.name + r = client.get(url) + if r.status_code != 200 or len(r.content) < 500: + return None + dest.write_bytes(r.content) + return dest.name + except Exception as e: + print(f" ! img {oid}: {e}", file=sys.stderr) + return None + + +# --------- step 4: hot list --------- + +def fetch_hot() -> list[dict]: + data = fetch_json(f"{API}/hotness?geeksite=boardgame&objecttype=thing&showcount=50&singular=1") + items = (data or {}).get("items") or [] + return items + + +# --------- step 5: forum threads (a few representative ones) --------- + +def fetch_top_thread_titles_for_game(oid: str, limit: int = 10) -> list[dict]: + """Get a slice of recent thread titles + post counts for the game's forum row.""" + data = fetch_json( + f"{API}/forums/threads?ajax=1&objectid={oid}&objecttype=thing&pageid=1" + f"&showcount={limit}&sort=latestpost" + ) + items = (data or {}).get("threads") or (data or {}).get("items") or [] + return items[:limit] + + +# --------- main --------- + +def main() -> None: + print(f"[bgg-scrape] step 1: rank pages × {TOP_PAGES}") + rank_rows = scrape_top_pages(TOP_PAGES) + by_oid = {} + for r in rank_rows: + oid = r["objectid"] + if oid in by_oid: + continue + by_oid[oid] = r + print(f" unique games: {len(by_oid)}") + if not by_oid: + print(" ! no rank rows collected, aborting", file=sys.stderr) + sys.exit(2) + + oids = list(by_oid.keys()) + + print(f"[bgg-scrape] step 2a: geekitems for {len(oids)} games (conc={CONCURRENCY})") + items: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for oid, data in zip(oids, pool.map(fetch_geekitem, oids)): + if data: + items[oid] = data + if len(items) % 100 == 0: + print(f" geekitems {len(items)}/{len(oids)}") + print(f" geekitems: {len(items)}") + + print(f"[bgg-scrape] step 2b: dynamicinfo for {len(oids)} games") + dyn: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for oid, data in zip(oids, pool.map(fetch_dyn, oids)): + if data: + dyn[oid] = data + if len(dyn) % 100 == 0: + print(f" dyn {len(dyn)}/{len(oids)}") + print(f" dynamicinfo: {len(dyn)}") + + print(f"[bgg-scrape] step 3: reviews (top {REVIEWS_PER_GAME}/game) for {len(oids)} games") + reviews: dict[str, list] = {} + REV_CONC = max(4, CONCURRENCY // 3) + def _fetch_rev(oid): + return oid, fetch_reviews(oid, REVIEWS_PER_GAME) + done_count = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=REV_CONC) as pool: + for oid, revs in pool.map(_fetch_rev, oids): + reviews[oid] = revs + done_count += 1 + if done_count % 25 == 0: + print(f" reviews {done_count}/{len(oids)} " + f"total_so_far={sum(len(v) for v in reviews.values())}") + total_revs = sum(len(v) for v in reviews.values()) + print(f" reviews total: {total_revs}") + + print(f"[bgg-scrape] step 4: forum thread titles per game (light)") + threads: dict[str, list] = {} + def _fetch_th(oid): + return oid, fetch_top_thread_titles_for_game(oid, 8) + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for oid, t in pool.map(_fetch_th, oids): + threads[oid] = t + th_total = sum(len(v) for v in threads.values()) + print(f" thread headers: {th_total}") + + print(f"[bgg-scrape] step 5: hot list") + hot = fetch_hot() + print(f" hot items: {len(hot)}") + + print(f"[bgg-scrape] step 6: cover images (top {len(oids)} games)") + n_imgs = 0 + def _fetch_img(oid): + r = by_oid.get(oid, {}) + # geekitems may have a higher-res image; prefer that if found + gi = (items.get(oid) or {}).get("item") or {} + big = gi.get("imageurl") or gi.get("imageurl_lg") or "" + cover = big or r.get("thumb") or "" + thumb = r.get("thumb") or big + c = fetch_image(cover, oid, "_cover") if cover else None + t = fetch_image(thumb, oid, "_thumb") if thumb and thumb != cover else c + return oid, c, t + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + cover_map: dict[str, dict] = {} + for oid, c, t in pool.map(_fetch_img, oids): + cover_map[oid] = {"cover": c, "thumb": t} + if c: + n_imgs += 1 + if (n_imgs % 100) == 0 and n_imgs: + print(f" imgs {n_imgs}/{len(oids)}") + print(f" cover images: {n_imgs}") + + print(f"[bgg-scrape] step 7: users (top reviewers across games)") + user_counter: dict[str, int] = {} + for revs in reviews.values(): + for rv in revs: + un = rv.get("username") + if un: + user_counter[un] = user_counter.get(un, 0) + 1 + top_users = [u for u, _ in sorted(user_counter.items(), key=lambda x: -x[1])][:USERS_TO_FETCH] + print(f" fetching {len(top_users)} user profiles…") + users: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: + for name, data in zip(top_users, pool.map(fetch_user, top_users)): + if data: + users[name] = data + print(f" users: {len(users)}") + + out = { + "scraped_at": int(time.time()), + "top_games": rank_rows, + "items": items, + "dyn": dyn, + "reviews": reviews, + "threads": threads, + "hot": hot, + "users": users, + "covers": cover_map, + } + out_path = OUT / "bgg.json" + out_path.write_text(json.dumps(out)) + print(f"[bgg-scrape] wrote {out_path} ({out_path.stat().st_size/1e6:.1f} MB)") + print(f"[bgg-scrape] images dir: {IMG_DIR} ({sum(1 for _ in IMG_DIR.iterdir())} files)") + + +if __name__ == "__main__": + main() diff --git a/sites/boardgamegeek/scrape_extras.py b/sites/boardgamegeek/scrape_extras.py new file mode 100644 index 00000000..deca1a5e --- /dev/null +++ b/sites/boardgamegeek/scrape_extras.py @@ -0,0 +1,274 @@ +"""Augmentation scraper: fill the gaps the first pass left. + +1) EXPANSIONS — for every top-500 base game, fetch the full list of expansion + ids it links to, then fetch geekitems + dynamicinfo for each expansion so + they become first-class Game rows (subtype='boardgameexpansion') in our + catalog. Fixes BGG--20 + provides real expansion data for every popular + base game. + +2) LOW RATINGS — the first pass scraped each game's reviews with + sort=rating&direction=desc only. Re-fetch with direction=asc so the seed + contains the actual low-end ratings real BGG users gave, not just the + top-rated reviews. Removes the structural answer-leak in BGG--14. + +Reads: sites/boardgamegeek/scraped_data/bgg.json +Writes: sites/boardgamegeek/scraped_data/bgg_extras.json + sites/boardgamegeek/scraped_data/images/_{cover,thumb}.jpg +""" +import concurrent.futures +import functools +import json +import pathlib +import sys +import time + +import httpx + +print = functools.partial(print, flush=True) + +BASE = pathlib.Path(__file__).parent +SRC = BASE / "scraped_data" / "bgg.json" +OUT_JSON = BASE / "scraped_data" / "bgg_extras.json" +IMG = BASE / "scraped_data" / "images" +IMG.mkdir(parents=True, exist_ok=True) + +API = "https://api.geekdo.com/api" +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") +CONC = 12 + +client = httpx.Client( + headers={"User-Agent": UA, "Accept": "application/json"}, + timeout=30.0, + limits=httpx.Limits(max_connections=CONC), +) + + +def get(url, retries=2): + for attempt in range(retries + 1): + try: + r = client.get(url) + if r.status_code == 200: + return r.json() + if r.status_code in (429, 502, 503, 504) and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + return None + except Exception as e: + if attempt < retries: + time.sleep(1.0 * (attempt + 1)) + continue + print(f" ! {url[:90]}: {e}", file=sys.stderr) + return None + + +def fetch_expansion_ids(base_oid: str) -> list[str]: + """Return all expansion objectids for a base game (multi-page API).""" + ids: list[str] = [] + page = 1 + while page <= 5: + url = (f"{API}/geekitem/linkeditems?linkdata_index=boardgameexpansion" + f"&objectid={base_oid}&objecttype=thing&pageid={page}&showcount=50") + data = get(url) + if not data: + break + items = data.get("items") or data.get("linkeditems") or [] + if not items: + break + new_ids = [] + for it in items: + oid = it.get("objectid") or it.get("id") + if oid: + new_ids.append(str(oid)) + ids.extend(new_ids) + if len(new_ids) < 50: + break + page += 1 + return ids + + +def fetch_geekitem(oid: str) -> dict | None: + return get(f"{API}/geekitems?objectid={oid}&objecttype=thing" + f"&subtype=boardgameexpansion&type=thing") + + +def fetch_dyn(oid: str) -> dict | None: + return get(f"{API}/dynamicinfo?objectid={oid}&objecttype=thing" + f"&subtype=boardgameexpansion&type=thing") + + +def fetch_image(url: str, oid: str, suffix: str) -> str | None: + if not url: + return None + dest = IMG / f"{oid}{suffix}.jpg" + if dest.exists() and dest.stat().st_size > 500: + return dest.name + try: + r = client.get(url) + if r.status_code != 200 or len(r.content) < 500: + return None + dest.write_bytes(r.content) + return dest.name + except Exception as e: + print(f" ! img {oid}: {e}", file=sys.stderr) + return None + + +def fetch_low_ratings(oid: str, count: int = 20) -> list[dict]: + """Fetch low-end ratings (sort asc) to balance the high-only first-pass data.""" + out = [] + page = 1 + while len(out) < count and page <= 3: + url = (f"{API}/collections?ajax=1&objectid={oid}&objecttype=thing" + f"&pageid={page}&showcount=50&require_review=true" + f"&sort=rating&direction=asc") + d = get(url) + items = (d or {}).get("items") or [] + if not items: + break + out.extend(items) + page += 1 + if len(items) < 50: + break + slim = [] + for it in out[:count]: + tf = it.get("textfield") or {} + comment_obj = tf.get("comment") if isinstance(tf.get("comment"), dict) else None + comment = comment_obj.get("rendered") or comment_obj.get("value") if comment_obj else None + rating_field = it.get("rating") + rating = None + if isinstance(rating_field, dict): + sub = rating_field.get("rating") + if isinstance(sub, dict): + rating = sub.get("value") + else: + rating = sub + elif isinstance(rating_field, (int, float, str)): + rating = rating_field + slim.append({ + "collid": it.get("collid"), + "username": (it.get("user") or {}).get("username") if isinstance(it.get("user"), dict) else None, + "country": (it.get("user") or {}).get("country") if isinstance(it.get("user"), dict) else None, + "rating": rating, + "comment_html": comment, + "tstamp": it.get("status_tstamp"), + }) + return slim + + +def main(): + print(f"[extras] loading {SRC}...") + src = json.loads(SRC.read_text()) + base_oids = list(src.get("items", {}).keys()) + print(f" base games: {len(base_oids)}") + + # ----- 1) expansion id lists ----- + print(f"[extras] fetching expansion id-lists for {len(base_oids)} base games (conc={CONC})...") + exp_ids_per_base: dict[str, list[str]] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_expansion_ids, oid): oid for oid in base_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + try: + exp_ids_per_base[oid] = fut.result() + except Exception as e: + exp_ids_per_base[oid] = [] + print(f" ! exp-ids {oid}: {e}", file=sys.stderr) + done += 1 + if done % 50 == 0: + tot = sum(len(v) for v in exp_ids_per_base.values()) + print(f" exp-ids {done}/{len(base_oids)} total={tot}") + + # Flatten + dedupe. We don't want to double-fetch. + all_expansion_ids = set() + for ids in exp_ids_per_base.values(): + for x in ids: + all_expansion_ids.add(x) + # Exclude expansions that are already base games in our seed (rare). + expansion_oids = [x for x in sorted(all_expansion_ids, key=int) + if x not in src.get("items", {})] + print(f" unique expansion oids: {len(expansion_oids)}") + + # ----- 2) geekitems for every expansion ----- + print(f"[extras] geekitems for {len(expansion_oids)} expansions...") + exp_items: dict[str, dict] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_geekitem, oid): oid for oid in expansion_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + data = fut.result() + if data: + exp_items[oid] = data + done += 1 + if done % 100 == 0: + print(f" exp-items {done}/{len(expansion_oids)} hits={len(exp_items)}") + + # ----- 3) dynamicinfo ----- + print(f"[extras] dynamicinfo for {len(expansion_oids)} expansions...") + exp_dyn: dict[str, dict] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_dyn, oid): oid for oid in expansion_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + data = fut.result() + if data: + exp_dyn[oid] = data + done += 1 + if done % 100 == 0: + print(f" exp-dyn {done}/{len(expansion_oids)} hits={len(exp_dyn)}") + + # ----- 4) expansion cover images ----- + print(f"[extras] cover images for expansions...") + n_imgs = 0 + def _fetch_img(oid): + gi = (exp_items.get(oid) or {}).get("item") or {} + cover = gi.get("imageurl") or gi.get("imageurl_lg") or "" + thumb = gi.get("thumbnail") or cover + c = fetch_image(cover, oid, "_cover") if cover else None + t = fetch_image(thumb, oid, "_thumb") if thumb else None + return oid, c, t + cover_map: dict[str, dict] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + for oid, c, t in pool.map(_fetch_img, expansion_oids): + cover_map[oid] = {"cover": c, "thumb": t} + if c: + n_imgs += 1 + if n_imgs % 100 == 0: + print(f" exp-imgs {n_imgs}") + print(f" expansion covers downloaded: {n_imgs}") + + # ----- 5) low ratings for base games ----- + print(f"[extras] low ratings (sort asc) for {len(base_oids)} base games...") + REV_CONC = max(4, CONC // 3) + low_reviews: dict[str, list] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=REV_CONC) as pool: + futures = {pool.submit(fetch_low_ratings, oid, 15): oid for oid in base_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + low_reviews[oid] = fut.result() + done += 1 + if done % 50 == 0: + tot = sum(len(v) for v in low_reviews.values()) + print(f" low-reviews {done}/{len(base_oids)} total={tot}") + + out = { + "scraped_at": int(time.time()), + "exp_ids_per_base": exp_ids_per_base, + "exp_items": exp_items, + "exp_dyn": exp_dyn, + "exp_covers": cover_map, + "low_reviews": low_reviews, + } + OUT_JSON.write_text(json.dumps(out)) + print(f"[extras] wrote {OUT_JSON} " + f"({OUT_JSON.stat().st_size/1e6:.1f} MB)") + print(f"[extras] images dir entries: " + f"{sum(1 for _ in IMG.iterdir())}") + + +if __name__ == "__main__": + main() diff --git a/sites/boardgamegeek/scrape_low_ratings.py b/sites/boardgamegeek/scrape_low_ratings.py new file mode 100644 index 00000000..48044329 --- /dev/null +++ b/sites/boardgamegeek/scrape_low_ratings.py @@ -0,0 +1,127 @@ +"""Tiny add-on: fetch genuinely-low BGG ratings (1-5 stars), no review filter. + +Without require_review=true, the BGG collections endpoint returns rating-only +entries too, which is where the actual low scores live. Real reviewers rarely +go below 6; bare ratings go to 1. + +Appends to scraped_data/bgg_extras.json under key 'low_ratings_only'. +""" +import concurrent.futures +import functools +import json +import pathlib +import sys +import time + +import httpx + +print = functools.partial(print, flush=True) + +BASE = pathlib.Path(__file__).parent +EXTRAS = BASE / "scraped_data" / "bgg_extras.json" +SRC = BASE / "scraped_data" / "bgg.json" + +API = "https://api.geekdo.com/api" +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") +CONC = 8 +client = httpx.Client( + headers={"User-Agent": UA, "Accept": "application/json"}, + timeout=30.0, + limits=httpx.Limits(max_connections=CONC), +) + + +def get(url, retries=2): + for attempt in range(retries + 1): + try: + r = client.get(url) + if r.status_code == 200: + return r.json() + if r.status_code in (429, 502, 503, 504) and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + return None + except Exception as e: + if attempt < retries: + time.sleep(1.0 * (attempt + 1)) + continue + return None + return None + + +def fetch_low_only(oid: str, count: int = 25) -> list[dict]: + """No require_review filter — pulls real low-end raw ratings.""" + out = [] + page = 1 + while len(out) < count and page <= 3: + url = (f"{API}/collections?ajax=1&objectid={oid}&objecttype=thing" + f"&pageid={page}&showcount=50&sort=rating&direction=asc") + d = get(url) + items = (d or {}).get("items") or [] + if not items: + break + out.extend(items) + page += 1 + if len(items) < 50: + break + slim = [] + for it in out[:count]: + rating_field = it.get("rating") + rating = None + if isinstance(rating_field, dict): + sub = rating_field.get("rating") + if isinstance(sub, dict): + rating = sub.get("value") + else: + rating = sub + elif isinstance(rating_field, (int, float, str)): + rating = rating_field + if rating is None: + continue + try: + rating_f = float(rating) + except (TypeError, ValueError): + continue + if rating_f > 5.5: + continue # only keep actual lows + tf = it.get("textfield") or {} + comment_obj = tf.get("comment") if isinstance(tf.get("comment"), dict) else None + comment = comment_obj.get("rendered") or comment_obj.get("value") if comment_obj else None + slim.append({ + "collid": it.get("collid"), + "username": (it.get("user") or {}).get("username") if isinstance(it.get("user"), dict) else None, + "country": (it.get("user") or {}).get("country") if isinstance(it.get("user"), dict) else None, + "rating": rating, + "comment_html": comment, + "tstamp": it.get("status_tstamp"), + }) + return slim + + +def main(): + src = json.loads(SRC.read_text()) + base_oids = list(src.get("items", {}).keys()) + extras = json.loads(EXTRAS.read_text()) if EXTRAS.exists() else {} + print(f"[low] fetching real low ratings for {len(base_oids)} base games...") + + low_only: dict[str, list] = {} + done = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=CONC) as pool: + futures = {pool.submit(fetch_low_only, oid, 25): oid for oid in base_oids} + for fut in concurrent.futures.as_completed(futures): + oid = futures[fut] + low_only[oid] = fut.result() + done += 1 + if done % 50 == 0: + tot = sum(len(v) for v in low_only.values()) + print(f" {done}/{len(base_oids)} low_total={tot}") + + extras['low_ratings_only'] = low_only + EXTRAS.write_text(json.dumps(extras)) + total = sum(len(v) for v in low_only.values()) + print(f"[low] wrote {total} truly-low ratings across {sum(1 for v in low_only.values() if v)} games") + + +if __name__ == "__main__": + main() diff --git a/sites/boardgamegeek/seed_data.py b/sites/boardgamegeek/seed_data.py new file mode 100644 index 00000000..fd13d667 --- /dev/null +++ b/sites/boardgamegeek/seed_data.py @@ -0,0 +1,1034 @@ +"""Idempotent seed for BoardGameGeek mirror. + +Loads sites/boardgamegeek/scraped_data/bgg.json (real BGG api.geekdo.com data) +into the SQLite DB. Two phases: + +1. seed_database(db, app) — games, designers, artists, publishers, + categories, mechanics, families, ratings, reviews, forums, threads, + posts, geeklists, geeklist items, hot list, real BGG users. + +2. seed_benchmark_users(db, app, bcrypt) — 4 deterministic benchmark accounts + alice_j / bob_c / carol_d / david_k with collections, ratings, plays, + forum activity, and geeklists. + +Byte-identical reset invariant: each function early-returns when the DB is +already populated. No commits unless it's the first run. +""" +import json +import os +import re +import shutil +import random +from datetime import datetime, timedelta, date + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_FILE = os.path.join(BASE_DIR, 'scraped_data', 'bgg.json') +EXTRAS_FILE = os.path.join(BASE_DIR, 'scraped_data', 'bgg_extras.json') +IMG_SRC = os.path.join(BASE_DIR, 'scraped_data', 'images') +IMG_DST = os.path.join(BASE_DIR, 'static', 'images') + +MIRROR_NOW = datetime(2026, 5, 26, 12, 0, 0) + +# Pinned deterministic RNG. +_R = random.Random(20260526) + + +def _slugify(s: str) -> str: + s = (s or '').lower() + s = re.sub(r'[^a-z0-9]+', '-', s).strip('-') + return s or 'item' + + +def _parse_int(v, default=0): + try: + return int(float(v)) + except (TypeError, ValueError): + return default + + +def _parse_float(v, default=0.0): + try: + return float(v) + except (TypeError, ValueError): + return default + + +def _shift(seconds_back: int) -> datetime: + return MIRROR_NOW - timedelta(seconds=seconds_back) + + +def _copy_images(_quiet: bool = False) -> None: + if not os.path.isdir(IMG_SRC): + return + os.makedirs(IMG_DST, exist_ok=True) + for fn in os.listdir(IMG_SRC): + src = os.path.join(IMG_SRC, fn) + dst = os.path.join(IMG_DST, fn) + if os.path.exists(dst): + continue + try: + shutil.copyfile(src, dst) + except Exception as e: + if not _quiet: + print(f" ! copy img {fn}: {e}") + + +# Hardcoded fallback so the site renders even when scraped_data is missing. +FALLBACK_GAMES = [ + {"bgg_id": 999001, "name": "Catan", "year": 1995, "minplayers": 3, "maxplayers": 4, + "minplaytime": 60, "maxplaytime": 120, "minage": 10, + "short_description": "Trade, build, settle the island of Catan.", + "description_html": "

Catan is a tile-and-resource game where players build settlements, cities and roads on an island made from hex tiles.

", + "categories": ["Economic", "Negotiation"], + "mechanics": ["Dice Rolling", "Hexagon Grid", "Modular Board", "Network and Route Building", "Trading"], + "designers": ["Klaus Teuber"], + "publishers": ["Catan Studio"], + "weight": 2.30, "avg": 7.13, "bayes": 7.10, "num_ratings": 110000, "rank": 415}, + {"bgg_id": 999002, "name": "Carcassonne", "year": 2000, "minplayers": 2, "maxplayers": 5, + "minplaytime": 30, "maxplaytime": 45, "minage": 7, + "short_description": "Build southern France by laying landscape tiles and placing meeples.", + "description_html": "

Carcassonne is a tile-laying game in which players draw and place a tile with a piece of southern French landscape on it.

", + "categories": ["Medieval", "Territory Building"], + "mechanics": ["Area Majority / Influence", "Tile Placement"], + "designers": ["Klaus-Jürgen Wrede"], + "publishers": ["Z-Man Games"], + "weight": 1.91, "avg": 7.40, "bayes": 7.39, "num_ratings": 120000, "rank": 199}, +] + + +# Top-level (site-wide) forums shown on /forums. +SITEWIDE_FORUMS = [ + ('BoardGameGeek', 'announcements', 10, 'BGG announcements and site news.'), + ('General Gaming', 'general', 20, 'All-around discussion of board games.'), + ('Recommendations', 'general', 30, 'What should I play / buy?'), + ('Strategy', 'strategy', 40, 'Game-specific strategy and tactics.'), + ('Sessions', 'sessions', 50, 'Session reports from actual plays.'), + ('Reviews', 'reviews', 60, 'In-depth game reviews.'), + ('Variants & House Rules','variants', 70, 'Variants and house rules.'), + ('Crowdfunding', 'crowdfunding', 80, 'Kickstarter, Gamefound, BackerKit.'), + ('Solo Gaming', 'general', 90, 'Solo play, scenarios, AI bots.'), + ('Two-Player Gaming', 'general', 100, 'Just the two of us.'), + ('Trading & Marketplace', 'marketplace', 110, 'Buy, sell, trade.'), + ('News', 'news', 120, 'Industry news, new releases.'), +] + + +# Per-game forum subdivisions (created for every game). +PER_GAME_FORUMS = [ + ('General', 'general', 10), + ('Reviews', 'reviews', 20), + ('Strategy', 'strategy', 30), + ('Sessions', 'sessions', 40), + ('Rules', 'rules', 50), + ('Variants', 'variants', 60), + ('Crowdfunding', 'crowdfunding', 70), +] + + +SAMPLE_THREAD_TITLES = [ + 'First impressions after 10 plays', + 'Quick reference card (PDF)', + 'Solo variant — works surprisingly well', + 'Question about the end-game scoring', + 'Box insert recommendations?', + 'How do you handle player elimination?', + 'Expansion buying guide', + 'Best at 3 or 4 players?', + 'Critical hit on the language dependence', + 'New player struggling — any tips?', + 'Late-game pacing problem', + 'Spoiler-free review', + 'Comparison with other titles in the genre', + 'Errata in the rulebook (printing 2)', + 'Heavy hand of the leader — fix or feature?', +] + + +SAMPLE_POST_BODIES = [ + "We just wrapped our 6th play and it keeps getting better — the early-game decisions matter more than I thought on my first run.", + "I struggled with this in my first session, then realized I was completely missing the cascade in the middle phase. Once I locked that in, things clicked.", + "Played at 4P last night and the downtime was noticeable, but the table talk made up for it. Probably want to stick to 3P for the heavier strategy sessions.", + "Has anyone tried the variant from the rulebook appendix? Curious whether it shortens the game or just adds complexity.", + "The art and component quality are a huge step up from the previous edition. The new tokens are easier to grip.", + "I prefer this over the older sequel for one reason: the action economy feels less swingy.", + "I lost track of how many times the leader pulled away in the final round. Anyone else find catch-up mechanisms too weak here?", + "Bought a player aid PDF from the Files section — game flowed much faster after.", + "Solo with the bot is great. Beat me 3 of 5 games — calibration feels right.", + "Heavy on table presence but the upkeep is minimal once you internalize the symbols.", +] + + +SAMPLE_REVIEW_BODIES = [ + "

I came in expecting a heavy puzzle and got one — but also surprised by how much the table talk shapes my decisions. After 12 plays it stays fresh because every game pushes different paths.

", + "

The first play felt overwhelming. By the third, the design clicked: each phase has one or two pivotal moments, and reading the table during them is everything.

", + "

Components are gorgeous. Iconography mostly self-explanatory after a single play, though one or two ambiguous symbols send me back to the rulebook.

", + "

For its weight class this delivers — the late-game tension is exactly right and player interaction never feels token. Highly recommend for groups who don't mind 90 minutes.

", + "

The catch-up mechanism keeps the leader honest. Score spreads usually finish within 10 points which makes the last round meaningful.

", + "

Hits the table because it's quick to teach, but rewards repeat play. I keep finding new angles 20 plays in.

", +] + + +def _build_fallback_data(): + """Convert the small FALLBACK_GAMES table into the same shape as bgg.json.""" + items = {} + dyn = {} + top_games = [] + for i, g in enumerate(FALLBACK_GAMES): + oid = str(g['bgg_id']) + links = { + 'boardgamedesigner': [{'objectid': 90000 + j, 'name': n} + for j, n in enumerate(g.get('designers', []))], + 'boardgamepublisher': [{'objectid': 91000 + j, 'name': n} + for j, n in enumerate(g.get('publishers', []))], + 'boardgamecategory': [{'objectid': 92000 + j, 'name': n} + for j, n in enumerate(g.get('categories', []))], + 'boardgamemechanic': [{'objectid': 93000 + j, 'name': n} + for j, n in enumerate(g.get('mechanics', []))], + } + items[oid] = {'item': { + 'objectid': g['bgg_id'], 'name': g['name'], + 'yearpublished': g['year'], 'minplayers': g['minplayers'], + 'maxplayers': g['maxplayers'], 'minplaytime': g['minplaytime'], + 'maxplaytime': g['maxplaytime'], 'minage': g['minage'], + 'short_description': g['short_description'], + 'description': g['description_html'], + 'subtype': 'boardgame', 'links': links, + }} + dyn[oid] = {'item': { + 'rankinfo': [{'rankobjectid': 1, 'rank': str(g['rank']), + 'baverage': str(g['bayes'])}], + 'polls': {'boardgameweight': {'averageweight': g['weight'], 'totalvotes': 100}}, + 'stats': {'usersrated': g['num_ratings'], 'average': g['avg'], + 'bayesaverage': g['bayes'], 'owned': g['num_ratings'] // 2, + 'wishing': g['num_ratings'] // 10, 'comments': g['num_ratings'] // 5}, + }} + top_games.append({'objectid': str(g['bgg_id']), 'rank': str(g['rank']), + 'name': g['name'], 'year': str(g['year']), + 'thumb': '', 'geek_rating': str(g['bayes']), + 'avg_rating': str(g['avg']), 'num_voters': str(g['num_ratings'])}) + return {'top_games': top_games, 'items': items, 'dyn': dyn, + 'reviews': {}, 'threads': {}, 'hot': [], 'users': {}, 'covers': {}} + + + +# ----- main seed ----- + +def seed_database(db, app): + """Idempotent: returns early if any games already exist.""" + from app import (User, Person, Publisher, Category, Mechanic, Family, Game, + GameLink, Rating, Collection, Play, Forum, Thread, Post, + GeekList, GeekListItem) + if Game.query.count() > 0: + return + + print('[bgg-seed] loading scraped data…') + if os.path.exists(DATA_FILE): + with open(DATA_FILE) as f: + data = json.load(f) + else: + print(f' ! {DATA_FILE} missing — using built-in fallback (~2 games)') + data = _build_fallback_data() + + # Optional: expansion + low-rating augmentation pass (scrape_extras.py). + extras = None + if os.path.exists(EXTRAS_FILE): + with open(EXTRAS_FILE) as f: + extras = json.load(f) + print(f' + extras: {len(extras.get("exp_items", {}))} expansions, ' + f'{sum(len(v) for v in extras.get("low_reviews", {}).values())} low ratings') + + # Merge expansion games into the items/dyn/covers maps so the existing + # seed loop creates them as first-class Game rows (subtype=expansion). + merged_items = dict(data.get('items') or {}) + merged_dyn = dict(data.get('dyn') or {}) + merged_covers = dict(data.get('covers') or {}) + for oid, payload in (extras.get('exp_items') or {}).items(): + if oid in merged_items: + continue + merged_items[oid] = payload + for oid, payload in (extras.get('exp_dyn') or {}).items(): + if oid in merged_dyn: + continue + merged_dyn[oid] = payload + for oid, payload in (extras.get('exp_covers') or {}).items(): + merged_covers.setdefault(oid, payload) + data['items'] = merged_items + data['dyn'] = merged_dyn + data['covers'] = merged_covers + + # Make sure every base game's boardgameexpansion link includes the + # expansion ids we discovered (the first-pass scrape only captured + # what api.geekitems happened to return, sometimes truncated). + for base_oid, ids in (extras.get('exp_ids_per_base') or {}).items(): + base_payload = merged_items.get(base_oid) + if not base_payload: + continue + gi = base_payload.get('item') or {} + links = gi.setdefault('links', {}) + existing = links.get('boardgameexpansion') or [] + existing_ids = {str(e.get('objectid')) for e in existing if e.get('objectid')} + for x in ids: + if x not in existing_ids: + name = ((merged_items.get(x) or {}).get('item') or {}).get('name') or f'Expansion {x}' + existing.append({'objectid': x, 'name': name}) + existing_ids.add(x) + links['boardgameexpansion'] = existing + + # Merge low ratings into the reviews map. + merged_reviews = dict(data.get('reviews') or {}) + # Two sources: text-review-restricted asc sort (low_reviews) AND + # un-restricted asc sort (low_ratings_only — actual 1-5 star raw ratings). + for src_key in ('low_reviews', 'low_ratings_only'): + for oid, low_list in (extras.get(src_key) or {}).items(): + base = merged_reviews.get(oid) or [] + seen_collids = {r.get('collid') for r in base if r.get('collid')} + for rv in low_list: + if rv.get('collid') and rv['collid'] in seen_collids: + continue + base.append(rv) + if rv.get('collid'): + seen_collids.add(rv['collid']) + merged_reviews[oid] = base + data['reviews'] = merged_reviews + + _copy_images() + + # ----- 1. real users from scraped reviewers ----- + print('[bgg-seed] users…') + users_by_name: dict[str, 'User'] = {} + raw_users = data.get('users') or {} + for uname, payload in raw_users.items(): + base = payload.get('base') or {} + if not uname: + continue + u = User( + username=uname, + email=None, + password_hash='!disabled', # real BGG users are display-only + real_name=f"{base.get('firstname','')} {base.get('lastname','')}".strip(), + country=base.get('country') or '', + state=base.get('state') or '', + city=base.get('city') or '', + isocountry=base.get('isocountry') or '', + about='', + joined_at=_parse_user_join_date(base.get('regdate')), + last_login=MIRROR_NOW, + geekgold=_R.randint(0, 5000), + is_supporter=bool(base.get('supportYears')), + ) + users_by_name[uname] = u + db.session.add(u) + db.session.flush() + + # ----- 2. games ----- + print('[bgg-seed] games + their people/categories/mechanics…') + people_by_bgg: dict[int, 'Person'] = {} + publishers_by_bgg: dict[int, 'Publisher'] = {} + categories_by_bgg: dict[int, 'Category'] = {} + mechanics_by_bgg: dict[int, 'Mechanic'] = {} + families_by_bgg: dict[int, 'Family'] = {} + games_by_bgg: dict[int, 'Game'] = {} + + def get_or_make(map_, model, bgg_id, name): + bgg_id = _parse_int(bgg_id) + if not bgg_id or not name: + return None + if bgg_id in map_: + return map_[bgg_id] + obj = model(bgg_id=bgg_id, name=name, slug=_slugify(name)) + map_[bgg_id] = obj + db.session.add(obj) + return obj + + top_rank_by_oid = {} + for tg in (data.get('top_games') or []): + oid = _parse_int(tg.get('objectid')) + rk = _parse_int(tg.get('rank')) + if oid and rk: + top_rank_by_oid[oid] = rk + + items = data.get('items') or {} + dyn = data.get('dyn') or {} + covers = data.get('covers') or {} + + for oid_str, payload in items.items(): + gi = (payload or {}).get('item') or {} + oid = _parse_int(gi.get('objectid') or oid_str) + if not oid: + continue + name = gi.get('name') or 'Untitled' + cover = (covers.get(oid_str) or {}).get('cover') + thumb = (covers.get(oid_str) or {}).get('thumb') + dyn_item = (dyn.get(oid_str) or {}).get('item') or {} + stats = dyn_item.get('stats') or {} + polls = dyn_item.get('polls') or {} + + # Weight / language dependence / age suggestion + weight = 0.0 + weight_votes = 0 + bgw = polls.get('boardgameweight') + if isinstance(bgw, dict): + weight = _parse_float(bgw.get('averageweight')) + weight_votes = _parse_int(bgw.get('totalvotes')) + lang_dep = polls.get('languagedependence') or '' + suggested_age = polls.get('playerage') or '' + + # Player count poll → "best" / "recommended" + best_str = '' + rec_str = '' + upoll = polls.get('userplayers') or {} + best_list = upoll.get('best') or [] + if isinstance(best_list, list) and best_list: + bmin = best_list[0].get('min') + bmax = best_list[0].get('max') + best_str = f"{bmin}" if bmin == bmax else f"{bmin}–{bmax}" + rec_list = upoll.get('recommended') or [] + if isinstance(rec_list, list) and rec_list: + mins = sorted(set(_parse_int(p.get('min')) for p in rec_list)) + maxs = sorted(set(_parse_int(p.get('max')) for p in rec_list)) + if mins and maxs: + rec_str = f"{min(mins)}–{max(maxs)}" + + rank_info = dyn_item.get('rankinfo') or [] + overall_rank = 0 + bayes = 0.0 + for ri in rank_info: + if ri.get('rankobjectid') in (1, '1') or ri.get('prettyname') == 'Board Game Rank': + r = ri.get('rank') + if r and r != 'Not Ranked': + overall_rank = _parse_int(r) + bayes = _parse_float(ri.get('baverage')) + break + if not overall_rank: + overall_rank = top_rank_by_oid.get(oid, 0) + + avg = _parse_float(stats.get('average')) + num_ratings = _parse_int(stats.get('usersrated')) + num_owners = _parse_int(stats.get('owned')) + num_wishing = _parse_int(stats.get('wishing')) + num_comments = _parse_int(stats.get('comments')) + + g = Game( + bgg_id=oid, + name=name, + slug=_slugify(name), + subtype=(gi.get('subtype') or 'boardgame'), + year_published=_parse_int(gi.get('yearpublished')), + minplayers=_parse_int(gi.get('minplayers')), + maxplayers=_parse_int(gi.get('maxplayers')), + minplaytime=_parse_int(gi.get('minplaytime')), + maxplaytime=_parse_int(gi.get('maxplaytime')), + minage=_parse_int(gi.get('minage')), + short_description=(gi.get('short_description') or '')[:8000], + description_html=(gi.get('description') or '')[:60000], + image_filename=cover or '', + thumb_filename=thumb or '', + avg_rating=avg, + bayes_average=bayes, + weight=weight, + weight_votes=weight_votes, + num_ratings=num_ratings, + num_owners=num_owners, + num_wishing=num_wishing, + num_comments=num_comments, + overall_rank=overall_rank, + best_player_count=best_str, + recommended_player_count=rec_str, + suggested_age=suggested_age, + language_dependence=lang_dep, + featured=False, + ) + db.session.add(g) + games_by_bgg[oid] = g + + db.session.flush() + print(f' games: {len(games_by_bgg)}') + + # ----- people / publishers / categories / mechanics + m2m links ----- + expansion_links = [] # (game_bgg_id, other_bgg_id) + integration_links = [] + for oid_str, payload in items.items(): + gi = (payload or {}).get('item') or {} + oid = _parse_int(gi.get('objectid') or oid_str) + g = games_by_bgg.get(oid) + if not g: + continue + links = gi.get('links') or {} + for entry in (links.get('boardgamedesigner') or []): + p = get_or_make(people_by_bgg, Person, entry.get('objectid'), entry.get('name')) + if p: + g.designers.append(p) + for entry in (links.get('boardgameartist') or []): + p = get_or_make(people_by_bgg, Person, entry.get('objectid'), entry.get('name')) + if p: + g.artists.append(p) + for entry in (links.get('boardgamepublisher') or []): + p = get_or_make(publishers_by_bgg, Publisher, entry.get('objectid'), entry.get('name')) + if p: + g.publishers.append(p) + for entry in (links.get('boardgamecategory') or []): + c = get_or_make(categories_by_bgg, Category, entry.get('objectid'), entry.get('name')) + if c: + g.categories.append(c) + for entry in (links.get('boardgamemechanic') or []): + m = get_or_make(mechanics_by_bgg, Mechanic, entry.get('objectid'), entry.get('name')) + if m: + g.mechanics.append(m) + for entry in (links.get('boardgamefamily') or []): + f = get_or_make(families_by_bgg, Family, entry.get('objectid'), entry.get('name')) + if f: + g.families.append(f) + for entry in (links.get('boardgameexpansion') or []): + other_oid = _parse_int(entry.get('objectid')) + if other_oid: + expansion_links.append((oid, other_oid)) + for entry in (links.get('boardgameintegration') or []): + other_oid = _parse_int(entry.get('objectid')) + if other_oid: + integration_links.append((oid, other_oid)) + + db.session.flush() + print(f' designers/artists: {len(people_by_bgg)} publishers: {len(publishers_by_bgg)} ' + f'categories: {len(categories_by_bgg)} mechanics: {len(mechanics_by_bgg)}') + + # ----- game-to-game links ----- + for (g_oid, o_oid) in expansion_links: + g = games_by_bgg.get(g_oid) + other = games_by_bgg.get(o_oid) + if g and other: + db.session.add(GameLink(game_id=g.id, other_id=other.id, kind='expansion')) + for (g_oid, o_oid) in integration_links: + g = games_by_bgg.get(g_oid) + other = games_by_bgg.get(o_oid) + if g and other: + db.session.add(GameLink(game_id=g.id, other_id=other.id, kind='integration')) + + # ----- featured set from hot list (intersect with seeded games) ----- + hot = data.get('hot') or [] + featured = 0 + for h in hot[:50]: + h_oid = _parse_int(h.get('objectid') or h.get('id')) + g = games_by_bgg.get(h_oid) + if g: + g.featured = True + featured += 1 + if featured == 0: + # If hot list didn't intersect, just feature the top 20 by rank. + for g in sorted(games_by_bgg.values(), key=lambda x: x.overall_rank or 99999)[:20]: + g.featured = True + db.session.flush() + + # ----- ratings + reviews from real users ----- + print('[bgg-seed] real ratings/reviews…') + rating_count = 0 + review_count = 0 + for oid_str, revs in (data.get('reviews') or {}).items(): + oid = _parse_int(oid_str) + g = games_by_bgg.get(oid) + if not g or not revs: + continue + for rv in revs: + uname = rv.get('username') + if not uname: + continue + u = users_by_name.get(uname) + if not u: + # synthesize a stub real user + u = User( + username=uname, + email=None, + password_hash='!disabled', + real_name='', + country=rv.get('country') or '', + joined_at=MIRROR_NOW - timedelta(days=_R.randint(60, 4000)), + last_login=MIRROR_NOW, + geekgold=_R.randint(0, 200), + ) + users_by_name[uname] = u + db.session.add(u) + db.session.flush() + existing = Rating.query.filter_by(user_id=u.id, game_id=g.id).first() + if existing: + continue + comment = rv.get('comment_html') or '' + try: + value = float(rv.get('rating')) if rv.get('rating') is not None else None + except (TypeError, ValueError): + value = None + if value is None: + continue + tstamp = _parse_review_timestamp(rv.get('tstamp')) + r = Rating(user_id=u.id, game_id=g.id, value=value, + review_html=comment, created_at=tstamp, + num_thumbs=_R.randint(0, 25) if comment else _R.randint(0, 3)) + db.session.add(r) + rating_count += 1 + if comment: + review_count += 1 + db.session.flush() + print(f' ratings: {rating_count} text reviews: {review_count}') + + # ----- forums (site-wide + per-game) ----- + print('[bgg-seed] forums + threads + posts…') + forums_created = [] + for title, section, sort, desc in SITEWIDE_FORUMS: + f = Forum(title=title, section=section, sort_order=sort, + description=desc, game_id=None, num_threads=0, num_posts=0) + db.session.add(f) + forums_created.append(f) + + for g in games_by_bgg.values(): + # Only base games get a full per-game forum. Expansions don't — + # real BGG also keeps expansion discussion in the parent game's forum. + if g.subtype != 'boardgame': + continue + for title, section, sort in PER_GAME_FORUMS: + f = Forum(title=title, section=section, sort_order=sort, + description=f'{title} discussion for {g.name}.', + game_id=g.id, num_threads=0, num_posts=0) + db.session.add(f) + db.session.flush() + print(f' forums: {Forum.query.count()}') + + # Threads from real BGG thread headers (when available) + thread_count = 0 + post_count = 0 + raw_threads = data.get('threads') or {} + user_pool = [u for u in users_by_name.values() if u.password_hash == '!disabled'] + if not user_pool: + user_pool = list(users_by_name.values()) + + def _author_for_index(i: int): + return user_pool[i % len(user_pool)] if user_pool else None + + for oid_str, headers in raw_threads.items(): + oid = _parse_int(oid_str) + g = games_by_bgg.get(oid) + if not g or not headers: + continue + # Pick a per-game forum at random (deterministic by thread index) + forums_for_game = Forum.query.filter_by(game_id=g.id).all() + if not forums_for_game: + continue + for ti, h in enumerate(headers[:8]): + subject = (h.get('subject') or h.get('title') or + SAMPLE_THREAD_TITLES[ti % len(SAMPLE_THREAD_TITLES)]) + subject = subject.strip()[:300] + fobj = forums_for_game[ti % len(forums_for_game)] + author = _author_for_index(oid + ti) + if not author: + continue + tcreated = MIRROR_NOW - timedelta(days=_R.randint(2, 1400), + hours=_R.randint(0, 23)) + n_posts = max(1, _parse_int(h.get('numposts') or h.get('numreplies')) or + _R.randint(2, 30)) + n_posts = min(n_posts, 30) + t = Thread(forum_id=fobj.id, subject=subject, author_id=author.id, + is_pinned=(ti == 0 and _R.random() < 0.15), + is_hot=(n_posts >= 15), + num_posts=n_posts, num_views=n_posts * _R.randint(8, 50), + created_at=tcreated, last_post_at=tcreated + timedelta(hours=n_posts * 2)) + db.session.add(t) + db.session.flush() + thread_count += 1 + fobj.num_threads = (fobj.num_threads or 0) + 1 + fobj.num_posts = (fobj.num_posts or 0) + n_posts + for pi in range(n_posts): + pauthor = _author_for_index(oid + ti + pi * 7) or author + body = SAMPLE_POST_BODIES[(oid + ti + pi) % len(SAMPLE_POST_BODIES)] + body_html = f'

{body}

' + # Sprinkle in a quoted previous post for variety + if pi > 0 and pi % 3 == 0: + prev = SAMPLE_POST_BODIES[(oid + ti + pi - 1) % len(SAMPLE_POST_BODIES)] + body_html = f'
{prev[:120]}…
' + body_html + pcreated = tcreated + timedelta(hours=pi * 2 + _R.randint(0, 4)) + p = Post(thread_id=t.id, author_id=pauthor.id, + body_html=body_html, created_at=pcreated, + thumbs=_R.randint(0, 8)) + db.session.add(p) + post_count += 1 + if thread_count % 200 == 0: + db.session.flush() + + db.session.flush() + print(f' threads: {thread_count} posts: {post_count}') + + # ----- GeekLists (synthesized from real games) ----- + print('[bgg-seed] geeklists…') + list_count = _seed_geeklists(db, games_by_bgg, users_by_name, _R) + print(f' geeklists: {list_count}') + + db.session.commit() + print('[bgg-seed] done.') + + +def _seed_geeklists(db, games_by_bgg, users_by_name, R): + from app import GeekList, GeekListItem + + games_sorted = sorted(games_by_bgg.values(), key=lambda g: g.overall_rank or 99999) + user_pool = list(users_by_name.values()) + if not user_pool: + return 0 + + LIST_DEFS = [ + ('Top 50 Heaviest Games of the Last Decade', + lambda g: g.year_published >= 2014 and g.weight >= 3.5, + lambda g: (-g.weight, g.overall_rank or 99999), + '

The crunchiest cardboard-and-chrome experiences released since 2014. ' + 'Ranked by community weight rating, descending.

'), + ('Best Two-Player Only Games', + lambda g: g.minplayers == 2 and g.maxplayers == 2, + lambda g: (g.overall_rank or 99999,), + '

Hand-curated list of games designed exclusively for two players. ' + 'No "you can play it solo" cop-outs.

'), + ('Sub-30-Minute Fillers I Will Defend', + lambda g: g.maxplaytime > 0 and g.maxplaytime <= 30, + lambda g: (g.overall_rank or 99999,), + '

Quick games that still pack a punch. Perfect for the last hour of game night.

'), + ('Gateway Games for Non-Gamers', + lambda g: g.weight > 0 and g.weight < 2.0 and (g.overall_rank or 99999) < 800, + lambda g: (g.overall_rank or 99999,), + '

Low-weight modern designs that have introduced more people to the hobby ' + 'than any Monopoly Special Edition ever did.

'), + ('Solo Mode That Actually Earns the SKU', + lambda g: 'Solitaire Game' in {c.name for c in g.categories} or g.minplayers == 1, + lambda g: (g.overall_rank or 99999,), + '

Games with bolted-on solo modes are forgivable. These games are designed with ' + 'the lone player in mind from the start.

'), + ('Wargame Newcomers Start Here', + lambda g: 'Wargame' in {c.name for c in g.categories} and (g.overall_rank or 99999) < 2000, + lambda g: (g.overall_rank or 99999,), + '

Light to medium wargames that won\'t scare off the rest of your group.

'), + ('Best Cooperative Games', + lambda g: 'Cooperative Game' in {m.name for m in g.mechanics}, + lambda g: (g.overall_rank or 99999,), + '

Win together or lose together — the best titles in the co-op space.

'), + ('Deckbuilders That Deserve a Place at the Table', + lambda g: 'Deck, Bag, and Pool Building' in {m.name for m in g.mechanics}, + lambda g: (g.overall_rank or 99999,), + '

The deckbuilding genre keeps evolving. These are the standouts beyond the ' + 'classics.

'), + ('Hidden Gems Under 5000 Owners', + lambda g: 0 < g.num_owners < 5000 and g.bayes_average >= 7.0, + lambda g: (-g.bayes_average, g.overall_rank or 99999), + '

Underrated games that fly under the radar. Help me make these owned numbers go up.

'), + ('Designer Spotlight: Vital Lacerda', + lambda g: any('lacerda' in p.name.lower() for p in g.designers), + lambda g: (g.overall_rank or 99999,), + '

Notes and rankings for every Lacerda title.

'), + ('Award Winners 2020-2025', + lambda g: 2020 <= g.year_published <= 2025 and (g.overall_rank or 99999) < 500, + lambda g: (-g.year_published, g.overall_rank or 99999), + '

Spiel des Jahres, Kennerspiel, and Golden Geek winners of the last five years.

'), + ('My Heavy Euro Top 25', + lambda g: g.weight >= 4.0, + lambda g: (-g.bayes_average, g.overall_rank or 99999), + '

If brain-burn was a food group I would die of it.

'), + ] + + n_created = 0 + for li, (title, predicate, sort_key, desc) in enumerate(LIST_DEFS): + author = user_pool[li % len(user_pool)] + l = GeekList(title=title, + description_html=desc, + author_id=author.id, + created_at=MIRROR_NOW - timedelta(days=R.randint(15, 1200)), + num_items=0, + num_thumbs=R.randint(8, 480)) + db.session.add(l) + db.session.flush() + n_created += 1 + # Find matching games + candidates = [g for g in games_sorted if g.overall_rank and predicate(g)] + candidates.sort(key=sort_key) + items = candidates[:25] + for pos, g in enumerate(items, start=1): + comment_body = R.choice([ + f"

{g.name} earns its spot because it nails the central design tension.

", + f"

Brought {g.name} to game night last week and it played beautifully — added at #{pos}.

", + f"

{g.name} keeps finding its way back to the table. Hard to argue with that.

", + f"

Hugely underplayed in our group. {g.name} deserves more love.

", + f"

The decision space in {g.name} is wider than the rulebook page count suggests.

", + ]) + db.session.add(GeekListItem( + list_id=l.id, game_id=g.id, + body_html=comment_body, position=pos, + num_thumbs=R.randint(0, 40), + )) + l.num_items = len(items) + db.session.flush() + return n_created + + +def _parse_user_join_date(s: str | None) -> datetime: + if not s: + return MIRROR_NOW - timedelta(days=_R.randint(365, 8000)) + try: + return datetime.strptime(s, '%Y-%m-%d') + except (ValueError, TypeError): + return MIRROR_NOW - timedelta(days=_R.randint(365, 8000)) + + +def _parse_review_timestamp(s: str | None) -> datetime: + if not s: + return MIRROR_NOW - timedelta(days=_R.randint(30, 3000)) + try: + return datetime.strptime(s, '%Y-%m-%d %H:%M:%S') + except (ValueError, TypeError): + return MIRROR_NOW - timedelta(days=_R.randint(30, 3000)) + + +# ----- benchmark users (deterministic) ----- + +BENCH_USERS = [ + {'username': 'alice_j', 'email': 'alice.j@test.com', 'real_name': 'Alice Johnson', + 'country': 'United States', 'state': 'California', 'city': 'Berkeley', + 'about': 'Heavy euro fan. Lacerda completist. Currently obsessed with Brass: Birmingham.'}, + {'username': 'bob_c', 'email': 'bob.c@test.com', 'real_name': 'Bob Chen', + 'country': 'Canada', 'state': 'British Columbia', 'city': 'Vancouver', + 'about': 'Cooperative games and dungeon crawlers. Gloomhaven scenario 95 grinder.'}, + {'username': 'carol_d', 'email': 'carol.d@test.com', 'real_name': 'Carol Davis', + 'country': 'United Kingdom', 'state': 'Greater London', 'city': 'London', + 'about': 'Two-player only. Race for the Galaxy until the heat-death of the universe.'}, + {'username': 'david_k', 'email': 'david.k@test.com', 'real_name': 'David Kim', + 'country': 'South Korea', 'state': 'Seoul', 'city': 'Seoul', + 'about': 'Wargames and economic sims. Will not apologise for owning every COIN game.'}, +] +BENCH_PASSWORD = 'TestPass123!' + + +def seed_benchmark_users(db, app, bcrypt): + from app import (User, Game, Rating, Collection, Play, GeekList, + GeekListItem, Thread, Post, Forum) + if User.query.filter_by(email='alice.j@test.com').first(): + return + + print('[bgg-seed] benchmark users…') + created = [] + for u in BENCH_USERS: + existing = User.query.filter_by(username=u['username']).first() + if existing: + # Real BGG dataset already has someone by that handle — promote it. + existing.email = u['email'] + existing.password_hash = bcrypt.generate_password_hash(BENCH_PASSWORD).decode() + existing.real_name = u['real_name'] + existing.country = u['country'] + existing.state = u['state'] + existing.city = u['city'] + existing.about = u['about'] + created.append(existing) + continue + new = User(username=u['username'], email=u['email'], + password_hash=bcrypt.generate_password_hash(BENCH_PASSWORD).decode(), + real_name=u['real_name'], country=u['country'], + state=u['state'], city=u['city'], about=u['about'], + joined_at=MIRROR_NOW - timedelta(days=3*365 + 100), + last_login=MIRROR_NOW, geekgold=120, is_supporter=True) + db.session.add(new) + created.append(new) + db.session.flush() + + # Map of user → preferred game categories/mechanics, used to pick their collection + profiles = { + 'alice_j': { + 'weight_range': (3.5, 5.0), + 'preferred_mechanics': {'Worker Placement', 'Network and Route Building', + 'Income', 'Hand Management'}, + 'preferred_categories': {'Economic', 'Industry / Manufacturing'}, + }, + 'bob_c': { + 'weight_range': (2.5, 4.5), + 'preferred_mechanics': {'Cooperative Game', 'Variable Player Powers', + 'Scenario / Mission / Campaign Game'}, + 'preferred_categories': {'Adventure', 'Exploration', 'Fantasy', 'Fighting'}, + }, + 'carol_d': { + 'weight_range': (1.5, 3.5), + 'preferred_mechanics': set(), + 'preferred_categories': set(), + 'players_must_include_two': True, + }, + 'david_k': { + 'weight_range': (3.5, 5.0), + 'preferred_mechanics': {'Area Movement', 'Hexagon Grid', 'Variable Player Powers', + 'Action Points', 'Simultaneous Action Selection'}, + 'preferred_categories': {'Wargame', 'Civilization', 'World War II'}, + }, + } + + # Helper to pick N games for each user, scored by their preferences. + def pick_games_for(uname: str, n: int = 25): + prof = profiles.get(uname, {}) + candidates = [] + for g in Game.query.filter(Game.overall_rank > 0, + Game.overall_rank <= 800).all(): + cats = {c.name for c in g.categories} + mechs = {m.name for m in g.mechanics} + score = 0.0 + if prof.get('weight_range'): + wmin, wmax = prof['weight_range'] + if wmin <= (g.weight or 0) <= wmax: + score += 3 + score += len(cats & prof.get('preferred_categories', set())) * 2 + score += len(mechs & prof.get('preferred_mechanics', set())) * 2 + if prof.get('players_must_include_two'): + if g.minplayers <= 2 <= g.maxplayers: + score += 2 + # Slight preference for higher-ranked + score += max(0, (800 - g.overall_rank) / 800) * 1.5 + if score > 0: + candidates.append((g, score)) + candidates.sort(key=lambda x: (-x[1], x[0].overall_rank)) + return [c[0] for c in candidates[:n]] + + # Pin choices so the seed is deterministic. + R = random.Random(99887766) + + # 1. Collections + ratings + for u in created: + picks = pick_games_for(u.username, n=25) + if not picks: + # Fall back to the top ranked games + picks = Game.query.filter(Game.overall_rank > 0) \ + .order_by(Game.overall_rank).limit(25).all() + for i, g in enumerate(picks): + own = i < 18 # first 18 owned + wish = (18 <= i < 22) # next 4 on wishlist + wantbuy = (22 <= i < 24) # 2 want-to-buy + wantplay = (i == 24) # last 1 want-to-play + e = Collection(user_id=u.id, game_id=g.id, + own=own, wishlist=wish, want_to_buy=wantbuy, + want_to_play=wantplay, + wishlist_priority=(R.randint(1, 5) if wish else 0), + comment=R.choice([ + '', '', '', # most blank + 'Shelf of shame for now.', + 'Sleeved and ready for the next session.', + 'Trade me if you want one.', + 'KS edition with all stretch goals.', + ]), + acquired_on=(MIRROR_NOW - timedelta(days=R.randint(40, 1800))).strftime('%Y-%m-%d') if own else '', + updated_at=MIRROR_NOW - timedelta(days=R.randint(1, 200))) + db.session.add(e) + # Owned games get a personal rating + if own: + base = 7.5 + R.uniform(-1.5, 2.0) + if g.bayes_average: + base = 0.6 * base + 0.4 * g.bayes_average + value = round(min(10.0, max(3.0, base)) * 2) / 2 # half-step + review = '' + # First 5 owned per user become full text reviews + if i < 5: + review = R.choice([ + f"

{g.name} has stayed in my regular rotation for years. The decision points always feel meaningful, and the table arc lands on a tight finish.

", + f"

I almost sold {g.name} after my second play. Then a friend insisted on one more game and I finally saw the shape of it. Now it's a top-10 keeper.

", + f"

{g.name} is the game I pull out when I want to win an argument about what 'elegant' means in the hobby.

", + f"

The components in {g.name} are excellent, but what surprises me each play is how different the path-to-victory feels. Highly recommended for the right group.

", + ]) + rt = Rating(user_id=u.id, game_id=g.id, value=value, + review_html=review, + created_at=MIRROR_NOW - timedelta(days=R.randint(10, 900)), + num_thumbs=R.randint(0, 30) if review else 0) + db.session.add(rt) + + db.session.flush() + + # 2. Plays log — last 60 days of plays per user + for u in created: + owned = Collection.query.filter_by(user_id=u.id, own=True).all() + owned_games = [db.session.get(Game, e.game_id) for e in owned] + owned_games = [g for g in owned_games if g] + if not owned_games: + continue + for play_i in range(R.randint(10, 22)): + g = R.choice(owned_games) + played = (MIRROR_NOW - timedelta(days=R.randint(1, 90))).date() + db.session.add(Play(user_id=u.id, game_id=g.id, played_on=played, + quantity=1, + length_minutes=(g.minplaytime + g.maxplaytime) // 2 if g.maxplaytime else 60, + num_players=max(g.minplayers, + R.randint(g.minplayers, max(g.minplayers, g.maxplayers))), + location=R.choice(['Home', 'Friend\'s House', 'Game Cafe', + 'Convention', 'FLGS']), + comments=R.choice(['', + 'Tight finish, decided on the last turn.', + 'Tried a new strategy — paid off.', + 'Solo mode this time, lost narrowly.', + 'Taught two new players, they want to play again.']))) + + # 3. Each benchmark user authors one GeekList + bench_lists = [ + ('alice_j', 'Alice\'s Lacerda Project', + '

Working through every Vital Lacerda title in publication order. Half ranking, half therapy.

', + lambda g: any('lacerda' in p.name.lower() for p in g.designers) or g.weight >= 4.0, + 12), + ('bob_c', 'Bob\'s Co-op Rotation', + '

Cooperative games currently in our weekly rotation.

', + lambda g: 'Cooperative Game' in {m.name for m in g.mechanics}, + 12), + ('carol_d', 'Carol\'s Two-Player-Only Shelf', + '

Designed for two. No "scales to 5" compromises. The shelf as it stands today.

', + lambda g: g.minplayers == 2 and g.maxplayers == 2, + 10), + ('david_k', 'David\'s COIN Lectern', + '

Every entry in the GMT COIN series I own, ranked by how often it hits the table.

', + lambda g: 'Wargame' in {c.name for c in g.categories} and g.weight >= 3.5, + 10), + ] + for uname, title, desc, predicate, n in bench_lists: + u = User.query.filter_by(username=uname).first() + if not u: + continue + candidates = [g for g in Game.query.all() if g.overall_rank and predicate(g)] + candidates.sort(key=lambda g: g.overall_rank or 99999) + picks = candidates[:n] + if not picks: + continue + gl = GeekList(title=title, description_html=desc, + author_id=u.id, + created_at=MIRROR_NOW - timedelta(days=R.randint(40, 800)), + num_items=len(picks), + num_thumbs=R.randint(20, 250)) + db.session.add(gl) + db.session.flush() + for pos, g in enumerate(picks, start=1): + db.session.add(GeekListItem( + list_id=gl.id, game_id=g.id, position=pos, + body_html=f'

{u.real_name.split()[0]}\'s note: {g.name} belongs here because it nails the brief.

', + num_thumbs=R.randint(0, 30), + )) + + db.session.flush() + + # 4. Forum activity — each user starts one thread + replies to a few others + general_forum = Forum.query.filter_by(title='General Gaming').first() + rec_forum = Forum.query.filter_by(title='Recommendations').first() + target_forums = [f for f in [general_forum, rec_forum] if f] + for u, opening in zip(created, [ + ('What\'s your highest-rated 2024 release?', + '

Yearly thread. Mine is Cyclades: Legendary Edition, against my own expectations.

'), + ('Looking for co-op suggestions that AREN\'T Pandemic / Spirit Island', + '

I love both, but the group has played them to death. What else should be in the conversation?

'), + ('Best 2-player only deckbuilder?', + '

I have Star Realms and 7 Wonders Duel. Looking for one more — preferably under 45 minutes.

'), + ('Wargames at 2 players in under 3 hours', + '

Title says it all. Block wargames welcome.

'), + ]): + if not target_forums: + break + fobj = target_forums[(u.id) % len(target_forums)] + t = Thread(forum_id=fobj.id, subject=opening[0], author_id=u.id, + num_posts=1, num_views=R.randint(60, 800), + created_at=MIRROR_NOW - timedelta(days=R.randint(7, 120)), + last_post_at=MIRROR_NOW - timedelta(days=R.randint(1, 6))) + db.session.add(t) + db.session.flush() + fobj.num_threads = (fobj.num_threads or 0) + 1 + fobj.num_posts = (fobj.num_posts or 0) + 1 + db.session.add(Post(thread_id=t.id, author_id=u.id, + body_html=opening[1], + created_at=t.created_at, thumbs=R.randint(0, 18))) + + db.session.commit() + print(f'[bgg-seed] benchmark seed done. (users={len(created)})') diff --git a/sites/boardgamegeek/static/css/bgg.css b/sites/boardgamegeek/static/css/bgg.css new file mode 100644 index 00000000..34e41717 --- /dev/null +++ b/sites/boardgamegeek/static/css/bgg.css @@ -0,0 +1,936 @@ +/* BoardGameGeek mirror — recreates the table-heavy, slightly retro look + of the real site. Color palette adapted from boardgamegeek.com (2025). */ + +:root { + --bgg-orange: #FF5100; + --bgg-orange-light: #FFB380; + --bgg-dark: #181C1E; + --bgg-blue: #5369A2; + --bgg-blue-dark: #303F70; + --bgg-green: #249563; + --bgg-red: #DB303F; + --bgg-bg: #f5f5f5; + --bgg-paper: #ffffff; + --bgg-grey: #E8E8E8; + --bgg-text: #2c3e50; + --bgg-muted: #6c757d; + --bgg-link: #103B7C; + --bgg-link-hover: #FF5100; + --bgg-border: #d4d4d4; + --bgg-card-shadow: 0 1px 3px rgba(0,0,0,0.07); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; padding: 0; + background: var(--bgg-bg); + color: var(--bgg-text); + font-family: Verdana, Geneva, Tahoma, sans-serif; + font-size: 13px; + line-height: 1.45; +} + +a { color: var(--bgg-link); text-decoration: none; } +a:hover { color: var(--bgg-link-hover); text-decoration: underline; } + +h1, h2, h3, h4, h5 { font-family: 'Trebuchet MS', sans-serif; font-weight: 600; color: var(--bgg-blue-dark); margin: 12px 0 8px; } +h1 { font-size: 22px; } +h2 { font-size: 18px; } +h3 { font-size: 16px; } + +img { vertical-align: middle; } + +/* ----- header ----- */ +.site-header { + background: var(--bgg-dark); + color: #fff; + border-bottom: 3px solid var(--bgg-orange); +} +.site-header .topbar { + max-width: 1280px; margin: 0 auto; + padding: 8px 16px; + display: flex; align-items: center; gap: 18px; +} +.site-header .logo { + font-family: 'Trebuchet MS', sans-serif; + font-size: 24px; font-weight: 700; + color: #fff; letter-spacing: 0.5px; +} +.site-header .logo .accent { color: var(--bgg-orange); } +.site-header a { color: #e6e6e6; } +.site-header a:hover { color: var(--bgg-orange); text-decoration: none; } +.site-header nav { display: flex; gap: 14px; flex: 1; font-size: 13px; } +.site-header nav a { padding: 4px 0; } +.site-header .user-area { display: flex; align-items: center; gap: 12px; font-size: 12px; } +.search-bar { + display: flex; align-items: stretch; + border: 1px solid #555; + border-radius: 3px; + overflow: hidden; + background: #2a2f33; +} +.search-bar input[type="text"] { + background: #2a2f33; color: #fff; + border: 0; padding: 6px 8px; + font-size: 12px; width: 180px; + outline: 0; +} +.search-bar select { + background: #2a2f33; color: #fff; + border: 0; border-left: 1px solid #555; + font-size: 12px; padding: 0 6px; + outline: 0; +} +.search-bar button { + background: var(--bgg-orange); color: #fff; + border: 0; padding: 4px 10px; + cursor: pointer; font-weight: 600; +} + +.subnav { + background: #2C3438; color: #e6e6e6; + font-size: 12px; +} +.subnav .inner { + max-width: 1280px; margin: 0 auto; + padding: 6px 16px; display: flex; gap: 18px; + flex-wrap: wrap; +} +.subnav a { color: #e6e6e6; } +.subnav a:hover { color: var(--bgg-orange); } + +/* ----- layout ----- */ +.page { + max-width: 1280px; margin: 0 auto; + padding: 16px; + display: grid; grid-template-columns: 1fr 320px; + gap: 16px; +} +.page.single { grid-template-columns: 1fr; } +.main, .sidebar { + background: var(--bgg-paper); + border: 1px solid var(--bgg-border); + border-radius: 4px; + padding: 14px 18px; + box-shadow: var(--bgg-card-shadow); +} + +/* ----- flash ----- */ +.flash { + padding: 8px 12px; border-radius: 3px; + margin: 0 0 14px; font-size: 13px; +} +.flash.success { background: #e2f3e3; color: #2c5a2e; border: 1px solid #b9dfba; } +.flash.error { background: #fde2e2; color: #7a2424; border: 1px solid #f5b5b5; } + +/* ----- tables ----- */ +.collection_table { + width: 100%; border-collapse: collapse; + font-size: 12px; +} +.collection_table th { + background: var(--bgg-blue); color: #fff; + text-align: left; padding: 6px 8px; + font-size: 11px; text-transform: uppercase; + font-weight: 600; +} +.collection_table th a { color: #fff; } +.collection_table td { + padding: 6px 8px; + border-bottom: 1px solid #ececec; + vertical-align: middle; +} +.collection_table tr:nth-child(even) { background: #fafbfc; } +.collection_table tr:hover { background: #fff6ed; } +.collection_table .collection_rank { width: 50px; text-align: center; color: var(--bgg-muted); font-weight: 600; } +.collection_table .collection_thumbnail { width: 56px; } +.collection_table .collection_thumbnail img { + width: 50px; height: 50px; object-fit: cover; border-radius: 2px; + border: 1px solid var(--bgg-border); +} +.collection_table .collection_objectname { min-width: 280px; } +.collection_table .collection_objectname a.primary { + font-weight: 700; color: var(--bgg-link); font-size: 13px; +} +.collection_table .collection_objectname .smallerfont { color: var(--bgg-muted); font-size: 11px; margin-left: 4px; } +.collection_table .collection_bggrating { width: 70px; text-align: center; } + +.rating-chip { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + color: #fff; font-weight: 700; font-size: 12px; + min-width: 36px; text-align: center; +} + +/* ----- box layout for the homepage / panels ----- */ +.module { + background: var(--bgg-paper); + border: 1px solid var(--bgg-border); + border-radius: 4px; + margin-bottom: 14px; + overflow: hidden; +} +.module .module-header { + background: var(--bgg-blue); + color: #fff; + padding: 6px 12px; + font-weight: 600; + font-size: 13px; + display: flex; justify-content: space-between; align-items: center; +} +.module .module-header a { color: #fff; font-size: 11px; } +.module .module-body { padding: 10px 12px; } + +/* ----- game grid (hot list) ----- */ +.game-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; +} +.game-grid .game-card { + text-align: center; font-size: 12px; +} +.game-grid .game-card img { + width: 100%; aspect-ratio: 1 / 1; object-fit: cover; + border: 1px solid var(--bgg-border); border-radius: 3px; +} +.game-grid .game-card .title { display: block; margin-top: 4px; font-weight: 600; } +.game-grid .game-card .meta { color: var(--bgg-muted); font-size: 11px; } + +/* ----- item (game detail) page ----- */ +.game-header { + display: grid; + grid-template-columns: 240px 1fr 240px; + gap: 20px; + align-items: start; +} +.game-header .cover img { + width: 100%; + border: 1px solid var(--bgg-border); + border-radius: 3px; + background: #f0f0f0; +} +.game-header .summary h1 { + margin-top: 0; font-size: 26px; line-height: 1.1; color: #181c1e; +} +.game-header .summary .year { + color: var(--bgg-muted); font-weight: 400; font-size: 18px; +} +.game-header .summary .meta { + font-size: 13px; color: #333; margin: 6px 0 12px; +} +.game-header .summary .meta a { color: var(--bgg-link); } +.stats-box { + border: 1px solid var(--bgg-border); + border-radius: 3px; padding: 10px; + font-size: 12px; background: #fafbfc; +} +.stats-box .stat-row { + display: flex; justify-content: space-between; + padding: 4px 0; border-bottom: 1px dashed #ddd; +} +.stats-box .stat-row:last-child { border-bottom: 0; } +.stats-box .stat-label { color: var(--bgg-muted); } +.stats-box .stat-value { font-weight: 600; } + +.tab-bar { + border-bottom: 2px solid var(--bgg-orange); + margin-top: 18px; + display: flex; gap: 0; +} +.tab-bar a { + padding: 8px 14px; + font-size: 12px; font-weight: 600; + color: var(--bgg-text); + border: 1px solid transparent; + border-bottom: 0; + border-radius: 4px 4px 0 0; + margin-right: 2px; + text-transform: uppercase; +} +.tab-bar a.active { + background: var(--bgg-orange); color: #fff; +} +.tab-bar a:hover:not(.active) { + background: #fff3e7; color: var(--bgg-orange); text-decoration: none; +} + +/* ----- forms ----- */ +form .field { margin-bottom: 10px; } +form label { display: block; font-weight: 600; margin-bottom: 3px; font-size: 12px; color: var(--bgg-text); } +form input[type=text], form input[type=email], form input[type=password], +form input[type=number], form select, form textarea { + width: 100%; padding: 6px 8px; + border: 1px solid var(--bgg-border); + border-radius: 3px; + font-family: inherit; font-size: 13px; + background: #fff; +} +form textarea { min-height: 100px; resize: vertical; } +form .form-row { display: flex; gap: 10px; } +form .form-row .field { flex: 1; } +.field-error { color: var(--bgg-red); font-size: 11px; margin-top: 3px; } +.user-actions-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.profile-layout { display: grid; grid-template-columns: 220px 1fr; gap: 20px; } + +button, .btn { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--bgg-blue-dark); + background: var(--bgg-blue); + color: #fff !important; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + text-decoration: none !important; +} +button:hover, .btn:hover { + background: var(--bgg-blue-dark); +} +.btn-orange { background: var(--bgg-orange); border-color: #c14000; } +.btn-orange:hover { background: #c14000; } +.btn-quiet { + background: transparent; + color: var(--bgg-link) !important; + border: 1px solid var(--bgg-border); +} +.btn-quiet:hover { background: #f6f6f6; color: var(--bgg-link-hover) !important; } +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ----- comments / posts ----- */ +.post { + border: 1px solid var(--bgg-border); + border-radius: 3px; + margin-bottom: 10px; + background: #fff; + display: grid; + grid-template-columns: 160px 1fr; +} +.post .post-author { + background: #f1f4f9; + padding: 12px; + border-right: 1px solid var(--bgg-border); + font-size: 12px; text-align: center; +} +.post .post-author .username { font-weight: 700; } +.post .post-author .location { color: var(--bgg-muted); font-size: 11px; } +.post .post-body { + padding: 12px 14px; + font-size: 13px; +} +.post .post-meta { + display: flex; justify-content: space-between; + color: var(--bgg-muted); font-size: 11px; + border-bottom: 1px dotted var(--bgg-border); + padding-bottom: 6px; margin-bottom: 8px; +} + +.review-block { + border: 1px solid var(--bgg-border); + border-left: 4px solid var(--bgg-orange); + border-radius: 3px; + padding: 10px 12px; + background: #fff; + margin-bottom: 10px; +} +.review-block .review-head { + display: flex; justify-content: space-between; + align-items: center; + font-size: 12px; margin-bottom: 6px; +} +.review-block .review-head .left { display: flex; gap: 10px; align-items: center; } + +/* ----- pagination ----- */ +.pagination { + margin: 14px 0; text-align: center; font-size: 12px; +} +.pagination a, .pagination span { + display: inline-block; padding: 4px 8px; + border: 1px solid var(--bgg-border); border-radius: 3px; + margin: 0 2px; min-width: 30px; +} +.pagination span.current { background: var(--bgg-orange); color: #fff; border-color: var(--bgg-orange); } + +/* ----- breadcrumb ----- */ +.crumb { font-size: 11px; color: var(--bgg-muted); margin-bottom: 6px; } +.crumb a { color: var(--bgg-link); } + +/* ----- thread row ----- */ +.thread-row { + display: grid; + grid-template-columns: 1fr 90px 100px 140px; + padding: 6px 8px; + border-bottom: 1px solid #ececec; + font-size: 12px; align-items: center; +} +.thread-row:hover { background: #fff6ed; } +.thread-row.pinned { background: #fffbe5; } +.thread-row .subject a { font-weight: 600; } +.thread-row .pin-badge { color: var(--bgg-orange); font-weight: 700; font-size: 10px; margin-right: 4px; } +.thread-row .replies, .thread-row .views { text-align: center; color: var(--bgg-muted); } +.thread-row .last { color: var(--bgg-muted); font-size: 11px; } +.thread-row .last .user { color: var(--bgg-link); } + +/* ----- footer ----- */ +.site-footer { + background: var(--bgg-dark); color: #ccc; + padding: 16px; + text-align: center; + font-size: 11px; + margin-top: 30px; +} +.site-footer a { color: var(--bgg-orange-light); } + +/* ----- responsive: collapse to single column under 900 ----- */ +@media (max-width: 900px) { + .page { grid-template-columns: 1fr; } + .page > * { min-width: 0; } + .sidebar { order: 2; } + .game-header { grid-template-columns: 1fr; } + .game-header .cover { max-width: 360px; margin: 0 auto; } + .collection_table { display: block; max-width: 100%; overflow-x: auto; } + .module .module-body { overflow-x: auto; } + .tab-bar { overflow-x: auto; } + .post { grid-template-columns: 1fr; } + .post .post-author { border-right: 0; border-bottom: 1px solid var(--bgg-border); } +} + +@media (max-width: 600px) { + .site-header .topbar { flex-wrap: wrap; gap: 8px 12px; padding: 8px; } + .site-header .logo { flex: 1 1 auto; font-size: 20px; } + .site-header .user-area { margin-left: auto; } + .site-header nav { order: 3; flex: 1 1 100%; justify-content: space-between; gap: 8px; } + .search-bar { order: 4; flex: 1 1 100%; width: 100%; } + .search-bar input[type="text"] { flex: 1 1 auto; min-width: 0; width: auto; } + .subnav .inner { padding: 6px 8px; gap: 10px 14px; } + .page { padding: 8px; gap: 8px; } + .main, .sidebar { padding: 10px; } + .thread-row { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; } + .thread-row .views { display: none; } + .thread-row .last { grid-column: 1 / -1; text-align: left; } + .user-actions-grid, .profile-layout { grid-template-columns: 1fr; } + form .form-row { flex-wrap: wrap; } + form .form-row .field { flex: 1 1 140px; } +} + +/* ----- ratings histogram ----- */ +.hist-table { width: 100%; font-size: 12px; } +.hist-table td { padding: 2px 4px; } +.hist-table .bar-cell { width: 60%; } +.hist-bar { + background: var(--bgg-orange); + height: 12px; border-radius: 2px; +} + +/* ----- person/category page ----- */ +.entity-head { + display: grid; grid-template-columns: 90px 1fr; gap: 14px; + margin-bottom: 14px; +} +.entity-head .label { + display: inline-block; + background: var(--bgg-blue); color: #fff; padding: 2px 6px; + font-size: 11px; text-transform: uppercase; border-radius: 2px; +} + +/* ----- description -----*/ +.description { + font-family: Verdana, sans-serif; + font-size: 13px; line-height: 1.55; +} +.description p { margin: 0 0 10px; } +.description em { color: var(--bgg-text); } + +.muted { color: var(--bgg-muted); font-size: 11px; } + +/* ----- 2026 live-site fidelity layer ----- */ +:root { + --bgg-purple: #3f3a60; + --bgg-purple-dark: #302c4c; + --bgg-canvas: #e7e7ea; + --bgg-link-modern: #1768ac; + --bgg-orange-modern: #f15a29; + --bgg-ink: #101014; +} + +html, body { + background: var(--bgg-canvas); + color: var(--bgg-ink); + font-family: proxima-nova, Arial, Helvetica, sans-serif; + font-size: 16px; + line-height: 1.35; +} + +a { color: var(--bgg-link-modern); } +a:hover { color: #0c4d83; text-decoration: none; } +h1, h2, h3, h4, h5 { + color: var(--bgg-ink); + font-family: proxima-nova, Arial, Helvetica, sans-serif; + font-weight: 700; +} +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} + +.site-header { + height: 52px; + background: var(--bgg-purple); + border: 0; + color: #fff; +} +.site-header .topbar { + width: 100%; max-width: none; height: 52px; margin: 0; + padding: 0 14px; gap: 11px; flex-wrap: nowrap; +} +.menu-toggle { + display: flex; flex-direction: column; justify-content: center; gap: 4px; + width: 22px; height: 40px; padding: 0; border: 0; background: none; +} +.menu-toggle span { display: block; width: 14px; height: 1px; background: rgba(255,255,255,.8); } +.menu-toggle:hover { background: none; } +.site-header .logo { + display: flex; align-items: center; gap: 6px; margin-right: 8px; + color: #fff; font-size: 23px; letter-spacing: .2px; +} +.logo-shield { + display: inline-block; width: 21px; height: 31px; + background: var(--bgg-orange-modern); + clip-path: polygon(12% 12%, 100% 0, 88% 84%, 35% 100%, 0 72%); + transform: rotate(4deg); +} +.logo-type { font-weight: 800; } +.logo-caret { color: rgba(255,255,255,.55); font-size: 11px; margin-left: 1px; } +.site-header .primary-nav { + display: flex; align-items: stretch; flex: 1; gap: 0; font-size: 14px; +} +.site-header .primary-nav a { + display: flex; align-items: center; padding: 0 10px; + color: #fff; font-weight: 700; +} +.site-header .primary-nav a:hover { background: var(--bgg-purple-dark); color: #fff; } +.site-header .primary-nav span { margin-left: 4px; color: rgba(255,255,255,.55); font-size: 9px; } +.site-header .user-area { + display: flex; flex: 0 0 auto; align-items: center; gap: 14px; + margin-left: auto; font-size: 14px; font-weight: 700; +} +.site-header .user-area a { color: #fff; } +.site-header .user-area form { margin: 0; } +.header-action { + padding: 0; border: 0; background: none; text-transform: none; + font-size: 14px; color: #fff !important; +} +.search-bar { + width: min(300px, 25vw); height: 34px; margin-left: 3px; + display: flex; align-items: center; border: 0; border-radius: 18px; + overflow: hidden; background: #fff; +} +.search-icon { + width: 14px; height: 14px; margin-left: 13px; flex: 0 0 auto; + border: 2px solid #8b8b95; border-radius: 50%; position: relative; +} +.search-icon:after { + content: ""; position: absolute; width: 6px; height: 2px; right: -5px; bottom: -2px; + background: #8b8b95; transform: rotate(-45deg); +} +.search-bar input[type="text"] { + flex: 1; width: auto; min-width: 0; height: 34px; padding: 6px 9px; + background: #fff; color: #222; font-size: 16px; +} +.search-bar select, .search-bar button { display: none; } + +.site-ad { + position: relative; max-width: 1200px; height: 126px; margin: 9px auto; + display: flex; align-items: center; justify-content: center; overflow: hidden; + background: + linear-gradient(115deg, transparent 0 24%, #76c8d8 24% 31%, #f7a937 31% 35%, #fff 35% 66%, #6bc3d5 66% 73%, #f8ab33 73% 77%, transparent 77%), + linear-gradient(35deg, #27344a, #899070); +} +.site-ad:before, .site-ad:after { + content: ""; position: absolute; inset: 0 62% 0 0; opacity: .7; + background: + radial-gradient(circle at 18% 30%, #cfb284 0 6%, transparent 6.5%), + radial-gradient(circle at 45% 65%, #727fbc 0 10%, transparent 10.5%), + radial-gradient(circle at 75% 35%, #e5ba56 0 8%, transparent 8.5%); +} +.site-ad:after { inset: 0 0 0 65%; transform: scaleX(-1); } +.site-ad-copy { z-index: 1; display: flex; flex-direction: column; align-items: center; line-height: .9; } +.site-ad-copy strong { color: #e95d37; font-size: 54px; letter-spacing: 2px; } +.site-ad-copy strong span { color: var(--bgg-purple); } +.site-ad-copy small { color: var(--bgg-purple); font-size: 28px; font-weight: 700; } + +.home-surface { + max-width: 1240px; margin: 0 auto 30px; background: #fff; + border-radius: 7px; overflow: hidden; +} +.home-tabs { + height: 57px; margin: 0 38px; display: flex; align-items: flex-end; + gap: 26px; border-bottom: 1px solid #d7d7da; +} +.home-tabs a { + height: 57px; padding: 20px 16px 13px; color: #56555b; font-size: 18px; +} +.home-tabs a.active { + color: #111; font-weight: 700; border-bottom: 3px solid var(--bgg-orange-modern); +} +.featured-stories { + padding: 38px; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr); + gap: 34px; +} +.featured-hero { + min-width: 0; display: grid; grid-template-columns: 1fr; color: #111; position: relative; +} +.featured-hero > img { + width: 100%; height: 308px; object-fit: cover; object-position: center; + border-radius: 7px; filter: brightness(.62) saturate(.9); +} +.featured-overlay { + position: absolute; top: 24px; left: 28px; color: #fff; + font-size: clamp(34px, 4vw, 57px); line-height: .9; font-weight: 800; letter-spacing: -1px; + text-shadow: 0 2px 15px rgba(0,0,0,.5); +} +.featured-hero > strong { margin-top: 15px; font-size: 21px; } +.featured-hero > small { margin-top: 7px; color: #64636a; font-size: 14px; } +.featured-list { display: flex; flex-direction: column; } +.featured-list a { + min-height: 92px; padding: 13px 0; display: flex; align-items: center; gap: 16px; + border-bottom: 1px solid #d6d6d8; color: #111; +} +.featured-list a:last-child { border-bottom: 0; } +.featured-list img { width: 76px; height: 76px; object-fit: cover; border-radius: 5px; } +.featured-list span { min-width: 0; display: flex; flex-direction: column; gap: 8px; } +.featured-list b { font-size: 17px; } +.featured-list small { color: #66656b; font-size: 14px; } + +.home-module { padding: 32px 38px; border-top: 1px solid #d6d6d8; overflow: hidden; } +.home-module-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; } +.home-module-header h2 { margin: 0 0 2px; font-size: 20px; text-transform: uppercase; } +.home-module-header p { margin: 0; color: #66656b; } +.home-module-header > a { + color: #333; font-size: 12px; text-transform: uppercase; margin-top: 5px; +} +.card-strip { + display: grid; grid-auto-flow: column; grid-auto-columns: minmax(190px, 1fr); + gap: 13px; overflow-x: auto; scrollbar-width: thin; padding-bottom: 8px; +} +.editorial-card, .campaign-card { min-width: 0; color: #111; } +.editorial-card > img, .campaign-card > img { + width: 100%; height: 125px; object-fit: cover; border-radius: 6px; +} +.editorial-card h3, .campaign-card h3 { margin: 10px 0 5px; font-size: 15px; line-height: 1.15; } +.editorial-card h3 span { margin-right: 5px; } +.editorial-card p, .campaign-card p { margin: 0; font-size: 12px; line-height: 1.35; color: #333; } +.campaign-strip { grid-auto-columns: minmax(205px, 1fr); } +.campaign-card small { color: #66656b; } +.campaign-card > span { + display: inline-block; margin-top: 12px; padding: 6px 10px; + border: 1px solid #4f8987; border-radius: 4px; color: #397472; font-size: 12px; +} +.preview-events { + padding: 0 38px 32px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; +} +.preview-events a { + min-height: 82px; padding: 12px 18px; display: grid; + grid-template-columns: 115px 1fr 95px; align-items: center; gap: 12px; + border-radius: 10px; background: #060606; color: #fff; +} +.preview-events > a > strong { font-family: Georgia, serif; font-size: 24px; text-align: center; line-height: .8; } +.preview-events span { color: #e69a57; font-size: 10px; } +.preview-events span b { color: #fff; font-size: 14px; } +.preview-events span small { display: block; color: #aaa; } +.preview-events i { + padding: 9px; border-radius: 20px; background: #fff; color: #111; + font-style: normal; text-align: center; text-transform: uppercase; font-size: 12px; font-weight: 700; +} +.video-grid { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); gap: 14px; } +.video-grid a { color: #111; } +.video-grid a > div { position: relative; height: 118px; overflow: hidden; border-radius: 5px; background: #ddd; } +.video-grid img { width: 100%; height: 100%; object-fit: cover; } +.video-grid a > div span { + position: absolute; inset: 0; display: grid; place-items: center; + color: #fff; font-size: 28px; text-shadow: 0 2px 8px #000; +} +.video-grid b, .video-grid small { display: block; margin-top: 7px; } +.video-grid b { font-size: 14px; } +.video-grid small { color: #66656b; font-size: 12px; } + +.home-ad-break { + height: 112px; margin: 0 38px 32px; display: grid; place-items: center; + color: #fff; font-size: 26px; font-weight: 800; letter-spacing: 3px; + background: + linear-gradient(120deg, rgba(63,58,96,.95), rgba(241,90,41,.86)), + repeating-linear-gradient(45deg, #34304e 0 18px, #27243d 18px 36px); +} +.home-ad-break-small { height: 76px; margin-top: 30px; margin-bottom: 0; } +.ranked-strip { grid-auto-columns: minmax(185px, 1fr); } + +.home-community-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; +} +.home-community-grid .home-module-header { margin-bottom: 9px; } +.community-column { min-width: 0; } +.compact-feed { border-top: 1px solid #d6d6d8; } +.compact-feed > a { + min-height: 82px; padding: 11px 0; display: flex; align-items: center; gap: 13px; + border-bottom: 1px solid #dedee1; color: #111; +} +.compact-feed img { width: 62px; height: 62px; object-fit: cover; flex: 0 0 auto; } +.compact-feed span { min-width: 0; } +.compact-feed b { display: block; font-size: 15px; line-height: 1.2; } +.compact-feed small { display: block; margin-top: 6px; color: #68676d; font-size: 12px; } +.compact-feed-text > a { min-height: 68px; } + +.giveaway-strip { grid-auto-columns: minmax(235px, 1fr); } +.giveaway-card { color: #111; } +.giveaway-card > div { position: relative; height: 138px; overflow: hidden; border-radius: 4px; background: #ddd; } +.giveaway-card img { width: 100%; height: 100%; object-fit: cover; filter: saturate(.95) brightness(.75); } +.giveaway-card strong { + position: absolute; inset: auto 10px 10px; padding: 8px; color: #fff; + border: 2px solid #fff; text-align: center; font-size: 14px; letter-spacing: 1px; +} +.giveaway-card h3 { margin: 9px 0 3px; font-size: 15px; } +.giveaway-card small { color: #66656b; font-size: 12px; } + +.review-feed { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 34px; } +.review-feed > a { + min-height: 104px; padding: 13px 0; display: flex; align-items: flex-start; gap: 14px; + border-top: 1px solid #dedee1; color: #111; +} +.review-feed img { width: 72px; height: 72px; object-fit: cover; flex: 0 0 auto; } +.review-feed span { min-width: 0; } +.review-feed b { display: block; font-size: 15px; line-height: 1.18; } +.review-feed small { display: block; margin: 4px 0; color: #66656b; font-size: 12px; } +.review-feed p { margin: 0; color: #343338; font-size: 12px; line-height: 1.32; } + +.home-community-grid-three { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; } +.text-feed { margin: 0; padding: 0; list-style-position: inside; border-top: 1px solid #d6d6d8; } +.text-feed li { padding: 11px 0; border-bottom: 1px solid #dedee1; color: #666; } +.text-feed a { color: #111; } +.text-feed b { display: block; font-size: 14px; } +.text-feed small { display: block; margin: 5px 0 0 18px; color: #68676d; font-size: 11px; } + +.image-wall { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 145px; gap: 8px; } +.image-wall a { position: relative; min-width: 0; overflow: hidden; background: #252430; } +.image-wall a:nth-child(4n+1), .image-wall a:nth-child(5n) { grid-row: span 2; } +.image-wall img { width: 100%; height: 100%; object-fit: cover; transition: transform .18s ease; } +.image-wall a:hover img { transform: scale(1.035); } +.image-wall span { + position: absolute; inset: auto 0 0; padding: 20px 9px 8px; color: #fff; + background: linear-gradient(transparent, rgba(0,0,0,.82)); font-size: 12px; font-weight: 700; +} + +.video-grid-wide { + display: grid; grid-auto-flow: column; grid-auto-columns: minmax(210px, 1fr); + grid-template-columns: none; overflow-x: auto; padding-bottom: 8px; +} +.video-grid-wide a > div { height: 126px; } +.video-grid-wide i { + position: absolute; right: 6px; bottom: 6px; padding: 2px 5px; + color: #fff; background: rgba(0,0,0,.82); font-size: 11px; font-style: normal; +} +.creator-spotlight { padding-bottom: 42px; } +.creator-intro { + min-height: 118px; margin-bottom: 22px; padding: 20px; display: flex; align-items: center; gap: 18px; + color: #fff; background: linear-gradient(110deg, #24213a, #4a456d 65%, #ef6539); +} +.creator-avatar { + width: 72px; height: 72px; display: grid; place-items: center; flex: 0 0 auto; + border: 3px solid rgba(255,255,255,.8); border-radius: 50%; background: #f15a29; + color: #fff; font-size: 24px; font-weight: 800; +} +.creator-intro h3 { margin: 0; color: #fff; font-size: 22px; } +.creator-intro b { display: block; margin-top: 2px; color: #f5b49e; font-size: 13px; } +.creator-intro p { max-width: 680px; margin: 8px 0 0; color: #e7e5ef; font-size: 13px; } + +.rail-layout { + max-width: 1250px; margin: 0 auto 30px; display: grid; + grid-template-columns: 150px minmax(0, 1fr); gap: 10px; align-items: start; +} +.hotness-rail { background: #fff; overflow: hidden; border-radius: 6px 6px 0 0; } +.rail-title { + padding: 8px 10px; background: var(--bgg-purple); color: #fff; + font-weight: 700; font-size: 12px; text-transform: uppercase; +} +.rail-filter { + padding: 8px; border-bottom: 1px solid #ddd; color: #222; + font-size: 11px; font-weight: 700; text-transform: uppercase; +} +.rail-more { float: right; font-size: 16px; } +.rail-game { + min-height: 42px; padding: 5px 7px; display: grid; + grid-template-columns: 31px minmax(0, 1fr) 9px; gap: 6px; align-items: center; + color: #15151a; border-bottom: 1px solid #e2e2e4; +} +.rail-game img { width: 31px; height: 31px; object-fit: cover; } +.rail-game span { min-width: 0; } +.rail-game b { + display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 12px; line-height: 1.1; +} +.rail-game small { display: block; font-size: 10px; color: #666; } +.rail-game i { font-size: 8px; font-style: normal; } +.rail-game i.up { color: #438568; } +.rail-game i.down { color: #a64a53; } + +.rail-layout > .main { + min-width: 0; padding: 10px; background: #fff; border: 0; border-radius: 6px; + box-shadow: none; +} +.commission-note { + padding: 12px; margin-bottom: 12px; border: 1px solid #d9d9dc; + color: #64636a; font-size: 16px; +} +.commission-note span { float: right; color: #111; font-weight: 700; } +.browse-main h1 { margin: 10px 8px; font-size: 24px; } +.browse-main > .muted { margin: 8px; font-size: 13px; } +.browse-main .collection_table { font-size: 14px; } +.browse-main .collection_table th { + height: 55px; padding: 8px; background: #fff; color: var(--bgg-link-modern); + border-bottom: 1px solid #c8c8cc; text-align: center; font-size: 14px; text-transform: none; +} +.browse-main .collection_table th:nth-child(3) { text-align: center; } +.browse-main .collection_table td { + height: 88px; padding: 10px; border: 1px solid #d8d8da; border-width: 0 1px 1px 0; +} +.browse-main .collection_table tr:nth-child(even) { background: #fff; } +.browse-main .collection_table tr:hover { background: #f7f7f8; } +.browse-main .collection_table .collection_rank { width: 65px; color: #111; } +.browse-main .collection_table .collection_thumbnail { width: 96px; } +.browse-main .collection_table .collection_thumbnail img { width: 64px; height: 64px; } +.browse-main .collection_table .collection_objectname a.primary { color: var(--bgg-link-modern); font-size: 15px; } +.browse-main .collection_table .collection_objectname .muted { font-size: 13px; color: #555; } +.collection_shop { width: 86px; text-align: center; } + +.item-main { padding: 0 !important; overflow: hidden; } +.item-main > .crumb { display: none; } +.game-hero { + position: relative; isolation: isolate; overflow: hidden; + min-height: 445px; padding: 14px; + grid-template-columns: 180px minmax(0, 1fr) 210px; gap: 24px; + background: #09090d; color: #fff; +} +.game-hero:after { + content: ""; position: absolute; inset: 0; z-index: -1; + background: linear-gradient(90deg, rgba(5,5,9,.98) 0 55%, rgba(5,5,9,.58)); +} +.game-hero-art { + position: absolute; inset: 0 0 0 45%; z-index: -2; + width: 55%; height: 100%; object-fit: cover; filter: saturate(.65); +} +.game-hero .cover { z-index: 1; } +.game-hero .cover img { border: 0; border-radius: 2px; } +.game-hero .summary, .game-hero .stats-box { z-index: 1; } +.game-kicker { margin-bottom: 34px; color: #ddd; font-size: 12px; text-transform: uppercase; } +.game-hero .summary h1 { color: #fff; font-size: 26px; } +.game-hero .summary .year { color: #ddd; font-size: 16px; } +.game-hero .summary .muted, .game-hero .summary .meta { color: #eee !important; } +.game-hero .summary .meta { margin: 8px 0; } +.game-hero .summary a { color: #fff !important; opacity: .88; text-decoration: underline; } +.game-hero .stats-box { + border: 0; background: rgba(0,0,0,.72); color: #fff; padding: 12px; +} +.game-hero .stat-label { color: #bbb; } +.hero-rating { + width: 66px; height: 58px; margin: 0 auto 14px; display: grid; place-items: center; + background: #4d9468; color: #fff; font-size: 25px; font-weight: 800; + clip-path: polygon(50% 0, 93% 22%, 93% 78%, 50% 100%, 7% 78%, 7% 22%); +} +.game-actions { + padding: 0 20px 15px; margin-top: -61px; position: relative; z-index: 2; + display: flex; justify-content: flex-end; gap: 5px; background: transparent; +} +.game-actions a { + padding: 9px 12px; color: #fff; border-radius: 4px; font-size: 13px; font-weight: 700; +} +.game-actions .action-buy { background: #47935c; } +.game-actions .action-sleeve { background: #625b8a; } +.game-actions .action-collection { background: #3379c9; } +.game-actions .action-light { background: #f3f3f5; color: #111; } +.item-main .tab-bar { + margin: 0; padding: 0 24px; overflow-x: auto; border-bottom: 1px solid #ccc; + background: #fff; flex-wrap: nowrap; +} +.item-main .tab-bar a { + flex: 0 0 auto; padding: 14px 10px; color: #111; border: 0; border-radius: 0; + font-size: 13px; text-transform: none; +} +.item-main .tab-bar a.active { color: #111; background: #fff; border-bottom: 4px solid var(--bgg-orange-modern); } +.item-main > h2, .item-main > h3, .item-main > p, .item-main > form, +.item-main > .description, .item-main > .collection_table, .item-main > .thread-row, +.item-main > .user-actions-grid { margin-left: 24px; margin-right: 24px; } +.item-main > h2 { margin-top: 28px; } + +.site-footer { + margin-top: 0; padding: 28px; background: #29263f; color: #d7d4e4; + display: flex; justify-content: center; align-items: center; gap: 20px; +} +.site-footer a { color: #fff; } +.footer-logo { display: flex; align-items: center; gap: 7px; font-size: 20px; } +.footer-logo .logo-shield { width: 15px; height: 22px; } + +@media (max-width: 1050px) { + .site-header .primary-nav a { padding: 0 6px; } + .site-header .primary-nav a:nth-last-child(-n+3) { display: none; } + .search-bar { width: 220px; } + .site-ad { margin-left: 14px; margin-right: 14px; } +} + +@media (max-width: 900px) { + .site-header .primary-nav { display: none; } + .site-header .topbar { padding: 0 10px; } + .site-header .user-area { margin-left: auto; } + .site-ad { display: none; } + .rail-layout { display: block; margin: 0; } + .hotness-rail { display: none; } + .rail-layout > .main { border-radius: 0; } + .home-surface { margin: 0; border-radius: 0; } + .featured-stories { grid-template-columns: 1fr; padding: 25px; } + .featured-list { display: grid; grid-template-columns: 1fr 1fr; gap: 0 20px; } + .home-community-grid, .home-community-grid-three { grid-template-columns: 1fr; } + .review-feed { grid-template-columns: 1fr; } + .image-wall { grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 130px; } + .game-hero { grid-template-columns: 170px 1fr; } + .game-hero .stats-box { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3,1fr); gap: 0 16px; } + .hero-rating { grid-row: span 4; } + .game-actions { margin-top: 0; padding: 10px 18px; justify-content: flex-start; flex-wrap: wrap; background: #111; } +} + +@media (max-width: 650px) { + .site-header .logo { font-size: 22px; margin-right: auto; } + .site-header .user-area { gap: 13px; font-size: 13px; } + .search-bar { width: 34px; margin-left: 0; background: transparent; } + .search-bar .search-icon { border-color: #c8c5d6; } + .search-bar .search-icon:after { background: #c8c5d6; } + .search-bar input { display: none; } + .home-tabs { height: 43px; margin: 0 15px; gap: 8px; } + .home-tabs a { height: 43px; padding: 12px 16px 8px; font-size: 16px; } + .featured-stories { padding: 18px 15px; } + .featured-hero > img { height: 245px; } + .featured-overlay { top: 20px; left: 20px; font-size: 39px; } + .featured-list { display: flex; } + .home-module { padding: 27px 15px; } + .home-ad-break { height: 72px; margin: 0 15px 20px; font-size: 17px; } + .home-module-header h2 { font-size: 16px; } + .card-strip { grid-auto-columns: 225px; } + .preview-events { padding: 0 15px 20px; grid-template-columns: 1fr; } + .video-grid { display: flex; overflow-x: auto; } + .video-grid a { flex: 0 0 225px; } + .image-wall { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 150px; } + .image-wall a:nth-child(4n+1), .image-wall a:nth-child(5n) { grid-row: span 1; } + .creator-intro { align-items: flex-start; padding: 16px; } + .commission-note { font-size: 14px; } + .commission-note span { float: none; display: block; margin-top: 4px; } + .browse-main { overflow-x: auto; } + .browse-main .collection_table { min-width: 790px; } + .game-hero { min-height: 0; grid-template-columns: 105px minmax(0,1fr); gap: 14px; padding: 14px; } + .game-kicker { margin-bottom: 10px; font-size: 9px; } + .game-hero .summary h1 { font-size: 21px; } + .game-hero .summary .meta { font-size: 12px; } + .game-hero .stats-box { display: none; } + .game-actions { gap: 4px; } + .game-actions a { padding: 7px 8px; font-size: 11px; } +} diff --git a/sites/boardgamegeek/static/icons/cover_placeholder.svg b/sites/boardgamegeek/static/icons/cover_placeholder.svg new file mode 100644 index 00000000..0ddede58 --- /dev/null +++ b/sites/boardgamegeek/static/icons/cover_placeholder.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + BGG + diff --git a/sites/boardgamegeek/static/icons/favicon.svg b/sites/boardgamegeek/static/icons/favicon.svg new file mode 100644 index 00000000..cb2d8bd8 --- /dev/null +++ b/sites/boardgamegeek/static/icons/favicon.svg @@ -0,0 +1,5 @@ + + + B + diff --git a/sites/boardgamegeek/tasks.jsonl b/sites/boardgamegeek/tasks.jsonl new file mode 100644 index 00000000..73609610 --- /dev/null +++ b/sites/boardgamegeek/tasks.jsonl @@ -0,0 +1,21 @@ +{"web_name":"BoardGameGeek","id":"BoardGameGeek--0","ques":"Find the #1 ranked board game on BoardGameGeek and report its designer(s). (Designers are not shown on the rank table — open the game's detail page.)","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_0.py","judge_rubric":"FACT CHECKPOINTS: MUST open the ranking page and the #1 game's detail page. MUST report the correct game and every designer shown there. Empty, partial, or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--1","ques":"Search for 'Gloomhaven' and report its average rating, weight, and number of voters from the game page.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_1.py","judge_rubric":"FACT CHECKPOINTS: MUST use site search and open Gloomhaven's game page. MUST report its average rating, weight, and voter count. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--2","ques":"On the Browse page, sort games by weight descending. Among the games listed on the first page of weight-sorted results, find the one whose overall rank is in the top 100 AND whose weight is the highest. Report its name and weight score.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS: MUST open Browse with descending weight sorting and use the first result page. MUST apply the top-100-rank constraint and report the qualifying game's name and weight. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--3","ques":"Find the highest-ranked board game whose mechanism includes 'Worker Placement' and report its designer(s).","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Worker Placement mechanism page and the highest-ranked qualifying game's detail page. MUST report the correct game and all listed designers. Empty, partial, or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--4","ques":"Sign in as alice_j (password TestPass123!). How many games are in her owned collection?","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_4.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j and open her owned collection. MUST report the displayed owned-game count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--5","ques":"Sign in as bob_c (password TestPass123!), open Brass: Birmingham, rate it 9.5 and write a one-sentence review.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open Brass: Birmingham, submit a 9.5 rating, and submit a non-empty one-sentence review. A claim without the saved rating and review FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--6","ques":"Sign in as carol_d (password TestPass123!). Using the 'Deck Construction' and 'Deck, Bag, and Pool Building' mechanism pages, find the highest-ranked game designed only for 2 players, then add it to her wishlist with priority 'Must have'.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as carol_d and inspect both named mechanism pages. MUST identify the highest-ranked game whose minimum and maximum player counts are both 2, then save it to her wishlist with priority Must have. A claim without the saved wishlist state FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--7","ques":"On the Hot page, find the latest publication year in the current Hotness list, then report the highest-ranked game from that year.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Hot page, determine the latest year present, and compare overall ranks among entries from that year. MUST report the highest-ranked qualifying game. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--8","ques":"Open the GeekList named 'Best Cooperative Games'. Which game is ranked #7 on that list, and what is that game's weight on its game page?","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Best Cooperative Games GeekList, identify its #7 entry, and open that game's detail page. MUST report the game and its displayed weight. Empty, partial, or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--9","ques":"Sign in as david_k (password TestPass123!) and create a new GeekList titled 'My COIN Series Picks' with the description 'Light, deep, and historical.'","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and create a GeekList with the exact requested title and description. A claim without the newly saved list under david_k FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--10","ques":"Compare Brass: Birmingham and Ark Nova on their game pages — which one has the heavier weight (higher complexity)?","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_10.py","judge_rubric":"FACT CHECKPOINTS: MUST open both game pages and compare their displayed weight values. MUST name the game with the higher weight. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--11","ques":"Open the publisher page for 'Z-Man Games' (use the Publishers index — you may have to filter by name). Report how many games are listed under that publisher in our catalog.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Publishers index and open the Z-Man Games publisher page. MUST report the catalog count shown for that publisher. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--12","ques":"Browse to the 'Action Points' mechanism page. Among games using Action Points, what is the highest-ranked one and what is its overall rank?","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS: MUST open the Action Points mechanism page and compare the listed games' overall ranks. MUST report the highest-ranked game's name and rank. Empty or incomplete answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--13","ques":"Sign in as alice_j (password TestPass123!). Open her collection sorted by 'Most recently updated' and remove the most-recently-updated entry from her collection.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_13.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as alice_j, sort her collection by Most recently updated, and remove the first entry from that ordering. A claim without the corresponding collection deletion FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--14","ques":"Open Wingspan and navigate to its Ratings & Reviews tab. Sort by 'Most Helpful' (thumbs) and report the username and thumb count of the top review. (The Overview tab does not list individual reviews — you must open the Ratings tab.)","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_14.py","judge_rubric":"FACT CHECKPOINTS: MUST open Wingspan's Ratings & Reviews tab and sort reviews by Most Helpful. MUST report the first review's username and thumb count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--15","ques":"In the Recommendations forum, find an open thread (not pinned, not locked) about 2-player games, open it, and post a reply suggesting '7 Wonders Duel' as an alternative. (Sign in as bob_c first, password TestPass123!.)","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as bob_c, open the Recommendations forum, select a 2-player thread that is neither pinned nor locked, and post a reply containing 7 Wonders Duel. A claim without the saved post in a qualifying thread FAILS."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--16","ques":"Open the 'Top 50 Heaviest Games of the Last Decade' GeekList. What is the #1 entry on that list?","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_16.py","judge_rubric":"FACT CHECKPOINTS: MUST open the named GeekList and inspect its ordered entries. MUST report the game in position #1. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--17","ques":"Sign in as david_k (password TestPass123!). Open his Plays page and report how many distinct games he has logged plays for.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: MUST sign in as david_k and open his Plays page. MUST count distinct game titles rather than play rows and report that count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--18","ques":"Find Vital Lacerda's designer page (the Designers index supports filtering by name). Among his games in the catalog, which one has the highest average rating?","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_18.py","judge_rubric":"FACT CHECKPOINTS: MUST use the Designers index and open Vital Lacerda's page. MUST compare the displayed average ratings and report the highest-rated listed game. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--19","ques":"Search for users by the keyword 'mike'. Open the first matching user profile (alphabetical) and report how many GeekLists they have authored.","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_19.py","judge_rubric":"FACT CHECKPOINTS: MUST search users for mike, use alphabetical result order, and open the first matching profile. MUST report that profile's authored-GeekList count. Empty or unsupported answers FAIL."} +{"web_name":"BoardGameGeek","id":"BoardGameGeek--20","ques":"Open Twilight Struggle's Expansions page. How many expansions are listed there?","web":"http://localhost:40024/","upstream_url":"https://boardgamegeek.com/","verifier_path":"sites/boardgamegeek/verify/verify_20.py","judge_rubric":"FACT CHECKPOINTS: MUST open Twilight Struggle's Expansions page and count the listed expansions. MUST report that count. Empty or unsupported answers FAIL."} diff --git a/sites/boardgamegeek/templates/_hotness_rail.html b/sites/boardgamegeek/templates/_hotness_rail.html new file mode 100644 index 00000000..70cf6f52 --- /dev/null +++ b/sites/boardgamegeek/templates/_hotness_rail.html @@ -0,0 +1,13 @@ + diff --git a/sites/boardgamegeek/templates/about.html b/sites/boardgamegeek/templates/about.html new file mode 100644 index 00000000..3d146c3d --- /dev/null +++ b/sites/boardgamegeek/templates/about.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}About | {{ site_name }}{% endblock %} +{% block content %} +
+
+

About BoardGameGeek

+

BoardGameGeek (BGG) is the world's largest online community of board game enthusiasts. Since 2000, BGG has been the place where players go to look up rules, read reviews, log plays, swap games and argue about which Lacerda title is the best one.

+

This is a WebHarbor mirror of the real boardgamegeek.com — a deterministic local environment used to benchmark web agents. Every page, every link, every rating is backed by data scraped from the real site so that the look and behavior match closely, but the environment is fully offline and resets to a known state between benchmark runs.

+

Real site: boardgamegeek.com

+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/account.html b/sites/boardgamegeek/templates/account.html new file mode 100644 index 00000000..ae00d06e --- /dev/null +++ b/sites/boardgamegeek/templates/account.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Account | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Account — {{ current_user.username }}

+
+ {{ form.hidden_tag() }} +
{{ form.real_name.label }}{{ form.real_name() }}
+
+
{{ form.country.label }}{{ form.country() }}
+
{{ form.state.label }}{{ form.state() }}
+
{{ form.city.label }}{{ form.city() }}
+
+
{{ form.about.label }}{{ form.about() }}
+ + Cancel +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/base.html b/sites/boardgamegeek/templates/base.html new file mode 100644 index 00000000..3717a97c --- /dev/null +++ b/sites/boardgamegeek/templates/base.html @@ -0,0 +1,78 @@ + + + + + {% block title %}{{ site_name }}{% endblock %} + + + + {% block head_extra %}{% endblock %} + + + + + +
+
+
GEEKUPUpgrade Your Game
+
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+ +
© {{ current_year }} BoardGameGeek · About · Help
+
+ + + diff --git a/sites/boardgamegeek/templates/browse.html b/sites/boardgamegeek/templates/browse.html new file mode 100644 index 00000000..f8ae44e6 --- /dev/null +++ b/sites/boardgamegeek/templates/browse.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Browse Board Games | {{ site_name }}{% endblock %} +{% block content %} +
+ {% include "_hotness_rail.html" %} +
+
We may earn a commission when you buy through our links. + {{ page }}, Next » +
+

Browse Board Games

+

+ Showing {{ ((page - 1) * per_page) + 1 }} – {{ ((page - 1) * per_page) + games | length }} + of {{ total | thousands }} games. + Sort by: + Rank · + Name · + Year · + Average Rating · + Voters · + Weight +

+ + + + + + + + + + + + + + + + {% for g in games %} + + + + + + + + + + + {% endfor %} + +
RankTitleGeek RatingAvg RatingNum VotersWeightShop
{{ g.overall_rank or '—' }} + {% if g.thumb_filename %} + + {% endif %} + + {{ g.name }} + ({{ g.year_published or '—' }}) + {% if g.short_description %} +
{{ g.short_description[:120] }}{% if g.short_description | length > 120 %}…{% endif %}
+ {% endif %} +
{{ g.bayes_average | one_decimal }}{{ g.avg_rating | one_decimal }}{{ g.num_ratings | thousands }}{{ g.weight | two_decimal }}View
+ + +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/collection.html b/sites/boardgamegeek/templates/collection.html new file mode 100644 index 00000000..16acf3f7 --- /dev/null +++ b/sites/boardgamegeek/templates/collection.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }}'s collection | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ u.username }}'s collection

+

+ Filter: + Own · + Previously owned · + Wishlist · + Want to play · + Want to buy · + Pre-ordered · + For trade · + Rated +

+

+ Sort: Name · + Rank · + My rating · + Year · + Most recently updated · + Acquired +

+

{{ total }} games in this view.

+ + + + + + + + + + + {% for x in entries %} + + + + + + + + + {% else %} + + {% endfor %} + +
TitleMy ratingRankYearComment
+ {% if x.game.thumb_filename %}{% endif %} + + {{ x.game.name }} + + {% if x.entry.own %}[own]{% endif %} + {% if x.entry.wishlist %}[wish #{{ x.entry.wishlist_priority }}]{% endif %} + {% if x.entry.want_to_buy %}[want]{% endif %} + {% if x.entry.want_to_play %}[wanttoplay]{% endif %} + {% if x.entry.preordered %}[preorder]{% endif %} + {% if x.entry.for_trade %}[trade]{% endif %} + + + {% if x.rating %}{{ x.rating.value | one_decimal }} + {% else %}{% endif %} + {{ x.game.overall_rank or '—' }}{{ x.game.year_published or '—' }}{{ x.entry.comment }}
No games match this filter.
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/credits.html b/sites/boardgamegeek/templates/credits.html new file mode 100644 index 00000000..38934e6a --- /dev/null +++ b/sites/boardgamegeek/templates/credits.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Credits — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Credits +
+

{{ g.name }} — Credits

+ +

Designers

+
    + {% for d in g.designers %} +
  • {{ d.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Artists

+
    + {% for a in g.artists %} +
  • {{ a.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Publishers

+
    + {% for p in g.publishers %} +
  • {{ p.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Categories

+
    + {% for c in g.categories %} +
  • {{ c.name }}
  • + {% else %}
  • {% endfor %} +
+ +

Mechanisms

+
    + {% for m in g.mechanics %} +
  • {{ m.name }}
  • + {% else %}
  • {% endfor %} +
+ + {% if g.families %} +

Families

+
    + {% for f in g.families %} +
  • {{ f.name }}
  • + {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/expansions.html b/sites/boardgamegeek/templates/expansions.html new file mode 100644 index 00000000..5e33f085 --- /dev/null +++ b/sites/boardgamegeek/templates/expansions.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Expansions — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Expansions +
+

{{ g.name }} — Expansions ({{ expansions | length }})

+ {% if expansions %} + + + + + + + + {% for e in expansions %} + + + + + + + + {% endfor %} + +
TitleYearAvg RatingVoters
+ {% if e.thumb_filename %}{% endif %} + + {{ e.name }} + {{ e.year_published or '—' }}{{ e.avg_rating | one_decimal }}{{ e.num_ratings | thousands }}
+ {% else %}

No expansions on record.

{% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/forgot.html b/sites/boardgamegeek/templates/forgot.html new file mode 100644 index 00000000..1d14362c --- /dev/null +++ b/sites/boardgamegeek/templates/forgot.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Forgot Password | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Forgot Password

+ {% if msg %}
{{ msg }}
{% endif %} +
+ +
+ + +
+ +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/forum.html b/sites/boardgamegeek/templates/forum.html new file mode 100644 index 00000000..aa498fed --- /dev/null +++ b/sites/boardgamegeek/templates/forum.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}{{ forum.title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Forums › + {% if forum.game %}{{ forum.game.name }} ›{% endif %} + {{ forum.title }} +
+

{{ forum.title }}

+

{{ forum.description }}

+ + {% if current_user.is_authenticated and not forum.id == 0 %} +

+ New Thread

+ {% endif %} + + {% for t in threads %} +
+
+ {% if t.is_pinned %}PIN{% endif %} + {% if t.is_hot %}🔥{% endif %} + {{ t.subject }} +
started by {{ t.author.username }} · {{ t.created_at | time_ago }}
+
+
{{ t.num_posts - 1 }} replies
+
{{ t.num_views | thousands }} views
+
last: {{ t.last_post_at | time_ago }}
+
+ {% else %} +

No threads in this forum yet.

+ {% endfor %} + + +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/forums_index.html b/sites/boardgamegeek/templates/forums_index.html new file mode 100644 index 00000000..973cd9cc --- /dev/null +++ b/sites/boardgamegeek/templates/forums_index.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Forums | {{ site_name }}{% endblock %} +{% block content %} +
+
+

BoardGameGeek Forums

+ {% for section_name, forums in sections.items() %} +

{{ section_name | capitalize }}

+ + + + + + + + {% for f in forums %} + + + + + + {% endfor %} + +
ForumThreadsPosts
+ {{ f.title }} +
{{ f.description }}
+
{{ f.num_threads | thousands }}{{ f.num_posts | thousands }}
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/game_forums.html b/sites/boardgamegeek/templates/game_forums.html new file mode 100644 index 00000000..2ae5322d --- /dev/null +++ b/sites/boardgamegeek/templates/game_forums.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Forums — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Forums +
+

{{ g.name }} — Forums

+ + + + + + + {% for f in forums %} + + + + + + {% endfor %} + +
ForumThreadsPosts
+ {{ f.title }} +
{{ f.description }}
+
{{ f.num_threads | thousands }}{{ f.num_posts | thousands }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/geeklist.html b/sites/boardgamegeek/templates/geeklist.html new file mode 100644 index 00000000..070fbe57 --- /dev/null +++ b/sites/boardgamegeek/templates/geeklist.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}{{ l.title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
GeekLists › {{ l.title }}
+

{{ l.title }}

+

+ by {{ l.author.username }} + · {{ l.num_items }} items · 👍 {{ l.num_thumbs }} · {{ l.created_at | time_ago }} + {% if current_user.is_authenticated %} +

+ + + + + +
+ {% endif %} +

+
{{ l.description_html | safe_html }}
+ + {% for it in items %} +
+
+
+ #{{ it.position }} + {% if it.game %} + + {% if it.game.thumb_filename %}{% endif %} + {{ it.game.name }} + ({{ it.game.year_published or '—' }}) + + {% else %} + (custom item) + {% endif %} +
+
👍 {{ it.num_thumbs }}
+
+
{{ it.body_html | safe_html }}
+
+ {% endfor %} + + {% if current_user.is_authenticated and l.author_id == current_user.id %} +

Add an item

+
+ +
+
+
+
+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/geeklist_new.html b/sites/boardgamegeek/templates/geeklist_new.html new file mode 100644 index 00000000..c453c077 --- /dev/null +++ b/sites/boardgamegeek/templates/geeklist_new.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}New GeekList | {{ site_name }}{% endblock %} +{% block content %} +
+
+

New GeekList

+
+ {{ form.hidden_tag() }} +
{{ form.title.label }}{{ form.title() }}
+
{{ form.description.label }}{{ form.description() }}
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/geeklists.html b/sites/boardgamegeek/templates/geeklists.html new file mode 100644 index 00000000..7ac9142d --- /dev/null +++ b/sites/boardgamegeek/templates/geeklists.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}GeekLists | {{ site_name }}{% endblock %} +{% block content %} +
+
+

GeekLists

+

+ {{ total }} lists. Sort: + Recent · + Most thumbed · + Most items + {% if current_user.is_authenticated %} + · + New GeekList + {% endif %} +

+ + + + + + + + + + {% for l in lists %} + + + + + + + + {% endfor %} + +
TitleAuthorItems👍Created
+ {{ l.title }} + {{ l.author.username }}{{ l.num_items }}{{ l.num_thumbs }}{{ l.created_at | time_ago }}
+ +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/help.html b/sites/boardgamegeek/templates/help.html new file mode 100644 index 00000000..8e736b65 --- /dev/null +++ b/sites/boardgamegeek/templates/help.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Help | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Help & FAQ

+

Sections of the site

+
    +
  • Browse — the master rank table of all board games.
  • +
  • Hot — what the community is talking about right now.
  • +
  • Forums — site-wide discussion plus per-game subforums.
  • +
  • GeekLists — curated lists of games.
  • +
  • Your account — collection, plays, ratings, geek profile.
  • +
+

How rankings work

+

Each game has an average rating (raw mean of every user's score) and a Geek Rating (Bayesian-adjusted average that prevents low-vote games from topping the list).

+

Reporting issues

+

This is a WebHarbor benchmark mirror; please file issues on the parent project.

+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/hotness.html b/sites/boardgamegeek/templates/hotness.html new file mode 100644 index 00000000..929ff345 --- /dev/null +++ b/sites/boardgamegeek/templates/hotness.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}The Hotness | {{ site_name }}{% endblock %} +{% block content %} +
+
+

The Hotness

+

The games BoardGameGeek users are talking about most right now (top 50).

+ +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/index.html b/sites/boardgamegeek/templates/index.html new file mode 100644 index 00000000..1de1ae89 --- /dev/null +++ b/sites/boardgamegeek/templates/index.html @@ -0,0 +1,235 @@ +{% extends "base.html" %} +{% block title %}{{ site_name }} | Gaming Unplugged Since 2000{% endblock %} +{% block content %} +
+ + + + +
+
+

The Hotness

Top trending games today

+ See All › +
+ +
+ +
+
+

Crowdfunding Countdown

Projects ending soon

+ See All › +
+ +
+ +
+ SPIELGEEKPREVIEW
SPIEL ESSENBrowse featured titles
Browse
+ GEN
CON
GEEKPREVIEW
GEN CON PREVIEWDiscover new releases
Browse
+
+ + + + + +
+
+

Bestsellers

Week of Aug 31–Sep 6

+ See All › +
+ +
+ +
+ + +
+ +
+
+

Giveaway Contests

Answer questions for a chance to win – Sponsored

+
+ +
+ +
+
+

Most Played Games

Month of August

+ See All › +
+ +
+ + + +
+

Hot In-Depth Reviews

+ +
+ +
+
+

Hot GeekLists

See All ›
+
    + {% for l in recent_lists[:7] %}
  1. {{ l.title }}{{ l.author.username }} · {{ l.num_items }} items · ♡ {{ l.num_thumbs }}
  2. {% endfor %} +
+
+
+

Blogs

See All ›
+ +
+ +
+ +
+
+

Hot Images

The top trending images on BGG today.

+ See All › +
+
+ {% for g in top_overall[:12] %} + {% if g.image_filename %}{{ g.name }}{% elif g.thumb_filename %}{{ g.name }}{% endif %}{{ g.name }} + {% endfor %} +
+
+ +
+

Hot Videos

See All ›
+ +
+ +
+

Creator Spotlight

View Channel ›
+
BG

BoardGameGeek Community

Video Channel

Playthroughs, reviews, tutorials, and tabletop stories from creators across the community.

+ +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/item.html b/sites/boardgamegeek/templates/item.html new file mode 100644 index 00000000..5cefc68c --- /dev/null +++ b/sites/boardgamegeek/templates/item.html @@ -0,0 +1,275 @@ +{% extends "base.html" %} +{% block title %}{{ g.name }} ({{ g.year_published or '—' }}) | {{ site_name }}{% endblock %} +{% block content %} +
+ {% include "_hotness_rail.html" %} +
+
+ Browse › + {% if g.subtype == 'boardgameexpansion' %}Expansion{% else %}Board Game{% endif %} › + {{ g.name }} +
+ +
+ {% if g.image_filename %} + + {% endif %} +
+ {% if g.image_filename %} + {{ g.name }} + {% else %} + {{ g.name }} + {% endif %} +
+
+
REIMPLEMENTED BY: {{ g.name | upper }} · + {% if g.overall_rank %}RANK: OVERALL {{ g.overall_rank }}{% endif %} +
+

{{ g.name }} ({{ g.year_published or '—' }})

+ {% if g.short_description %} +

{{ g.short_description }}

+ {% endif %} +
+ Designed by: + {% for d in g.designers[:5] %} + {{ d.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Art by: + {% for a in g.artists[:4] %} + {{ a.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Publishers: + {% for p in g.publishers[:6] %} + {{ p.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Categories: + {% for c in g.categories %} + {{ c.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+ Mechanisms: + {% for m in g.mechanics %} + {{ m.name }}{% if not loop.last %}, {% endif %} + {% else %}—{% endfor %} +
+
+
+
{{ g.avg_rating | one_decimal }}
+
+ Overall Rank + {% if g.overall_rank %}#{{ g.overall_rank }}{% else %}—{% endif %} +
+
+ Geek Rating + + + {{ g.bayes_average | one_decimal }} + + +
+
+ Average Rating + {{ g.avg_rating | two_decimal }} +
+
+ Num Voters + {{ g.num_ratings | thousands }} +
+
+ Owners + {{ g.num_owners | thousands }} +
+
+ Wishlist + {{ g.num_wishing | thousands }} +
+
+ Players + {{ g.players_str }} {% if g.best_player_count %}(best {{ g.best_player_count }}){% endif %} +
+
+ Play Time + {{ g.time_str }} +
+
+ Age + {{ g.minage }}+ +
+
+ Weight + {{ g.weight | two_decimal }} / 5 ({{ g.weight_label }}) +
+
+ Language Dep. + {{ g.language_dependence or '—' }} +
+
+
+ +
+ 🛒 Buy a Copy + ▣ Sleeve It + {% if current_user.is_authenticated %} + ☷ Add to Collection + {% else %} + ☷ Add to Collection + {% endif %} + Log Play + ♥ {{ g.num_owners | thousands }} +
+ + + +

Description

+
{{ g.description_html | safe_html }}
+ + {# Top Reviews moved off the overview tab — agents must navigate to + Ratings & Reviews to read individual reviews. This avoids the + hard-coded "top review visible on overview" shortcut. The summary + counts (avg, voters, weight) remain in the stats box above. #} + + {% if expansions %} +

Expansions

+ + + {% for e in expansions[:8] %} + + + + + + {% endfor %} + +
+ {% if e.thumb_filename %}{% endif %} + + {{ e.name }} + ({{ e.year_published or '—' }}) + {{ e.avg_rating | one_decimal }}
+ {% if expansions | length > 8 %}

See all expansions →

{% endif %} + {% endif %} + + {% if threads %} +

Recent Forum Activity

+ {% for t in threads %} +
+
+ {% if t.is_pinned %}PIN{% endif %} + {{ t.subject }} + · {{ t.forum.title }} +
+
{{ t.num_posts - 1 }} replies
+
{{ t.num_views | thousands }}
+
{{ t.last_post_at | time_ago }}
+
+ {% endfor %} + {% endif %} + + {# User actions #} + {% if current_user.is_authenticated %} +

Your Stuff

+ + + {% if my_collection %} +
+ + +
+ {% endif %} + +

Log a play

+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ {% else %} +

+ Sign in + to rate this game, add it to your collection, or log a play. +

+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/login.html b/sites/boardgamegeek/templates/login.html new file mode 100644 index 00000000..7a5532a3 --- /dev/null +++ b/sites/boardgamegeek/templates/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Sign In | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Sign In

+
+ {{ form.hidden_tag() }} +
{{ form.username.label }}{{ form.username() }}
+
{{ form.password.label }}{{ form.password() }}
+ + Create account + Forgot password +
+

+ Benchmark accounts: alice_j, bob_c, carol_d, david_k + — password TestPass123!. +

+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/person.html b/sites/boardgamegeek/templates/person.html new file mode 100644 index 00000000..b85df238 --- /dev/null +++ b/sites/boardgamegeek/templates/person.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}{{ person.name }} ({{ kind | capitalize }}) | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ person.name }}

+

Board game {{ kind }}, {{ games | length }} games on file.

+ +

Sort: Rank · Year + · Avg Rating · Name

+ + + + + + + + + + {% for g in games %} + + + + + + + + {% endfor %} + +
TitleYearRankAvg
+ {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {{ g.year_published or '—' }}{{ g.overall_rank or '—' }}{{ g.avg_rating | one_decimal }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/plays.html b/sites/boardgamegeek/templates/plays.html new file mode 100644 index 00000000..fd6655fc --- /dev/null +++ b/sites/boardgamegeek/templates/plays.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }}'s plays | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ u.username }}'s plays

+ + + + + + + {% for p, g in plays %} + + + + + + + + + {% else %} + + {% endfor %} + +
DateGamePlayersLengthLocationComments
{{ p.played_on.strftime('%Y-%m-%d') }}{% if g %}{{ g.name }}{% else %}(deleted){% endif %}{{ p.num_players or '—' }}{{ p.length_minutes }} min{{ p.location }}{{ p.comments }}
No plays logged yet.
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/property.html b/sites/boardgamegeek/templates/property.html new file mode 100644 index 00000000..301e3831 --- /dev/null +++ b/sites/boardgamegeek/templates/property.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ entity.name }} ({{ kind | capitalize }}) | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ {% if kind == 'category' %}Categories + {% elif kind == 'mechanic' %}Mechanisms + {% else %}Browse{% endif %} › + {{ entity.name }} +
+
+
{{ kind | capitalize }}
+
+

{{ entity.name }}

+

{{ games | length }} games tagged with this {{ kind }}.

+
+
+ +

Sort: Rank · Avg Rating + · Year · Name

+ + + + + + + + + + + {% for g in games %} + + + + + + + + + + {% endfor %} + +
#TitleYearGeekAvgVoters
{{ g.overall_rank or '—' }} + {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {{ g.year_published or '—' }}{{ g.bayes_average | one_decimal }}{{ g.avg_rating | one_decimal }}{{ g.num_ratings | thousands }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/publisher.html b/sites/boardgamegeek/templates/publisher.html new file mode 100644 index 00000000..75c8f2b7 --- /dev/null +++ b/sites/boardgamegeek/templates/publisher.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}{{ publisher.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ publisher.name }}

+

Publisher · {{ games | length }} games on file.

+ + + + + + + + + + {% for g in games %} + + + + + + + + {% endfor %} + +
TitleYearRankAvg
+ {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {{ g.year_published or '—' }}{{ g.overall_rank or '—' }}{{ g.avg_rating | one_decimal }}
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/ratings.html b/sites/boardgamegeek/templates/ratings.html new file mode 100644 index 00000000..ad2a4041 --- /dev/null +++ b/sites/boardgamegeek/templates/ratings.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}Ratings & Reviews — {{ g.name }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Browse › + {{ g.name }} › Ratings & Reviews +
+ +

{{ g.name }} — Ratings & Reviews

+ +
+
+
+ Average + {{ g.avg_rating | two_decimal }} +
+
+ Voters + {{ g.num_ratings | thousands }} +
+
+ Comments + {{ g.num_comments | thousands }} +
+

Distribution

+ + {% set max_count = histogram.values() | max %} + {% for bucket in range(10, 0, -1) %} + + + + + + {% endfor %} +
{{ bucket }} + {% if max_count and histogram[bucket] %} +
+ {% endif %} +
{{ histogram[bucket] }}
+
+ +
+

+ Sort: + Highest · + Lowest · + Most Recent · + Most Helpful +

+ + + + + + + {% for r in ratings %} + + + + + + + + {% endfor %} + +
RatingUserReview / CommentDate👍
+ {{ r.value | one_decimal }} + + {{ r.user.username }} + {% if r.user.country %}
{{ r.user.country }}
{% endif %} +
+ {% if r.review_html %}{{ r.review_html | safe_html }} + {% else %}no comment{% endif %} + {{ r.created_at.strftime('%Y-%m-%d') }}{{ r.num_thumbs }}
+ + +
+
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/register.html b/sites/boardgamegeek/templates/register.html new file mode 100644 index 00000000..ff0e3903 --- /dev/null +++ b/sites/boardgamegeek/templates/register.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Create Account | {{ site_name }}{% endblock %} +{% block content %} +
+
+

Create Account

+
+ {{ form.hidden_tag() }} +
+ {{ form.username.label }}{{ form.username() }} + {% for error in form.username.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.email.label }}{{ form.email() }} + {% for error in form.email.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.password.label }}{{ form.password() }} + {% for error in form.password.errors %}
{{ error }}
{% endfor %} +
+
{{ form.real_name.label }}{{ form.real_name() }}
+
{{ form.country.label }}{{ form.country() }}
+ + Sign in instead +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/search.html b/sites/boardgamegeek/templates/search.html new file mode 100644 index 00000000..35fe20e0 --- /dev/null +++ b/sites/boardgamegeek/templates/search.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %} + {% if q %}Search: {{ q }} | {{ site_name }}{% else %}Search | {{ site_name }}{% endif %} +{% endblock %} +{% block content %} +
+
+

Search

+ +
+
+
+
+ +
+
+
+
+ + {% if q %} +

{{ total | thousands }} result{{ '' if total == 1 else 's' }} for {{ q }}.

+ + {% if tab == 'boardgame' %} + + + + + + + + + {% for g in games %} + + + + + + + + {% endfor %} + +
TitleYearRankAvg
+ {% if g.thumb_filename %}{% endif %} + + {{ g.name }} + {% if g.short_description %}
{{ g.short_description[:140] }}{% if g.short_description | length > 140 %}…{% endif %}
{% endif %} +
{{ g.year_published or '—' }}{{ g.overall_rank or '—' }}{{ g.avg_rating | one_decimal }}
+ {% elif tab == 'user' %} + + + + {% for u in users %} + + + + + + {% endfor %} + +
UsernameReal NameLocation
{{ u.username }}{{ u.real_name or '' }}{{ u.display_location }}
+ {% elif tab == 'geeklist' %} + + + + + + {% for l in lists %} + + + + + + + {% endfor %} + +
ListAuthorItems👍
{{ l.title }}{{ l.author.username }}{{ l.num_items }}{{ l.num_thumbs }}
+ {% endif %} + + + {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/taxonomy_index.html b/sites/boardgamegeek/templates/taxonomy_index.html new file mode 100644 index 00000000..67c27af9 --- /dev/null +++ b/sites/boardgamegeek/templates/taxonomy_index.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+

{{ title }}

+ {% if filter_q is defined %} +
+ + + {% if filter_q %}Clear{% endif %} +
+ {% endif %} +

{{ items | length }} {{ kind }}{{ 's' if items | length != 1 }}{% if filter_q %} matching "{{ filter_q }}"{% endif %}.

+
+ {% for it in items %} +
+ {{ it.name }} + ({{ it.games | length }}) +
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/thread.html b/sites/boardgamegeek/templates/thread.html new file mode 100644 index 00000000..e83aa42b --- /dev/null +++ b/sites/boardgamegeek/templates/thread.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}{{ thread.subject }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Forums › + {{ thread.forum.title }} + › {{ thread.subject }} +
+

{{ thread.subject }}

+

+ {% if thread.is_pinned %}PIN{% endif %} + Started by {{ thread.author.username }} + · {{ thread.created_at | time_ago }} + · {{ thread.num_posts }} posts · {{ thread.num_views | thousands }} views + {% if thread.is_locked %} · (Locked){% endif %} +

+ + {% for p in posts %} +
+ +
+ + {{ p.body_html | safe_html }} + {% if current_user.is_authenticated %} +
+
+ + + + + +
+
+ {% endif %} +
+
+ {% endfor %} + + {% if current_user.is_authenticated and not thread.is_locked %} +

Reply

+
+ {{ reply_form.hidden_tag() }} +
+ +
+ +
+ {% else %} +

+ {% if thread.is_locked %}Thread is locked. + {% else %}Sign in to reply.{% endif %} +

+ {% endif %} +
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/thread_new.html b/sites/boardgamegeek/templates/thread_new.html new file mode 100644 index 00000000..bd308f86 --- /dev/null +++ b/sites/boardgamegeek/templates/thread_new.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}New Thread — {{ forum.title }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+ Forums › + {{ forum.title }} › New Thread +
+

New thread in {{ forum.title }}

+
+ {{ form.hidden_tag() }} +
+ {{ form.subject.label }} + {{ form.subject() }} +
+
+ {{ form.body.label }} + {{ form.body() }} +
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/templates/user.html b/sites/boardgamegeek/templates/user.html new file mode 100644 index 00000000..f2b4ed68 --- /dev/null +++ b/sites/boardgamegeek/templates/user.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }} | {{ site_name }}{% endblock %} +{% block content %} +
+
+
+
+

{{ u.username }}

+ {% if u.real_name %}
{{ u.real_name }}
{% endif %} + {% if u.display_location %}
{{ u.display_location }}
{% endif %} +
joined {{ u.joined_at | time_ago }}
+
+
Owned{{ own }}
+
Wishlist{{ wishlist }}
+
Want to buy{{ want }}
+
Rated{{ rated }}
+
Reviews{{ reviews }}
+
Plays logged{{ plays_count }}
+
GeekLists authored{{ geeklists_count }}
+
GeekGold{{ u.geekgold }}
+
+ +
+
+

{{ u.username }}'s profile

+ {% if u.about %}
{{ u.about }}
{% else %}

No bio yet.

{% endif %} + +

Top-rated games

+ + + {% for r in top_rated %} + + + + + + {% else %} + + {% endfor %} + +
+ {{ r.value | one_decimal }} + {{ r.game.name }} ({{ r.game.year_published or '—' }}){{ r.created_at | time_ago }}
No ratings yet.
+ +

Recent plays

+ + + + {% for p in recent_plays %} + + + + + + + {% else %}{% endfor %} + +
DateGamePlayersLocation
{{ p.played_on.strftime('%Y-%m-%d') }}{% if p.game %}{{ p.game.name }}{% else %}—{% endif %}{{ p.num_players }}{{ p.location }}
No plays logged.
+ + {% if geeklists %} +

GeekLists by {{ u.username }}

+
    + {% for l in geeklists %}
  • {{ l.title }} · {{ l.num_items }} items
  • {% endfor %} +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/sites/boardgamegeek/verify/test_environment_quality.py b/sites/boardgamegeek/verify/test_environment_quality.py new file mode 100644 index 00000000..eb5fb149 --- /dev/null +++ b/sites/boardgamegeek/verify/test_environment_quality.py @@ -0,0 +1,162 @@ +"""Regression checks for the BoardGameGeek review fixes.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.request +from pathlib import Path + + +SITE_DIR = Path(__file__).resolve().parents[1] +SEED_DB = SITE_DIR / "instance_seed" / "boardgamegeek.db" + + +def _copy_runtime(destination: Path, *, include_templates: bool = False) -> None: + shutil.copy2(SITE_DIR / "app.py", destination / "app.py") + shutil.copy2(SITE_DIR / "seed_data.py", destination / "seed_data.py") + instance = destination / "instance" + instance.mkdir() + shutil.copy2(SEED_DB, instance / "boardgamegeek.db") + if include_templates: + shutil.copytree(SITE_DIR / "templates", destination / "templates") + + +class EnvironmentQualityTests(unittest.TestCase): + def test_task_ids_and_urls_match_registered_port(self) -> None: + rows = [ + json.loads(line) + for line in (SITE_DIR / "tasks.jsonl").read_text(encoding="utf-8").splitlines() + ] + self.assertEqual(21, len(rows)) + for number, row in enumerate(rows): + with self.subTest(task=number): + self.assertEqual(f"BoardGameGeek--{number}", row["id"]) + self.assertEqual("http://localhost:40024/", row["web"]) + expected = f"sites/boardgamegeek/verify/verify_{number}.py" + self.assertEqual(expected, row["verifier_path"]) + self.assertTrue((SITE_DIR.parents[1] / expected).is_file()) + self.assertTrue(row["judge_rubric"].startswith("FACT CHECKPOINTS:")) + self.assertNotIn("answer", row) + + def test_registration_template_displays_validation_errors(self) -> None: + template = (SITE_DIR / "templates" / "register.html").read_text(encoding="utf-8") + for field in ("username", "email", "password"): + with self.subTest(field=field): + self.assertIn(f"form.{field}.errors", template) + + def test_task_action_controls_are_exposed_in_the_ui(self) -> None: + item_template = (SITE_DIR / "templates" / "item.html").read_text(encoding="utf-8") + user_template = (SITE_DIR / "templates" / "user.html").read_text(encoding="utf-8") + app_source = (SITE_DIR / "app.py").read_text(encoding="utf-8") + self.assertIn("url_for('collection_remove'", item_template) + self.assertIn("Remove from collection", item_template) + self.assertIn("geeklists_count", user_template) + self.assertIn("geeklists_count=", app_source) + + def test_direct_start_uses_one_flask_sqlalchemy_instance(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + runtime = Path(temp_dir) + _copy_runtime(runtime) + before = hashlib.sha256((runtime / "instance" / "boardgamegeek.db").read_bytes()).hexdigest() + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + env = os.environ.copy() + env["PORT"] = str(port) + process = subprocess.Popen( + [sys.executable, "app.py"], + cwd=runtime, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/_health", timeout=1 + ) as response: + self.assertEqual(200, response.status) + break + except Exception: + if process.poll() is not None: + break + time.sleep(0.1) + else: + self.fail("BoardGameGeek app did not become healthy") + finally: + process.terminate() + output, _ = process.communicate(timeout=10) + after = hashlib.sha256((runtime / "instance" / "boardgamegeek.db").read_bytes()).hexdigest() + self.assertEqual(before, after) + self.assertNotIn("SQLAlchemy instance", output) + self.assertNotIn("[boardgamegeek] seed error", output) + + def test_new_rating_updates_global_aggregate_once(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + runtime = Path(temp_dir) + _copy_runtime(runtime) + script = """ +import json +import app as module + +module.app.config['WTF_CSRF_ENABLED'] = False +with module.app.app_context(): + game = module.Game.query.filter_by(name='Brass: Birmingham').one() + user = module.User.query.filter_by(username='bob_c').one() + before = {'average': game.avg_rating, 'count': game.num_ratings} + game_id = game.bgg_id + user_id = user.id + +with module.app.test_client() as client: + with client.session_transaction() as session: + session['_user_id'] = str(user_id) + session['_fresh'] = True + response = client.post( + f'/rate/{game_id}', + data={'value': '9.5', 'review': 'A focused and rewarding economic game.'}, + ) + +with module.app.app_context(): + game = module.Game.query.filter_by(name='Brass: Birmingham').one() + rating = module.Rating.query.filter_by(user_id=user_id, game_id=game.id).one() + print(json.dumps({ + 'status': response.status_code, + 'before': before, + 'after': {'average': game.avg_rating, 'count': game.num_ratings}, + 'rating': rating.value, + 'review': rating.review_html, + })) +""" + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=runtime, + check=True, + capture_output=True, + text=True, + ) + result = json.loads(completed.stdout.splitlines()[-1]) + before = result["before"] + expected_average = ( + before["average"] * before["count"] + 9.5 + ) / (before["count"] + 1) + self.assertEqual(302, result["status"]) + self.assertEqual(9.5, result["rating"]) + self.assertIn("focused and rewarding", result["review"]) + self.assertEqual(before["count"] + 1, result["after"]["count"]) + self.assertAlmostEqual(expected_average, result["after"]["average"], places=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/boardgamegeek/verify/test_verifiers.py b/sites/boardgamegeek/verify/test_verifiers.py new file mode 100644 index 00000000..764da65c --- /dev/null +++ b/sites/boardgamegeek/verify/test_verifiers.py @@ -0,0 +1,381 @@ +"""Positive, adversarial, and legal-alternative tests for all BGG verifiers.""" + +from __future__ import annotations + +import json +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from urllib.parse import quote_plus + + +VERIFY_DIR = Path(__file__).resolve().parent +SEED_DB = VERIFY_DIR.parent / "instance_seed" / "boardgamegeek.db" +PASSWORD = "TestPass123!" + + +def next_id(connection: sqlite3.Connection, table: str) -> int: + return int(connection.execute(f"SELECT COALESCE(MAX(id),0)+1 FROM {table}").fetchone()[0]) + + +def game_id(connection: sqlite3.Connection, name: str) -> tuple[int, int]: + return tuple(connection.execute("SELECT id,bgg_id FROM games WHERE name=?", (name,)).fetchone()) + + +def user_id(connection: sqlite3.Connection, username: str) -> int: + return int(connection.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()[0]) + + +def mutation(task: int): + if task == 5: + def rate(connection: sqlite3.Connection) -> None: + gid, _ = game_id(connection, "Brass: Birmingham") + uid = user_id(connection, "bob_c") + average, count = connection.execute( + "SELECT avg_rating,num_ratings FROM games WHERE id=?", (gid,) + ).fetchone() + connection.execute( + "INSERT INTO ratings(id,user_id,game_id,value,review_html,created_at,num_thumbs) VALUES(?,?,?,?,?,?,?)", + ( + next_id(connection, "ratings"), uid, gid, 9.5, + "

A focused and rewarding economic game.

", + "2026-05-26 12:00:00.000000", 0, + ), + ) + connection.execute( + "UPDATE games SET avg_rating=?,num_ratings=? WHERE id=?", + (((average * count) + 9.5) / (count + 1), count + 1, gid), + ) + return rate + if task == 6: + def wishlist(connection: sqlite3.Connection) -> None: + gid, _ = game_id(connection, "Android: Netrunner") + uid = user_id(connection, "carol_d") + connection.execute( + """ + INSERT INTO collections( + id,user_id,game_id,own,prevowned,want_to_play,want_to_buy, + wishlist,wishlist_priority,preordered,for_trade,comment, + acquired_on,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + next_id(connection, "collections"), uid, gid, 0, 0, 0, 0, + 1, 1, 0, 0, "", "", "2026-05-26 12:00:00.000000", + ), + ) + return wishlist + if task == 9: + def create_list(connection: sqlite3.Connection) -> None: + connection.execute( + "INSERT INTO geeklists(id,title,description_html,author_id,created_at,num_thumbs,num_items) VALUES(?,?,?,?,?,?,?)", + ( + next_id(connection, "geeklists"), "My COIN Series Picks", + "

Light, deep, and historical.

", user_id(connection, "david_k"), + "2026-05-26 12:00:00.000000", 0, 0, + ), + ) + return create_list + if task == 13: + def remove_recent(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "alice_j") + row = connection.execute( + "SELECT id FROM collections WHERE user_id=? AND own=1 ORDER BY updated_at DESC LIMIT 1", + (uid,), + ).fetchone() + connection.execute("DELETE FROM collections WHERE id=?", (row[0],)) + return remove_recent + if task == 15: + def reply(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "bob_c") + thread = connection.execute( + """ + SELECT t.id,t.forum_id FROM threads t JOIN forums f ON f.id=t.forum_id + WHERE f.title='Recommendations' AND t.is_pinned=0 AND t.is_locked=0 + AND (lower(t.subject) LIKE '%2 player%' OR lower(t.subject) LIKE '%two-player%') + ORDER BY t.id LIMIT 1 + """ + ).fetchone() + connection.execute( + "INSERT INTO posts(id,thread_id,author_id,body_html,created_at,edited_at,thumbs) VALUES(?,?,?,?,?,?,?)", + ( + next_id(connection, "posts"), thread[0], uid, + "

Try 7 Wonders Duel as an alternative.

", + "2026-05-26 12:00:00.000000", None, 0, + ), + ) + connection.execute( + "UPDATE threads SET num_posts=num_posts+1,last_post_at=? WHERE id=?", + ("2026-05-26 12:00:00.000000", thread[0]), + ) + connection.execute( + "UPDATE forums SET num_posts=num_posts+1 WHERE id=?", (thread[1],) + ) + return reply + return None + + +def fixture(task: int, *, alternate: bool = False) -> tuple[list[dict], str, object | None]: + base = "http://127.0.0.1:40021" if alternate else "http://localhost:40021" + + def url(path: str) -> str: + return base + path + + def nav(path: str) -> dict: + return {"url": url(path), "action": "navigate", "params": {}} + + def click(path: str, destination: str) -> dict: + return {"url": url(path), "url_after": url(destination), "action": "click", "params": {}} + + def enter(path: str, text: str) -> dict: + return {"url": url(path), "action": "input", "params": {"text": text}} + + def game(bgg_id: int, slug: str) -> str: + return f"/boardgame/{bgg_id}" if alternate else f"/boardgame/{bgg_id}/{slug}" + + def subpage(bgg_id: int, slug: str, page: str) -> str: + return f"/boardgame/{bgg_id}/{page}" if alternate else f"/boardgame/{bgg_id}/{slug}/{page}" + + def login(username: str) -> list[dict]: + return [ + nav("/login"), enter("/login", username), enter("/login", PASSWORD), + click("/login", f"/user/{username}"), nav(f"/user/{username}"), + ] + + mutate = mutation(task) + if task == 0: + paths = [nav("/browse/boardgame"), click("/browse/boardgame", game(224517, "brass-birmingham")), nav(game(224517, "brass-birmingham"))] + answer = "Brass: Birmingham is #1; its designers are Gavan Brown, Matt Tolman, and Martin Wallace." + elif task == 1: + search = "/search?q=Gloomhaven&type=boardgame" + paths = [nav(search), click(search, game(174430, "gloomhaven")), nav(game(174430, "gloomhaven"))] + answer = "Gloomhaven has average rating 8.5389, weight 3.91917, and 67,165 voters." + elif task == 2: + paths = [nav("/browse/boardgame?sort=weight&dir=desc")] + answer = "On Mars has the highest qualifying weight: 4.62606." + elif task == 3: + worker = "/boardgamemechanic/2082" if alternate else "/boardgamemechanic/2082/worker-placement" + target = game(397598, "dune-imperium-uprising") + paths = [nav("/boardgamemechanic"), click("/boardgamemechanic", worker), nav(worker), click(worker, target), nav(target)] + answer = "Dune: Imperium – Uprising is highest-ranked; its designer is Paul Dennen." + elif task == 4: + paths = login("alice_j") + [nav("/collection/alice_j")] + answer = "alice_j has 18 owned games in her collection." + elif task == 5: + target = game(224517, "brass-birmingham") + paths = login("bob_c") + [nav(target), enter(target, "9.5"), enter(target, "A focused and rewarding economic game."), click(target, target), nav(target)] + answer = "The 9.5 rating and one-sentence review were saved." + elif task == 6: + deck_a = "/boardgamemechanic/3004" if alternate else "/boardgamemechanic/3004/deck-construction" + deck_b = "/boardgamemechanic/2664" if alternate else "/boardgamemechanic/2664/deck-bag-and-pool-building" + target = game(124742, "android-netrunner") + paths = login("carol_d") + [nav(deck_a), nav(deck_b), nav(target), enter(target, "Must have"), click(target, target), nav(target)] + answer = "Android: Netrunner was added to carol_d's wishlist as Must have." + elif task == 7: + paths = [nav("/hot" if alternate else "/hotness")] + answer = "The latest year is 2025; The Lord of the Rings: Fate of the Fellowship is the highest-ranked game from that year." + elif task == 8: + target = game(167355, "nemesis") + paths = [nav("/geeklists"), click("/geeklists", "/geeklist/7"), nav("/geeklist/7"), click("/geeklist/7", target), nav(target)] + answer = "The #7 entry is Nemesis, with a weight of 3.49." + elif task == 9: + new_id = 17 + paths = login("david_k") + [nav("/geeklist/new"), enter("/geeklist/new", "My COIN Series Picks"), enter("/geeklist/new", "Light, deep, and historical."), click("/geeklist/new", f"/geeklist/{new_id}"), nav(f"/geeklist/{new_id}")] + answer = "My COIN Series Picks was created." + elif task == 10: + brass = game(224517, "brass-birmingham") + ark = game(342942, "ark-nova") + paths = [nav(brass), nav(ark)] + answer = "Brass: Birmingham is heavier than Ark Nova (3.86 versus 3.80)." + elif task == 11: + index = "/boardgamepublisher?q=" + quote_plus("Z-Man Games") + detail = "/boardgamepublisher/538" if alternate else "/boardgamepublisher/538/z-man-games" + paths = [nav(index), click(index, detail), nav(detail)] + answer = "Z-Man Games has 116 games listed in the catalog." + elif task == 12: + detail = "/boardgamemechanic/2001" if alternate else "/boardgamemechanic/2001/action-points" + paths = [nav("/boardgamemechanic"), click("/boardgamemechanic", detail), nav(detail)] + answer = "Pandemic Legacy: Season 1 is highest, at overall rank #3." + elif task == 13: + collection = "/collection/alice_j?status=own&sort=recent" + target = game(284378, "kanban-ev") + paths = login("alice_j") + [nav(collection), click(collection, target), nav(target), click(target, target), nav(target)] + answer = "Kanban EV, the most recently updated entry, was removed." + elif task == 14: + target = game(266192, "wingspan") + ratings = subpage(266192, "wingspan", "ratings") + "?sort=thumbs" + paths = [nav(target), click(target, ratings), nav(ratings)] + answer = "The top review is by ogzz with 23 thumbs." + elif task == 15: + target = "/thread/4004" + paths = login("bob_c") + [nav("/forums"), nav("/forum/3"), nav(target), enter(target, "Try 7 Wonders Duel as an alternative."), click(target, target), nav(target)] + answer = "Posted a reply suggesting 7 Wonders Duel." + elif task == 16: + paths = [nav("/geeklists"), click("/geeklists", "/geeklist/1"), nav("/geeklist/1")] + answer = "The #1 entry is Pax Renaissance: 2nd Edition." + elif task == 17: + paths = login("david_k") + [nav("/plays/david_k")] + answer = "david_k has logged plays for 7 distinct games." + elif task == 18: + index = "/boardgamedesigner?q=" + quote_plus("Vital Lacerda") + detail = "/boardgamedesigner/12396?sort=average" if alternate else "/boardgamedesigner/12396/vital-lacerda?sort=average" + paths = [nav(index), click(index, detail), nav(detail)] + answer = "Speakeasy has Vital Lacerda's highest average rating." + elif task == 19: + search = "/search?q=mike&type=user" + paths = [nav(search), click(search, "/user/EViLMiKE"), nav("/user/EViLMiKE")] + answer = "The first match is EViLMiKE, with 0 GeekLists authored." + elif task == 20: + target = game(12333, "twilight-struggle") + expansions = subpage(12333, "twilight-struggle", "expansions") + paths = [nav(target), click(target, expansions), nav(expansions)] + answer = "Twilight Struggle has 11 expansions listed." + else: + raise ValueError(task) + return paths, answer, mutate + + +class VerifierTests(unittest.TestCase): + maxDiff = None + + def run_verifier( + self, + task: int, + steps: list[dict], + answer: str, + mutate=None, + *, + start_url: str | None = None, + ) -> tuple[int, dict]: + with tempfile.TemporaryDirectory(prefix=f"bgg-verify-{task}-") as temp_dir: + root = Path(temp_dir) + initial = root / "initial.db" + after = root / "after.db" + run = root / "run" + run.mkdir() + shutil.copy2(SEED_DB, initial) + shutil.copy2(SEED_DB, after) + if mutate: + connection = sqlite3.connect(after) + try: + mutate(connection) + connection.commit() + finally: + connection.close() + origin = start_url or ( + "http://127.0.0.1:40021/" + if steps and "127.0.0.1" in steps[0]["url"] + else "http://localhost:40021/" + ) + trajectory = { + "task_id": f"BoardGameGeek--{task}", + "start_url": origin, + "steps": steps, + "final_url": steps[-1].get("url_after", steps[-1].get("url")) if steps else origin, + "final_answer": answer, + } + (run / "trajectory.json").write_text(json.dumps(trajectory), encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(VERIFY_DIR / f"verify_{task}.py"), + "--run_dir", str(run), + "--initial_db", str(initial), + "--after_db", str(after), + "--no_llm", "true", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + try: + verdict = json.loads(result.stdout) + except Exception as error: + self.fail(f"task {task} emitted invalid JSON: {error}\nstdout={result.stdout}\nstderr={result.stderr}") + return result.returncode, verdict + + def test_positive_trajectories_pass_every_verifier(self) -> None: + for task in range(21): + with self.subTest(task=task): + steps, answer, mutate = fixture(task) + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_legal_alternative_paths_pass_every_verifier(self) -> None: + for task in range(21): + with self.subTest(task=task): + steps, answer, mutate = fixture(task, alternate=True) + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_close_but_wrong_results_fail_every_verifier(self) -> None: + mutation_tasks = {5, 6, 9, 13, 15} + for task in range(21): + with self.subTest(task=task): + steps, answer, mutate = fixture(task) + if task in mutation_tasks: + mutate = None + else: + answer = "I visited the requested pages but could not determine the answer." + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code, verdict) + self.assertFalse(verdict["pass"], verdict) + + def test_answer_or_state_without_navigation_fails_every_verifier(self) -> None: + for task in range(21): + with self.subTest(task=task): + _steps, answer, mutate = fixture(task) + code, verdict = self.run_verifier(task, [], answer, mutate) + self.assertNotEqual(0, code, verdict) + self.assertFalse(verdict["pass"], verdict) + + def test_prompt_permitted_browse_and_compare_paths_pass(self) -> None: + variants = {} + + steps, answer, mutate = fixture(11) + for step in steps: + for key in ("url", "url_after"): + if key in step: + step[key] = step[key].replace( + "/boardgamepublisher?q=Z-Man+Games", "/boardgamepublisher" + ) + variants[11] = (steps, answer, mutate) + + steps, answer, mutate = fixture(18) + for step in steps: + for key in ("url", "url_after"): + if key in step: + step[key] = step[key].replace( + "/boardgamedesigner?q=Vital+Lacerda", "/boardgamedesigner" + ).replace("?sort=average", "") + variants[18] = (steps, answer, mutate) + + for task, (steps, answer, mutate) in variants.items(): + with self.subTest(task=task): + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_keyboard_form_submission_passes_stateful_tasks(self) -> None: + for task in (5, 6, 9, 13, 15): + with self.subTest(task=task): + steps, answer, mutate = fixture(task) + for step in reversed(steps): + if step["action"] == "click": + step["action"] = "press" + step["params"] = {"key": "Enter"} + break + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/boardgamegeek/verify/verify_0.py b/sites/boardgamegeek/verify/verify_0.py new file mode 100644 index 00000000..875ca698 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_0.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(0) diff --git a/sites/boardgamegeek/verify/verify_1.py b/sites/boardgamegeek/verify/verify_1.py new file mode 100644 index 00000000..6c98f4e9 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_1.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(1) diff --git a/sites/boardgamegeek/verify/verify_10.py b/sites/boardgamegeek/verify/verify_10.py new file mode 100644 index 00000000..1f5f4d8d --- /dev/null +++ b/sites/boardgamegeek/verify/verify_10.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(10) diff --git a/sites/boardgamegeek/verify/verify_11.py b/sites/boardgamegeek/verify/verify_11.py new file mode 100644 index 00000000..6a2b091f --- /dev/null +++ b/sites/boardgamegeek/verify/verify_11.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(11) diff --git a/sites/boardgamegeek/verify/verify_12.py b/sites/boardgamegeek/verify/verify_12.py new file mode 100644 index 00000000..9ab0b635 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_12.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(12) diff --git a/sites/boardgamegeek/verify/verify_13.py b/sites/boardgamegeek/verify/verify_13.py new file mode 100644 index 00000000..ac740840 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_13.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(13) diff --git a/sites/boardgamegeek/verify/verify_14.py b/sites/boardgamegeek/verify/verify_14.py new file mode 100644 index 00000000..7c220825 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_14.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(14) diff --git a/sites/boardgamegeek/verify/verify_15.py b/sites/boardgamegeek/verify/verify_15.py new file mode 100644 index 00000000..5aa0c87a --- /dev/null +++ b/sites/boardgamegeek/verify/verify_15.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(15) diff --git a/sites/boardgamegeek/verify/verify_16.py b/sites/boardgamegeek/verify/verify_16.py new file mode 100644 index 00000000..71f72a9a --- /dev/null +++ b/sites/boardgamegeek/verify/verify_16.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(16) diff --git a/sites/boardgamegeek/verify/verify_17.py b/sites/boardgamegeek/verify/verify_17.py new file mode 100644 index 00000000..9dc8a656 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_17.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(17) diff --git a/sites/boardgamegeek/verify/verify_18.py b/sites/boardgamegeek/verify/verify_18.py new file mode 100644 index 00000000..4f5ea5af --- /dev/null +++ b/sites/boardgamegeek/verify/verify_18.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(18) diff --git a/sites/boardgamegeek/verify/verify_19.py b/sites/boardgamegeek/verify/verify_19.py new file mode 100644 index 00000000..2753a423 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_19.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(19) diff --git a/sites/boardgamegeek/verify/verify_2.py b/sites/boardgamegeek/verify/verify_2.py new file mode 100644 index 00000000..07e66803 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_2.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(2) diff --git a/sites/boardgamegeek/verify/verify_20.py b/sites/boardgamegeek/verify/verify_20.py new file mode 100644 index 00000000..b1b3f352 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_20.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(20) diff --git a/sites/boardgamegeek/verify/verify_3.py b/sites/boardgamegeek/verify/verify_3.py new file mode 100644 index 00000000..b2592f72 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_3.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(3) diff --git a/sites/boardgamegeek/verify/verify_4.py b/sites/boardgamegeek/verify/verify_4.py new file mode 100644 index 00000000..995f6f26 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_4.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(4) diff --git a/sites/boardgamegeek/verify/verify_5.py b/sites/boardgamegeek/verify/verify_5.py new file mode 100644 index 00000000..902bdafc --- /dev/null +++ b/sites/boardgamegeek/verify/verify_5.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(5) diff --git a/sites/boardgamegeek/verify/verify_6.py b/sites/boardgamegeek/verify/verify_6.py new file mode 100644 index 00000000..f8bfa9ef --- /dev/null +++ b/sites/boardgamegeek/verify/verify_6.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(6) diff --git a/sites/boardgamegeek/verify/verify_7.py b/sites/boardgamegeek/verify/verify_7.py new file mode 100644 index 00000000..b95b615d --- /dev/null +++ b/sites/boardgamegeek/verify/verify_7.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(7) diff --git a/sites/boardgamegeek/verify/verify_8.py b/sites/boardgamegeek/verify/verify_8.py new file mode 100644 index 00000000..3182b966 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_8.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(8) diff --git a/sites/boardgamegeek/verify/verify_9.py b/sites/boardgamegeek/verify/verify_9.py new file mode 100644 index 00000000..a1959bb0 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_9.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from verify_lib import run_task + + +if __name__ == "__main__": + run_task(9) diff --git a/sites/boardgamegeek/verify/verify_lib.py b/sites/boardgamegeek/verify/verify_lib.py new file mode 100644 index 00000000..a9f31ef4 --- /dev/null +++ b/sites/boardgamegeek/verify/verify_lib.py @@ -0,0 +1,606 @@ +#!/usr/bin/env python3 +"""Shared deterministic verifier for BoardGameGeek tasks.""" + +from __future__ import annotations + +import argparse +import html +import ipaddress +import json +import os +import re +import sqlite3 +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable, Sequence +from urllib.parse import parse_qs, urlparse + + +PASSWORD = "TestPass123!" +KNOWN_GAME_SUBPAGES = {"ratings", "credits", "expansions", "forums"} + + +@dataclass(frozen=True) +class VerifyArgs: + run_dir: str + initial_db: str | None + after_db: str | None + no_llm: bool + + +def _bool_value(value: str) -> bool: + return str(value).casefold() in {"1", "true", "yes", "on"} + + +def parse_args() -> VerifyArgs: + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db") + parser.add_argument("--after_db") + parser.add_argument("--container", default="") # compatibility with agent_demo + parser.add_argument("--no_llm", nargs="?", const=True, default=False, type=_bool_value) + args = parser.parse_args() + run_dir = Path(args.run_dir) + initial = args.initial_db or str(run_dir / "initial.db") + after = args.after_db or str(run_dir / "after.db") + return VerifyArgs( + run_dir=str(run_dir), + initial_db=initial if Path(initial).is_file() else None, + after_db=after if Path(after).is_file() else None, + no_llm=bool(args.no_llm), + ) + + +def load_run(run_dir: str | os.PathLike[str]) -> dict[str, Any]: + data = json.loads((Path(run_dir) / "trajectory.json").read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("trajectory.json must be a JSON object") + return data + + +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = text.replace("’", "'").replace("“", '"').replace("”", '"') + return re.sub(r"\s+", " ", text).strip().casefold() + + +def plain_html(value: Any) -> str: + return normalize_text(html.unescape(re.sub(r"<[^>]+>", " ", str(value or "")))) + + +def final_answer(trajectory: dict[str, Any]) -> str: + return str(trajectory.get("final_answer") or "").strip() + + +def trajectory_urls(trajectory: dict[str, Any]) -> list[str]: + urls: list[str] = [] + for value in [trajectory.get("start_url")]: + if value: + urls.append(str(value)) + for step in trajectory.get("steps") or []: + if not isinstance(step, dict): + continue + for key in ("url_before", "url", "url_after"): + value = str(step.get(key) or "") + if value and (not urls or value != urls[-1]): + urls.append(value) + final = str(trajectory.get("final_url") or "") + if final and (not urls or final != urls[-1]): + urls.append(final) + return urls + + +def _is_loopback(hostname: str) -> bool: + if hostname.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def is_target_url(url: str, trajectory: dict[str, Any]) -> bool: + parsed = urlparse(str(url or "")) + start = urlparse(str(trajectory.get("start_url") or "")) + return bool( + parsed.scheme in {"http", "https"} + and start.scheme == parsed.scheme + and parsed.hostname + and start.hostname + and _is_loopback(parsed.hostname) + and _is_loopback(start.hostname) + and parsed.port == start.port + ) + + +def normalized_path(url: str) -> str: + path = urlparse(str(url or "")).path or "/" + return path.rstrip("/") or "/" + + +def query_matches(url: str, expected: dict[str, str]) -> bool: + observed = parse_qs(urlparse(url).query) + return all( + normalize_text((observed.get(key) or [""])[0]) == normalize_text(value) + for key, value in expected.items() + ) + + +def target_urls(trajectory: dict[str, Any]) -> list[str]: + return [url for url in trajectory_urls(trajectory) if is_target_url(url, trajectory)] + + +def visited_path(trajectory: dict[str, Any], path: str) -> bool: + expected = normalized_path(path) + return any(normalized_path(url) == expected for url in target_urls(trajectory)) + + +def visited_query(trajectory: dict[str, Any], path: str, expected: dict[str, str]) -> bool: + wanted = normalized_path(path) + return any( + normalized_path(url) == wanted and query_matches(url, expected) + for url in target_urls(trajectory) + ) + + +def _game_path_matches(path: str, bgg_id: int, subpage: str | None = None) -> bool: + parts = normalized_path(path).strip("/").split("/") + if len(parts) < 2 or parts[0] != "boardgame" or parts[1] != str(bgg_id): + return False + if subpage is None: + return len(parts) == 2 or ( + len(parts) == 3 and parts[2] not in KNOWN_GAME_SUBPAGES + ) + return (len(parts) == 3 and parts[2] == subpage) or ( + len(parts) == 4 and parts[3] == subpage + ) + + +def visited_game(trajectory: dict[str, Any], bgg_id: int, subpage: str | None = None) -> bool: + return any( + _game_path_matches(url, bgg_id, subpage) for url in target_urls(trajectory) + ) + + +def visited_entity(trajectory: dict[str, Any], prefix: str, bgg_id: int) -> bool: + for url in target_urls(trajectory): + parts = normalized_path(url).strip("/").split("/") + if len(parts) in {2, 3} and parts[:2] == [prefix, str(bgg_id)]: + return True + return False + + +def ordered(trajectory: dict[str, Any], predicates: Sequence[Callable[[str], bool]]) -> bool: + urls = target_urls(trajectory) + cursor = 0 + for predicate in predicates: + for index in range(cursor, len(urls)): + if predicate(urls[index]): + cursor = index + 1 + break + else: + return False + return True + + +def path_predicate(path: str, query: dict[str, str] | None = None) -> Callable[[str], bool]: + expected_path = normalized_path(path) + expected_query = query or {} + return lambda url: normalized_path(url) == expected_path and query_matches(url, expected_query) + + +def game_predicate(bgg_id: int, subpage: str | None = None) -> Callable[[str], bool]: + return lambda url: _game_path_matches(url, bgg_id, subpage) + + +def transition_pairs(trajectory: dict[str, Any]): + steps = trajectory.get("steps") or [] + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + current = str(step.get("url") or step.get("url_before") or "") + if not is_target_url(current, trajectory): + continue + following = str(step.get("url_after") or "") + if not following and index + 1 < len(steps) and isinstance(steps[index + 1], dict): + following = str( + steps[index + 1].get("url") + or steps[index + 1].get("url_before") + or "" + ) + if following and is_target_url(following, trajectory): + yield normalize_text(step.get("action")), current, following + + +def submitted_from(trajectory: dict[str, Any], predicate: Callable[[str], bool]) -> bool: + return any( + action in {"click", "press", "submit"} and predicate(current) + for action, current, _ in transition_pairs(trajectory) + ) + + +def input_values(trajectory: dict[str, Any], predicate: Callable[[str], bool] | None = None) -> list[str]: + values: list[str] = [] + for step in trajectory.get("steps") or []: + if not isinstance(step, dict): + continue + if normalize_text(step.get("action")) not in {"input", "fill", "type", "select"}: + continue + url = str(step.get("url") or step.get("url_before") or "") + if not is_target_url(url, trajectory) or (predicate and not predicate(url)): + continue + params = step.get("params") or {} + if not isinstance(params, dict): + continue + value = params.get("text", params.get("value", params.get("option", params.get("label")))) + if value is not None: + values.append(str(value)) + return values + + +def entered_exact(trajectory: dict[str, Any], value: str, predicate: Callable[[str], bool] | None = None) -> bool: + expected = normalize_text(value) + return any(normalize_text(item) == expected for item in input_values(trajectory, predicate)) + + +def entered_contains(trajectory: dict[str, Any], value: str, predicate: Callable[[str], bool] | None = None) -> bool: + expected = normalize_text(value) + return any(expected in normalize_text(item) for item in input_values(trajectory, predicate)) + + +def login_submitted_as(trajectory: dict[str, Any], username: str) -> bool: + predicate = path_predicate("/login") + return ( + visited_path(trajectory, "/login") + and entered_exact(trajectory, username, predicate) + and entered_exact(trajectory, PASSWORD, predicate) + and submitted_from(trajectory, predicate) + ) + + +NEGATIONS = {"not", "no", "never", "without", "isn't", "isnt", "wasn't", "wasnt", "didn't", "didnt"} + + +def _negated_at(text: str, start: int) -> bool: + clause = re.split(r"[.!?;:\n]+|\b(?:and|but|however|instead)\b", text[:start])[-1] + return any(word in NEGATIONS for word in re.findall(r"[a-z0-9]+(?:'[a-z]+)?", clause)) + + +def affirmative_contains(value: Any, expected: Any) -> bool: + text = normalize_text(value) + needle = normalize_text(expected) + matches = list(re.finditer(re.escape(needle), text)) if needle else [] + return bool(matches and not _negated_at(text, matches[-1].start())) + + +def contains_all(value: Any, expected: Iterable[Any]) -> bool: + return all(affirmative_contains(value, item) for item in expected) + + +def number_matches(value: Any, expected: float, tolerance: float = 0.005) -> bool: + text = normalize_text(value) + for match in re.finditer(r"(? bool: + text = normalize_text(value) + for match in re.finditer(r"(? tolerance or _negated_at(text, match.start()): + continue + window = text[max(0, match.start() - distance):match.end() + distance] + if any(normalize_text(label) in window for label in labels): + return True + return False + + +def claims_heavier(value: Any, winner: str, loser: str) -> bool: + text = normalize_text(value) + return ( + affirmative_contains(text, winner) + and affirmative_contains(text, loser) + and any(word in text for word in ("heavier", "higher", "more complex", "greater")) + ) + + +def db_rows(path: str, sql: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]: + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + +def row_dicts(path: str, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: + return [dict(row) for row in db_rows(path, sql, params)] + + +def table_rows(path: str, table: str) -> list[tuple[Any, ...]]: + return [tuple(row) for row in db_rows(path, f'SELECT * FROM "{table}" ORDER BY rowid')] + + +def table_names(path: str) -> list[str]: + return [ + str(row["name"]) + for row in db_rows( + path, + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + ] + + +def changed_tables(initial: str, after: str) -> set[str]: + if table_names(initial) != table_names(after): + return {""} + return { + table + for table in table_names(initial) + if table_rows(initial, table) != table_rows(after, table) + } + + +def rows_by_id(path: str, table: str) -> dict[int, tuple[Any, ...]]: + return {int(row[0]): tuple(row) for row in db_rows(path, f'SELECT * FROM "{table}" ORDER BY id')} + + +class Judge: + def __init__(self, task_number: int): + self.task_id = f"BoardGameGeek--{task_number}" + self.passed = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, condition: bool, evidence: str = "") -> bool: + self.evidence.append(f"[{'PASS' if condition else 'FAIL'}] {name}: {evidence}") + if not condition: + self.passed = False + if not self.reason: + self.reason = name + return bool(condition) + + def emit(self) -> None: + print( + json.dumps( + { + "task_id": self.task_id, + "pass": self.passed, + "reason": self.reason, + "evidence": self.evidence, + }, + ensure_ascii=False, + indent=2, + ) + ) + raise SystemExit(0 if self.passed else 1) + + +def _require_common(judge: Judge, task_number: int, trajectory: dict[str, Any], args: VerifyArgs) -> tuple[str, str] | None: + answer = final_answer(trajectory) + judge.check("task_id_matches", trajectory.get("task_id") == judge.task_id, repr(trajectory.get("task_id"))) + judge.check("final_answer_nonempty", bool(answer), repr(answer)) + judge.check("start_url_is_target", is_target_url(str(trajectory.get("start_url") or ""), trajectory), repr(trajectory.get("start_url"))) + readable = bool(args.initial_db and args.after_db) + judge.check("databases_readable", readable, f"initial={args.initial_db} after={args.after_db}") + return (args.initial_db, args.after_db) if readable else None + + +def _read_only(judge: Judge, initial: str, after: str) -> None: + changes = changed_tables(initial, after) + judge.check("read_only_database_unchanged", not changes, repr(changes)) + + +def _answer_numbers(judge: Judge, answer: str, facts: Sequence[tuple[float, Sequence[str], float]]) -> None: + for number, labels, tolerance in facts: + judge.check( + f"answer_{normalize_text(labels[0]).replace(' ', '_')}", + number_bound_to(answer, number, labels, tolerance), + repr(answer), + ) + + +def _verify_read_task(task: int, trajectory: dict[str, Any], answer: str, initial: str, judge: Judge) -> None: + if task == 0: + game = row_dicts(initial, "SELECT * FROM games WHERE overall_rank=1")[0] + designers = row_dicts(initial, "SELECT p.name FROM people p JOIN game_designers gd ON gd.person_id=p.id WHERE gd.game_id=?", (game["id"],)) + judge.check("browse_then_rank_one_detail", ordered(trajectory, [path_predicate("/browse/boardgame"), game_predicate(game["bgg_id"])]), game["name"]) + judge.check("answer_game_and_all_designers", contains_all(answer, [game["name"], *[row["name"] for row in designers]]), repr(answer)) + elif task == 1: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Gloomhaven'")[0] + judge.check("search_then_game_detail", ordered(trajectory, [path_predicate("/search", {"q": "Gloomhaven"}), game_predicate(game["bgg_id"])]), game["name"]) + _answer_numbers(judge, answer, [(game["avg_rating"], ("average", "rating"), 0.005), (game["weight"], ("weight", "complexity"), 0.005), (game["num_ratings"], ("voters", "ratings"), 0.0)]) + elif task == 2: + game = row_dicts(initial, "SELECT * FROM (SELECT * FROM games WHERE subtype='boardgame' ORDER BY weight DESC LIMIT 100) WHERE overall_rank BETWEEN 1 AND 100 ORDER BY weight DESC LIMIT 1")[0] + judge.check("weight_sorted_first_page", visited_query(trajectory, "/browse/boardgame", {"sort": "weight", "dir": "desc"}), "sort=weight&dir=desc") + judge.check("answer_game_and_weight", affirmative_contains(answer, game["name"]) and number_bound_to(answer, game["weight"], ("weight", "complexity"), 0.005), repr(answer)) + elif task == 3: + game = row_dicts(initial, "SELECT g.* FROM games g JOIN game_mechanics gm ON gm.game_id=g.id JOIN mechanics m ON m.id=gm.mechanic_id WHERE m.name='Worker Placement' AND g.overall_rank>0 ORDER BY g.overall_rank LIMIT 1")[0] + designers = row_dicts(initial, "SELECT p.name FROM people p JOIN game_designers gd ON gd.person_id=p.id WHERE gd.game_id=?", (game["id"],)) + judge.check("mechanism_then_game_detail", ordered(trajectory, [path_predicate("/boardgamemechanic"), lambda url: normalized_path(url).startswith("/boardgamemechanic/2082"), game_predicate(game["bgg_id"])]), game["name"]) + judge.check("answer_game_and_designers", contains_all(answer, [game["name"], *[row["name"] for row in designers]]), repr(answer)) + elif task == 4: + count = db_rows(initial, "SELECT COUNT(*) n FROM collections c JOIN users u ON u.id=c.user_id WHERE u.username='alice_j' AND c.own=1")[0]["n"] + judge.check("login_then_owned_collection", login_submitted_as(trajectory, "alice_j") and ordered(trajectory, [path_predicate("/login"), path_predicate("/collection/alice_j")]), "alice_j") + judge.check("answer_owned_count", number_bound_to(answer, count, ("owned", "collection", "games"), 0.0), repr(answer)) + elif task == 7: + latest_year = db_rows(initial, "SELECT MAX(year_published) year FROM games WHERE featured=1")[0]["year"] + game = row_dicts(initial, "SELECT * FROM games WHERE featured=1 AND year_published=? AND overall_rank>0 ORDER BY overall_rank LIMIT 1", (latest_year,))[0] + judge.check("hot_page_visited", visited_path(trajectory, "/hotness") or visited_path(trajectory, "/hot"), "hotness alias accepted") + judge.check("answer_latest_year_and_highest_ranked_game", affirmative_contains(answer, game["name"]) and number_matches(answer, latest_year, 0.0), repr(answer)) + elif task == 8: + data = row_dicts( + initial, + """ + SELECT l.id list_id,l.title,i.position,g.name,g.bgg_id,g.weight + FROM geeklists l + JOIN geeklist_items i ON i.list_id=l.id + JOIN games g ON g.id=i.game_id + WHERE l.title='Best Cooperative Games' AND i.position=7 + """, + )[0] + judge.check( + "target_list_then_seventh_game_detail", + ordered( + trajectory, + [path_predicate(f"/geeklist/{data['list_id']}"), game_predicate(data["bgg_id"])], + ), + f"{data['title']} #7 = {data['name']}", + ) + judge.check( + "answer_seventh_game_and_weight", + affirmative_contains(answer, data["name"]) + and number_bound_to(answer, data["weight"], ("weight", "complexity"), 0.005), + repr(answer), + ) + elif task == 10: + games = {row["name"]: row for row in row_dicts(initial, "SELECT name,bgg_id,weight FROM games WHERE name IN ('Brass: Birmingham','Ark Nova')")} + judge.check("both_game_pages_visited", visited_game(trajectory, games["Brass: Birmingham"]["bgg_id"]) and visited_game(trajectory, games["Ark Nova"]["bgg_id"]), "both details") + judge.check("answer_identifies_heavier_game", claims_heavier(answer, "Brass: Birmingham", "Ark Nova"), repr(answer)) + elif task == 11: + publisher = row_dicts(initial, "SELECT * FROM publishers WHERE name='Z-Man Games'")[0] + count = db_rows(initial, "SELECT COUNT(*) n FROM game_publishers WHERE publisher_id=?", (publisher["id"],))[0]["n"] + judge.check("publisher_index_then_detail", ordered(trajectory, [path_predicate("/boardgamepublisher"), lambda url: normalized_path(url).startswith(f"/boardgamepublisher/{publisher['bgg_id']}")]), publisher["name"]) + judge.check("answer_publisher_count", number_bound_to(answer, count, ("games", "listed", "catalog"), 0.0), repr(answer)) + elif task == 12: + game = row_dicts(initial, "SELECT g.* FROM games g JOIN game_mechanics gm ON gm.game_id=g.id JOIN mechanics m ON m.id=gm.mechanic_id WHERE m.name='Action Points' AND g.overall_rank>0 ORDER BY g.overall_rank LIMIT 1")[0] + judge.check("mechanisms_then_action_points", ordered(trajectory, [path_predicate("/boardgamemechanic"), lambda url: normalized_path(url).startswith("/boardgamemechanic/2001")]), "Action Points") + judge.check("answer_game_and_rank", affirmative_contains(answer, game["name"]) and number_bound_to(answer, game["overall_rank"], ("rank", "#"), 0.0), repr(answer)) + elif task == 14: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Wingspan'")[0] + top = row_dicts(initial, "SELECT u.username,r.num_thumbs FROM ratings r JOIN users u ON u.id=r.user_id WHERE r.game_id=? ORDER BY r.num_thumbs DESC,r.value DESC LIMIT 1", (game["id"],))[0] + judge.check("detail_then_helpful_ratings", ordered(trajectory, [game_predicate(game["bgg_id"]), lambda url: _game_path_matches(url, game["bgg_id"], "ratings") and query_matches(url, {"sort": "thumbs"})]), game["name"]) + judge.check("answer_user_and_thumbs", affirmative_contains(answer, top["username"]) and number_bound_to(answer, top["num_thumbs"], ("thumb", "helpful"), 0.0), repr(answer)) + elif task == 16: + data = row_dicts(initial, "SELECT l.id,l.title,g.name FROM geeklists l JOIN geeklist_items i ON i.list_id=l.id JOIN games g ON g.id=i.game_id WHERE l.title='Top 50 Heaviest Games of the Last Decade' AND i.position=1")[0] + judge.check("geeklists_then_target_list", ordered(trajectory, [path_predicate("/geeklists"), path_predicate(f"/geeklist/{data['id']}")]), data["title"]) + judge.check("answer_first_entry", affirmative_contains(answer, data["name"]), repr(answer)) + elif task == 17: + count = db_rows(initial, "SELECT COUNT(DISTINCT p.game_id) n FROM plays p JOIN users u ON u.id=p.user_id WHERE u.username='david_k'")[0]["n"] + judge.check("login_then_plays", login_submitted_as(trajectory, "david_k") and ordered(trajectory, [path_predicate("/login"), path_predicate("/plays/david_k")]), "david_k") + judge.check("answer_distinct_game_count", number_bound_to(answer, count, ("distinct", "games"), 0.0), repr(answer)) + elif task == 18: + designer = row_dicts(initial, "SELECT * FROM people WHERE name='Vital Lacerda'")[0] + game = row_dicts(initial, "SELECT g.* FROM games g JOIN game_designers gd ON gd.game_id=g.id WHERE gd.person_id=? ORDER BY g.avg_rating DESC LIMIT 1", (designer["id"],))[0] + judge.check("designer_index_then_detail", ordered(trajectory, [path_predicate("/boardgamedesigner"), lambda url: normalized_path(url).startswith(f"/boardgamedesigner/{designer['bgg_id']}")]), designer["name"]) + judge.check("answer_highest_average_game", affirmative_contains(answer, game["name"]), repr(answer)) + elif task == 19: + user = row_dicts(initial, "SELECT * FROM users WHERE lower(username) LIKE '%mike%' OR lower(real_name) LIKE '%mike%' ORDER BY username ASC LIMIT 1")[0] + count = db_rows(initial, "SELECT COUNT(*) n FROM geeklists WHERE author_id=?", (user["id"],))[0]["n"] + judge.check("user_search_then_first_profile", ordered(trajectory, [lambda url: normalized_path(url) == "/search" and query_matches(url, {"q": "mike", "type": "user"}), path_predicate(f"/user/{user['username']}")]), user["username"]) + judge.check("answer_username_and_geeklist_count", affirmative_contains(answer, user["username"]) and number_bound_to(answer, count, ("geeklists", "authored"), 0.0), repr(answer)) + elif task == 20: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Twilight Struggle'")[0] + count = db_rows(initial, "SELECT COUNT(*) n FROM game_links WHERE game_id=? AND kind='expansion'", (game["id"],))[0]["n"] + judge.check("detail_then_expansions", ordered(trajectory, [game_predicate(game["bgg_id"]), game_predicate(game["bgg_id"], "expansions")]), game["name"]) + judge.check("answer_expansion_count", number_bound_to(answer, count, ("expansions", "listed"), 0.0), repr(answer)) + else: + raise ValueError(f"unsupported read task {task}") + + +def _verify_task_5(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + game = row_dicts(initial, "SELECT * FROM games WHERE name='Brass: Birmingham'")[0] + user = row_dicts(initial, "SELECT * FROM users WHERE username='bob_c'")[0] + game_path = game_predicate(game["bgg_id"]) + judge.check("login_and_rating_flow", login_submitted_as(trajectory, "bob_c") and ordered(trajectory, [path_predicate("/login"), game_path]) and entered_exact(trajectory, "9.5", game_path) and submitted_from(trajectory, game_path), "bob_c -> Brass -> submit") + before = row_dicts(initial, "SELECT * FROM ratings WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + now = row_dicts(after, "SELECT * FROM ratings WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + judge.check("rating_was_new", before == [], repr(before)) + exact = len(now) == 1 and now[0]["value"] == 9.5 and len(plain_html(now[0]["review_html"])) >= 8 + sentence_marks = re.findall(r"[.!?]+(?:\s|$)", plain_html(now[0]["review_html"])) if now else [] + judge.check("one_sentence_rating_saved", exact and len(sentence_marks) <= 1, repr(now)) + after_game = row_dicts(after, "SELECT * FROM games WHERE id=?", (game["id"],))[0] + expected = (game["avg_rating"] * game["num_ratings"] + 9.5) / (game["num_ratings"] + 1) + judge.check("aggregate_updated_once", after_game["num_ratings"] == game["num_ratings"] + 1 and abs(after_game["avg_rating"] - expected) < 1e-12, f"expected={expected} after={after_game['avg_rating']}") + judge.check("only_rating_and_game_changed", changed_tables(initial, after) == {"games", "ratings"}, repr(changed_tables(initial, after))) + + +def _verify_task_6(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + game = row_dicts(initial, "SELECT DISTINCT g.* FROM games g JOIN game_mechanics gm ON gm.game_id=g.id JOIN mechanics m ON m.id=gm.mechanic_id WHERE g.subtype='boardgame' AND g.minplayers=2 AND g.maxplayers=2 AND g.overall_rank>0 AND m.name IN ('Deck Construction','Deck, Bag, and Pool Building') ORDER BY g.overall_rank LIMIT 1")[0] + user = row_dicts(initial, "SELECT * FROM users WHERE username='carol_d'")[0] + judge.check("login_and_both_mechanism_pages", login_submitted_as(trajectory, "carol_d") and visited_entity(trajectory, "boardgamemechanic", 3004) and visited_entity(trajectory, "boardgamemechanic", 2664), "both mechanism categories") + game_path = game_predicate(game["bgg_id"]) + judge.check("target_game_opened_and_submitted", visited_game(trajectory, game["bgg_id"]) and submitted_from(trajectory, game_path), game["name"]) + before = row_dicts(initial, "SELECT * FROM collections WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + now = row_dicts(after, "SELECT * FROM collections WHERE user_id=? AND game_id=?", (user["id"], game["id"])) + exact = len(now) == 1 and now[0]["wishlist"] == 1 and now[0]["wishlist_priority"] == 1 + judge.check("must_have_wishlist_added", before == [] and exact, repr(now)) + judge.check("only_collections_changed", changed_tables(initial, after) == {"collections"}, repr(changed_tables(initial, after))) + + +def _verify_task_9(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + user = row_dicts(initial, "SELECT * FROM users WHERE username='david_k'")[0] + title = "My COIN Series Picks" + description = "Light, deep, and historical." + form = path_predicate("/geeklist/new") + judge.check("login_and_new_list_form", login_submitted_as(trajectory, "david_k") and ordered(trajectory, [path_predicate("/login"), form]) and entered_exact(trajectory, title, form) and entered_exact(trajectory, description, form) and submitted_from(trajectory, form), "exact title and description") + before_ids = {row["id"] for row in row_dicts(initial, "SELECT * FROM geeklists")} + after_rows = row_dicts(after, "SELECT * FROM geeklists ORDER BY id") + created = [row for row in after_rows if row["id"] not in before_ids] + exact = len(created) == 1 and created[0]["author_id"] == user["id"] and created[0]["title"] == title and plain_html(created[0]["description_html"]) == normalize_text(description) and created[0]["num_items"] == 0 + judge.check("one_exact_geeklist_created", exact, repr(created)) + judge.check("created_list_opened", len(created) == 1 and visited_path(trajectory, f"/geeklist/{created[0]['id']}"), repr(created)) + judge.check("only_geeklists_changed", changed_tables(initial, after) == {"geeklists"}, repr(changed_tables(initial, after))) + + +def _verify_task_13(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + user = row_dicts(initial, "SELECT * FROM users WHERE username='alice_j'")[0] + entry = row_dicts(initial, "SELECT c.*,g.bgg_id,g.name FROM collections c JOIN games g ON g.id=c.game_id WHERE c.user_id=? AND c.own=1 ORDER BY c.updated_at DESC LIMIT 1", (user["id"],))[0] + collection_seen = visited_query(trajectory, "/collection/alice_j", {"sort": "recent"}) + game_path = game_predicate(entry["bgg_id"]) + judge.check("login_recent_collection_remove_flow", login_submitted_as(trajectory, "alice_j") and collection_seen and ordered(trajectory, [path_predicate("/login"), lambda url: normalized_path(url) == "/collection/alice_j" and query_matches(url, {"sort": "recent"}), game_path]) and submitted_from(trajectory, game_path), entry["name"]) + before = rows_by_id(initial, "collections") + now = rows_by_id(after, "collections") + judge.check("only_most_recent_entry_removed", entry["id"] in before and entry["id"] not in now and len(now) == len(before) - 1 and all(row == now.get(row_id) for row_id, row in before.items() if row_id != entry["id"]), entry["name"]) + judge.check("only_collections_changed", changed_tables(initial, after) == {"collections"}, repr(changed_tables(initial, after))) + + +def _verify_task_15(trajectory: dict[str, Any], answer: str, initial: str, after: str, judge: Judge) -> None: + user = row_dicts(initial, "SELECT * FROM users WHERE username='bob_c'")[0] + eligible = row_dicts(initial, "SELECT t.*,f.id forum_id FROM threads t JOIN forums f ON f.id=t.forum_id WHERE f.title='Recommendations' AND t.is_pinned=0 AND t.is_locked=0 AND (lower(t.subject) LIKE '%2 player%' OR lower(t.subject) LIKE '%two-player%')") + eligible_ids = {row["id"] for row in eligible} + visited_ids = {row["id"] for row in eligible if visited_path(trajectory, f"/thread/{row['id']}")} + chosen = next(iter(visited_ids), None) + predicate = path_predicate(f"/thread/{chosen}") if chosen else lambda _url: False + judge.check("login_forum_open_thread_flow", login_submitted_as(trajectory, "bob_c") and visited_path(trajectory, "/forums") and visited_path(trajectory, "/forum/3") and bool(visited_ids), repr(eligible_ids)) + judge.check("reply_phrase_entered_and_submitted", chosen is not None and entered_contains(trajectory, "7 Wonders Duel", predicate) and submitted_from(trajectory, predicate), repr(chosen)) + before_ids = {row["id"] for row in row_dicts(initial, "SELECT * FROM posts")} + new_posts = [row for row in row_dicts(after, "SELECT * FROM posts") if row["id"] not in before_ids] + exact = len(new_posts) == 1 and new_posts[0]["thread_id"] in eligible_ids and new_posts[0]["author_id"] == user["id"] and "7 wonders duel" in plain_html(new_posts[0]["body_html"]) + judge.check("one_exact_reply_saved", exact, repr(new_posts)) + if exact: + tid = new_posts[0]["thread_id"] + before_thread = row_dicts(initial, "SELECT * FROM threads WHERE id=?", (tid,))[0] + after_thread = row_dicts(after, "SELECT * FROM threads WHERE id=?", (tid,))[0] + before_forum = row_dicts(initial, "SELECT * FROM forums WHERE id=?", (before_thread["forum_id"],))[0] + after_forum = row_dicts(after, "SELECT * FROM forums WHERE id=?", (before_thread["forum_id"],))[0] + judge.check("thread_and_forum_counts_incremented", after_thread["num_posts"] == before_thread["num_posts"] + 1 and after_forum["num_posts"] == before_forum["num_posts"] + 1, f"thread={before_thread['num_posts']}->{after_thread['num_posts']} forum={before_forum['num_posts']}->{after_forum['num_posts']}") + judge.check("only_forum_reply_tables_changed", changed_tables(initial, after) == {"forums", "posts", "threads"}, repr(changed_tables(initial, after))) + + +def run_task(task_number: int) -> None: + args = parse_args() + trajectory = load_run(args.run_dir) + answer = final_answer(trajectory) + judge = Judge(task_number) + databases = _require_common(judge, task_number, trajectory, args) + if databases: + initial, after = databases + if task_number == 5: + _verify_task_5(trajectory, answer, initial, after, judge) + elif task_number == 6: + _verify_task_6(trajectory, answer, initial, after, judge) + elif task_number == 9: + _verify_task_9(trajectory, answer, initial, after, judge) + elif task_number == 13: + _verify_task_13(trajectory, answer, initial, after, judge) + elif task_number == 15: + _verify_task_15(trajectory, answer, initial, after, judge) + else: + _verify_read_task(task_number, trajectory, answer, initial, judge) + _read_only(judge, initial, after) + judge.emit() diff --git a/websyn_start.sh b/websyn_start.sh index 9539b4f9..ea74ecc6 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,7 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers) + cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers boardgamegeek) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR"