The leagues, phase 1: the profile clock ticks — xp, levels, the identity screen, the server mirror

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 12:22:33 +02:00
1 parent bc10216d0b
commit 9133c0d351
12 files changed
+507 -19

No files matched your search

+82 -1
View File
@@ -37,6 +37,55 @@ NAME_MAX = 12
MODES = ('g', 'z', 'c')
lock = threading.Lock()
# ---- THE PROFILE MIRROR (2026-08-29, the leagues). localStorage alone dies with cleared browser
# data, and the meta-progression asks for hours of investment — so the client mirrors its profile
# here, keyed by the same random id that owns board names. This is a BACKUP, not an account: the
# client is the authority while it has one, and adopts the mirror only when its own storage is
# empty (or behind on xp). Same stance as the scores: no auth beyond the id, nothing here may
# crash, hang or grow without bound, every field re-derived before it touches disk.
PROFILE_FILE = os.path.join(DATA, 'profiles.json')
PROFILE_KEEP = 5000 # profiles kept; oldest-touched evicted past this
plock = threading.Lock()
def pload():
try:
with open(PROFILE_FILE) as f:
d = json.load(f)
return d if isinstance(d, dict) else {}
except (OSError, ValueError):
return {}
def psave(d):
os.makedirs(DATA, exist_ok=True)
tmp = PROFILE_FILE + '.tmp'
with open(tmp, 'w') as f:
json.dump(d, f)
os.replace(tmp, PROFILE_FILE)
def clean_profile(payload):
"""(id, profile) or None. Whitelisted and bounded — this file is read back and rendered."""
if not isinstance(payload, dict) or not isinstance(payload.get('p'), dict):
return None
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
if not who:
return None
p = payload['p']
word = lambda k, n=12: re.sub(r'[^a-z0-9_]', '', str(p.get(k, '')))[:n]
try:
out = {'v': 1,
'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)),
'lg': max(1, min(int(p.get('lg', 1)), 5)),
'ch': 1 if p.get('ch') else 0,
'setup': 1 if p.get('setup') else 0,
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
except (TypeError, ValueError):
return None
return who, out
def mode_of(r):
return r.get('m') or 'g'
@@ -234,6 +283,14 @@ class Handler(SimpleHTTPRequestHandler):
with lock:
return self.reply(200, {'m': want,
'scores': public([r for r in load() if mode_of(r) == want])})
if path == '/api/profile':
who = re.sub(r'[^a-z0-9]', '', (self.path.split('id=')[1].split('&')[0]
if 'id=' in self.path else ''))[:40]
if not who:
return self.reply(400, {'error': 'no id'})
with plock:
row = pload().get(who)
return self.reply(200, {'ok': True, 'p': row and row.get('p')})
# Don't hand out the source. Nothing secret lives in it — the .env is not in the image and
# the client is public JS regardless — but serving server.py and the asset-pipeline scripts
# to anyone who guesses the name is untidy, and it publishes the exact validation rules.
@@ -242,7 +299,8 @@ class Handler(SimpleHTTPRequestHandler):
return super().do_GET()
def do_POST(self):
if self.path.split('?')[0] != '/api/scores':
path = self.path.split('?')[0]
if path not in ('/api/scores', '/api/profile'):
return self.send_error(404)
try:
n = int(self.headers.get('Content-Length') or 0)
@@ -250,6 +308,29 @@ class Handler(SimpleHTTPRequestHandler):
return self.reply(400, {'error': 'bad length'})
if not 0 < n <= MAX_BODY:
return self.reply(400, {'error': 'bad length'})
if path == '/api/profile':
try:
got = clean_profile(json.loads(self.rfile.read(n)))
except ValueError:
got = None
if not got:
return self.reply(400, {'error': 'bad profile'})
who, prof = got
with plock:
d = pload()
# the mirror never goes BACKWARD on xp: a second device with a stale localStorage
# boots, finds nothing local, adopts the mirror — but if it posts before adopting
# (or a wiped phone posts a fresh profile), a lower xp must not overwrite the
# investment the mirror exists to protect.
old = d.get(who)
if old and old.get('p', {}).get('xp', 0) > prof['xp']:
return self.reply(200, {'ok': True, 'p': old['p']})
d[who] = {'p': prof, 't': int(time.time())}
if len(d) > PROFILE_KEEP:
for k in sorted(d, key=lambda k: d[k].get('t', 0))[:len(d) - PROFILE_KEEP]:
del d[k]
psave(d)
return self.reply(200, {'ok': True, 'p': prof})
try:
row = clean(json.loads(self.rfile.read(n)))
except ValueError: