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:
1 parent
bc10216d0b
commit
9133c0d351
12 files changed
+507
-19
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user