← backtrust.py
1"""Keeping the public leaderboards honest enough: run tickets, score caps, names.
2 
3The game runs in the player's browser, so a determined cheater can always forge
4a score. These checks stop the casual kinds:
5 
6- A score must come with a run ticket the server issued when the run started,
7 for that exact tour and rule set. Each ticket counts once.
8- The run must have taken a believable amount of time for the tour's length.
9- Every number must fit what the tour and its rules allow.
10- Submissions per visitor are rate-limited.
11- Player names are length-limited and run through a word filter.
12"""
13 
14import re
15import secrets
16import time
17 
18RUN_TTL = 3 * 86400
19MIN_SECONDS_PER_STOP = 8
20RATE_MAX = 30
21RATE_WINDOW = 3600
22 
23# Words that keep a name off a public board. Matched after undoing common
24# letter swaps (0->o, 1->i, 3->e, 4->a, 5->s, 7->t, @->a, $->s) and repeated
25# letters. Long, unambiguous words are caught anywhere in the name ("f u c k",
26# "xXfvckXx"); short ones only as a whole word, so "Hancock", "grape" and
27# "Cumberland" stay allowed. ("Dick" stays allowed too: Dick Latvala, Dick's Picks.)
28_BLOCKED_ANYWHERE = [
29 "fuck", "fvck", "shit", "cunt", "bitch", "pussy", "asshole", "whore", "nigger", "nigga", "faggot",
30 "retard", "tranny", "hitler", "porn", "jizz", "wetback",
31]
32_BLOCKED_WORDS = [
33 "fuk", "cock", "slut", "fag", "kike", "spic", "chink", "gook", "rape", "rapist", "nazi", "kkk",
34 "penis", "vagina", "cum", "twat", "dyke",
35]
36_SWAPS = str.maketrans({"0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a", "$": "s", "!": "i"})
37 
38 
39def _squash(text: str) -> str:
40 return re.sub(r"(.)\1+", r"\1", text)
41 
42 
43def clean_name(raw: str) -> tuple[str | None, str | None]:
44 """Return (name, None) if usable, or (None, reason) if not."""
45 swapped = str(raw or "").lower().translate(_SWAPS)
46 name = re.sub(r"\s+", " ", str(raw or "")).strip()
47 name = re.sub(r"[^A-Za-z0-9 _.'\-]", "", name)[:24].strip()
48 if len(name) < 2:
49 return None, "Pick a name with at least 2 letters."
50 letters = _squash(re.sub(r"[^a-z]", "", swapped))
51 words = {_squash(w) for w in re.split(r"[^a-z]+", swapped) if w}
52 bad = any(_squash(w) in letters for w in _BLOCKED_ANYWHERE) or any(_squash(w) in words for w in _BLOCKED_WORDS)
53 if bad:
54 return None, "That name can't go on a public board. Pick another."
55 return name, None
56 
57 
58def start_run(store, tag: str, dna_code: str, stops: int, years: int, luck: int | None) -> dict:
59 run_id = secrets.token_urlsafe(12)
60 seed = luck if isinstance(luck, int) and 0 < luck < 2**31 else secrets.randbelow(2**31 - 1) + 1
61 rec = {"tag": tag, "dna": dna_code, "stops": stops, "years": years, "seed": seed, "t0": time.time()}
62 store.set("run:" + run_id, rec, ttl=RUN_TTL)
63 return {"run": run_id, "seed": seed}
64 
65 
66def score_cap(rec: dict, stops: int, years: int, legend_pts: int) -> int:
67 """The most a run with these numbers could honestly score, with headroom."""
68 return int(
69 rec.get("tapes", 0) * 8
70 + rec.get("shows", 0) * 15
71 + rec.get("legendary", 0) * legend_pts
72 + 200 # vibes + cred, each capped at 100
73 + 2400 # cash / 5, for up to $12,000 banked
74 + rec.get("miles", 0) / 40
75 + 50
76 )
77 
78 
79def check_run(store, run_id: str, rec: dict, legend_pts: int, ip: str) -> tuple[dict | None, str | None]:
80 """Validate a submission against its run ticket. Returns (ticket, None) or (None, reason)."""
81 if ip and store.incr("rl:" + ip, ttl=RATE_WINDOW) > RATE_MAX:
82 return None, "Too many scores from here in the last hour. Try again later."
83 ticket = store.get("run:" + str(run_id)) if run_id else None
84 if not ticket:
85 return None, "That run is already on the board, or it wasn't started from a shared tour."
86 stops, years = ticket["stops"], ticket["years"]
87 if time.time() - ticket["t0"] < MIN_SECONDS_PER_STOP * stops * years:
88 return None, "That run finished faster than the road allows."
89 limits = {
90 "shows": stops * years, "legendary": years, "tapes": 40 * stops * years,
91 "miles": 12000 * years, "day": 400 * years,
92 }
93 for k, cap in limits.items():
94 v = rec.get(k, 0)
95 if not isinstance(v, int) or v < 0 or v > cap:
96 return None, "Those numbers don't fit this tour."
97 if not isinstance(rec.get("score"), int) or not 0 <= rec["score"] <= score_cap(rec, stops, years, legend_pts):
98 return None, "That score doesn't fit this tour."
99 store.delete("run:" + str(run_id)) # each ticket counts once
100 return ticket, None
101