Data-driven parks: one heightAt(x,z) evaluated from element JSON drives the editor viewport, collision, test ride, and exported game levels. Ships with Paddo ported from bookquoy, a MODELBEAST panel (gen images, cut bg, image->3D, place farm GLBs as props), image decals + reference underlay tracing, a park design linter (docs/DESIGN_PRINCIPLES.md), THPS-style instant test ride, and a level.js codegen that drops straight into bookquoy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
158 lines
6.9 KiB
Python
158 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""SKATEMAKER PRO dev server.
|
|
|
|
Static files + three jobs the browser can't do alone:
|
|
/api/parks GET list | /api/parks/<name> GET load, POST save (JSON body)
|
|
/api/upload?name= POST raw bytes -> assets/uploads/<name>, returns {"path": ...}
|
|
/mb/... MODELBEAST proxy (queue on the m3ultra, token from
|
|
~/Documents/backnforth/.env — stays server-side, never in JS)
|
|
/mb/assets GET -> asset list
|
|
/mb/assets/<id>/file GET -> file bytes (same-origin for GLTFLoader)
|
|
/mb/jobs POST -> submit {operator, asset_id?, params}
|
|
/mb/jobs/<id> GET -> poll (control-char-safe)
|
|
/mb/import?asset=&name= GET -> download MB asset into assets/uploads/, return path
|
|
|
|
Run: python3 serve.py [port] (default 8093)
|
|
"""
|
|
import json, os, re, sys, urllib.parse, urllib.request
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
|
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
PARKS = os.path.join(ROOT, 'parks')
|
|
UPLOADS = os.path.join(ROOT, 'assets', 'uploads')
|
|
MB_HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
|
|
SAFE = re.compile(r'^[\w.\- ]+$')
|
|
|
|
|
|
def mb_token():
|
|
t = os.environ.get('MB_TOKEN')
|
|
if t:
|
|
return t
|
|
try:
|
|
for line in open(os.path.expanduser('~/Documents/backnforth/.env')):
|
|
if line.startswith('MB_TOKEN='):
|
|
return line.split('=', 1)[1].strip()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def mb_req(path, data=None, ctype=None, timeout=180):
|
|
tok = mb_token()
|
|
if not tok:
|
|
raise RuntimeError('MB_TOKEN not found')
|
|
h = {'Authorization': 'Bearer ' + tok}
|
|
if ctype:
|
|
h['Content-Type'] = ctype
|
|
r = urllib.request.Request(MB_HOST + path, data=data, headers=h)
|
|
return urllib.request.urlopen(r, timeout=timeout).read()
|
|
|
|
|
|
def clean_json(b): # MB job logs carry raw control chars
|
|
s = b.decode('utf-8', 'replace')
|
|
return ''.join(c if c >= ' ' or c in '\t\n' else ' ' for c in s).encode()
|
|
|
|
|
|
class H(SimpleHTTPRequestHandler):
|
|
def _send(self, code, body, ctype='application/json'):
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', ctype)
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _json(self, obj, code=200):
|
|
self._send(code, json.dumps(obj).encode())
|
|
|
|
def _err(self, msg, code=500):
|
|
self._json({'error': str(msg)}, code)
|
|
|
|
def do_GET(self):
|
|
path, _, query = self.path.partition('?')
|
|
q = dict(p.split('=', 1) for p in query.split('&') if '=' in p)
|
|
try:
|
|
if path == '/api/parks':
|
|
names = sorted(f[:-5] for f in os.listdir(PARKS) if f.endswith('.json'))
|
|
return self._json(names)
|
|
if path.startswith('/api/parks/'):
|
|
name = urllib.parse.unquote(path.split('/', 3)[3])
|
|
if not SAFE.match(name):
|
|
return self._err('bad name', 400)
|
|
f = os.path.join(PARKS, name + '.json')
|
|
if not os.path.exists(f):
|
|
return self._err('not found', 404)
|
|
return self._send(200, open(f, 'rb').read())
|
|
if path == '/mb/assets':
|
|
return self._send(200, clean_json(mb_req('/api/assets?limit=100')))
|
|
m = re.match(r'^/mb/assets/([\w-]+)/file$', path)
|
|
if m:
|
|
return self._send(200, mb_req(f'/api/assets/{m.group(1)}/file'),
|
|
'application/octet-stream')
|
|
m = re.match(r'^/mb/jobs/([\w-]+)$', path)
|
|
if m:
|
|
return self._send(200, clean_json(mb_req(f'/api/jobs/{m.group(1)}')))
|
|
if path == '/mb/import':
|
|
aid, name = q.get('asset', ''), urllib.parse.unquote(q.get('name', 'asset.bin'))
|
|
if not re.match(r'^[\w-]+$', aid) or not SAFE.match(name):
|
|
return self._err('bad args', 400)
|
|
os.makedirs(UPLOADS, exist_ok=True)
|
|
data = mb_req(f'/api/assets/{aid}/file')
|
|
out = os.path.join(UPLOADS, name)
|
|
open(out, 'wb').write(data)
|
|
return self._json({'path': 'assets/uploads/' + name, 'bytes': len(data)})
|
|
except Exception as e:
|
|
return self._err(e)
|
|
return super().do_GET()
|
|
|
|
def do_POST(self):
|
|
path, _, query = self.path.partition('?')
|
|
q = dict(p.split('=', 1) for p in query.split('&') if '=' in p)
|
|
n = int(self.headers.get('Content-Length') or 0)
|
|
body = self.rfile.read(n) if n else b''
|
|
try:
|
|
if path.startswith('/api/parks/'):
|
|
name = urllib.parse.unquote(path.split('/', 3)[3])
|
|
if not SAFE.match(name):
|
|
return self._err('bad name', 400)
|
|
json.loads(body) # must be valid JSON
|
|
os.makedirs(PARKS, exist_ok=True)
|
|
open(os.path.join(PARKS, name + '.json'), 'wb').write(body)
|
|
return self._json({'saved': name})
|
|
if path == '/api/upload':
|
|
name = urllib.parse.unquote(q.get('name', ''))
|
|
if not SAFE.match(name):
|
|
return self._err('bad name', 400)
|
|
os.makedirs(UPLOADS, exist_ok=True)
|
|
open(os.path.join(UPLOADS, name), 'wb').write(body)
|
|
return self._json({'path': 'assets/uploads/' + name, 'bytes': len(body)})
|
|
if path == '/mb/jobs':
|
|
return self._send(200, clean_json(
|
|
mb_req('/api/jobs', data=body, ctype='application/json')))
|
|
if path == '/mb/upload': # raw bytes -> MB multipart asset
|
|
name = urllib.parse.unquote(q.get('name', 'upload.png'))
|
|
if not SAFE.match(name):
|
|
return self._err('bad name', 400)
|
|
bound = 'smpb0undary'
|
|
ctype = q.get('type', 'application/octet-stream')
|
|
mp = (f'--{bound}\r\nContent-Disposition: form-data; name="file"; '
|
|
f'filename="{name}"\r\nContent-Type: {ctype}\r\n\r\n').encode() \
|
|
+ body + f'\r\n--{bound}--\r\n'.encode()
|
|
return self._send(200, clean_json(mb_req(
|
|
'/api/assets', data=mp,
|
|
ctype=f'multipart/form-data; boundary={bound}')))
|
|
except Exception as e:
|
|
return self._err(e)
|
|
return self._err('unknown endpoint', 404)
|
|
|
|
def log_message(self, fmt, *a):
|
|
if '/mb/jobs/' not in str(a[0] if a else ''): # quiet the poll spam
|
|
super().log_message(fmt, *a)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8093
|
|
os.chdir(ROOT)
|
|
print(f'SKATEMAKER PRO on http://localhost:{port} (MB proxy -> {MB_HOST})')
|
|
HTTPServer(('0.0.0.0', port), H).serve_forever()
|