1"""Game DNA: the rule sliders a player can change before a run, and their short codes.
2
3A rule set is encoded into a short, reversible code so it can ride inside a
4hashtag and a share link (``#TTGD77AprK3F9Q``) and be decoded back into exact
5slider positions by whoever clicks it. Named presets use their name as the code.
6The game as built is ``CLASSIC`` and adds nothing to the tag.
7
8``RULES`` is the single source of truth: the browser reads it from
9``/api/dna/rules`` and the game reads the decoded values from the play page.
10"""
11
12import string
13from typing import Any
14
15# id, label, unit, min, max, step, default, hint, is_new
16RULES: list[dict[str, Any]] = [
17 {
18 "id": "code", "name": "Taper's code", "unit": " Cred per tape sold", "min": 0, "max": 10, "step": 1, "def": 0,
19 "isNew": True,
20 "hint": "Old-school tapers traded, never sold. Lot Cred you lose for each tape you sell. The game as built has none.",
21 },
22 {"id": "gas", "name": "Gas guzzling", "unit": "x", "min": 0.5, "max": 2, "step": 0.25, "def": 1,
23 "hint": "How fast the van burns gas between shows."},
24 {"id": "chaos", "name": "Road chaos", "unit": "x", "min": 0, "max": 2, "step": 0.25, "def": 1,
25 "hint": "How many surprises hit you on each drive: cops, flat tires, strangers, stews."},
26 {"id": "wear", "name": "Van wear", "unit": "x", "min": 0.5, "max": 2, "step": 0.25, "def": 1,
27 "hint": "How hard the road is on the van."},
28 {"id": "hustle", "name": "Lot economy", "unit": "x", "min": 0.5, "max": 2, "step": 0.25, "def": 1,
29 "hint": "How much cash the lot pays for grilled cheese, posters and tapes."},
30 {"id": "cash", "name": "Starting cash", "unit": "x", "min": 0.5, "max": 2, "step": 0.25, "def": 1,
31 "hint": "Money in your pocket when the tour starts."},
32 {"id": "legend", "name": "Legendary tape value", "unit": " pts", "min": 0, "max": 200, "step": 20, "def": 60,
33 "hint": "Score for taping the tour's legendary night."},
34 {"id": "loyal", "name": "Crew loyalty", "unit": "x", "min": 0.5, "max": 2, "step": 0.25, "def": 1,
35 "hint": "Higher means van-mates are less likely to bail when vibes drop."},
36]
37
38PRESETS: dict[str, dict[str, Any]] = {
39 "CLASSIC": {"label": "Classic", "values": {}},
40 "HardRoad": {"label": "Hard Road", "values": {"gas": 1.75, "chaos": 1.75, "wear": 1.75, "cash": 0.75, "loyal": 0.75}},
41 "TapersCode": {"label": "Taper's Code", "values": {"code": 8, "hustle": 0.75, "legend": 160}},
42 "EasyRider": {"label": "Easy Rider", "values": {"gas": 0.5, "chaos": 0.5, "wear": 0.5, "cash": 1.5, "loyal": 1.5}},
43}
44
45_ALPHA = string.digits + string.ascii_uppercase # base 36
46
47
48def _positions(rule: dict) -> int:
49 return int(round((rule["max"] - rule["min"]) / rule["step"])) + 1
50
51
52def defaults() -> dict:
53 return {r["id"]: r["def"] for r in RULES}
54
55
56def _snap(rule: dict, value) -> float:
57 try:
58 v = float(value)
59 except (TypeError, ValueError):
60 return rule["def"]
61 v = min(max(v, rule["min"]), rule["max"])
62 idx = round((v - rule["min"]) / rule["step"])
63 out = rule["min"] + idx * rule["step"]
64 return int(out) if float(out).is_integer() else round(out, 4)
65
66
67def normalize(values: dict) -> dict:
68 """Clamp and snap every rule to a legal slider position; fill defaults."""
69 return {r["id"]: _snap(r, values.get(r["id"], r["def"])) for r in RULES}
70
71
72def encode(values: dict) -> str:
73 """Rule values -> a code: a preset name, or a short base-36 string of slider positions."""
74 vals = normalize(values)
75 for name, p in PRESETS.items():
76 if vals == normalize(p["values"]):
77 return name
78 n = 0
79 for r in RULES:
80 n = n * _positions(r) + round((vals[r["id"]] - r["min"]) / r["step"])
81 s = ""
82 while n:
83 n, d = divmod(n, 36)
84 s = _ALPHA[d] + s
85 # A leading "X" marks a custom code so it can never collide with a preset name.
86 return "X" + (s or "0")
87
88
89def decode(code: str | None) -> dict:
90 """A code -> rule values. Unknown or garbled codes fall back to the game as built."""
91 if not code or code == "CLASSIC":
92 return defaults()
93 if code in PRESETS:
94 return normalize(PRESETS[code]["values"])
95 if not code.startswith("X"):
96 return defaults()
97 try:
98 n = int(code[1:], 36)
99 except ValueError:
100 return defaults()
101 idx = []
102 for r in reversed(RULES):
103 n, d = divmod(n, _positions(r))
104 idx.append(d)
105 if n:
106 return defaults()
107 idx.reverse()
108 return {r["id"]: _snap(r, r["min"] + i * r["step"]) for r, i in zip(RULES, idx)}
109
110
111def label(code: str) -> str:
112 if code in PRESETS:
113 return PRESETS[code]["label"]
114 return "Custom rules " + code
115