Files
Campus-25/server/ImportRealStore.py
T
2026-10-14 06:59:51 +02:00

277 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Build the Watch store (Data/APIS/GiftDropStores/3.json) and the first-party custom-item overlay
(Data/APIS/items/FirstPartyUGC.json) from a REAL Rec Room storefront capture.
Inputs (RefData/):
Watch_EnumValue_3.json - a live Watch storefront page (3326 rows, March 2026 capture, from
github.com/recflare/server apps/econ/static/db). The modern row shape:
AvatarItemInfo {Version, LegacyAvatarItemDesc, DownloadableAvatarItemId},
AvatarItemId, EquipmentItemId, ThumbnailImageName, CanBeGifted, AvailableAt.
2025-1-cai.json - the first-party custom avatar item export (2830 Coach items), already
priced from that storefront by the reference's price-custom-avatar-items.ts.
What it keeps (the 20250718 client can only show what it knows about):
* legacy avatar items whose descriptor is in Data/APIS/items/avatar_items.json (the client's
item db), spelled the way the catalog spells it (full UUIDs - short guids did not load);
* downloadable / custom avatar items whose record AND asset bundles we hold (CDN/AvatarBlobs);
* equipment skins released before the client build (AvailableAt <= 2025-07-18);
* consumables Data/APIS/items/Consumables.json or AllUnlockableConsumables.json knows;
* the random boxes (IsQuery) and the hair dyes (HairGrants unlocks colours off those rows).
Sales are stripped (the capture was mid 80%-off), RR+ prices are 10% off like everywhere here,
and AvatarItemInfo (the downloadable twin) is never kept - see the loop below for why.
Bundle hashes: the VPS serves the CDN bundles re-stamped for Unity 2021.3 (ConvertBundles2021.py +
vps_swap_bundles.py), whose sha256s are RefData/hashes-2021.json (a copy of the VPS's
conv2021/out/hashes.json). Every save written here - and UGC.json itself, once, with a backup -
carries the CONVERTED hash, so a PackageServer deploy no longer puts the Unity 6 hashes back.
Run from the repo root (D:\\Puck\\Campus2025Server-2025): python ImportRealStore.py
Then PackageServer.ps1 ships Data\\ (the server rebuilds the merged store on the next request).
"""
import json, os, sys, re, shutil, datetime, collections
ROOT = os.path.dirname(os.path.abspath(__file__))
SRV = os.path.join(ROOT, 'Server', '2025-RecRoom-Server-E12354-main')
DATA = os.path.join(SRV, 'Data')
ITEMS = os.path.join(DATA, 'APIS', 'items')
STORE = os.path.join(DATA, 'APIS', 'GiftDropStores', '3.json')
OVERLAY = os.path.join(ITEMS, 'FirstPartyUGC.json')
REF = os.path.join(ROOT, 'RefData')
CLIENT_BUILD = '2025-07-18' # the 20250718.01 client: nothing released after it renders
CUTOFF = '2025-07-19T23:59:59' # ConfigCampus.CustomItemCreatedCutoffUtc - later saves are unreadable
RRPLUS = 10 # StoreCatalog.RRPlusDiscountPercent
FLOOR_CLOTHING = 500 # every avatar item / custom item costs at least this
FLOOR_5STAR_TOP = 2500 # a rarity-50 top costs at least this
FLOOR_OTHER = 100 # skins / consumables / boxes never sell for 0
TOKENS = 2
def load(p):
with open(p, encoding='utf-8-sig') as f:
return json.load(f)
def save(p, obj):
tmp = p + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(obj, f, indent=2, ensure_ascii=False)
os.replace(tmp, p)
def norm_desc(d):
"""PlayerDB.NormalizeAvatarItemDesc: 4 fields, trailing empties dropped, lower."""
if not d: return ''
p = (d.split(',') + ['', '', '', ''])[:4]
while p and p[-1] == '': p.pop()
return ','.join(x.strip() for x in p).lower()
def short_to_uuid(tok):
"""Rec Room packed guid (22-char url-safe base64, .NET little-endian) -> uuid string."""
import base64, uuid
if not tok or len(tok) != 22: return tok
try:
b = base64.urlsafe_b64decode(tok + '==')
return str(uuid.UUID(bytes_le=b))
except Exception:
return tok
def uuid_desc(d):
return ','.join(short_to_uuid(t) for t in (d or '').split(','))
def before_build(iso):
return not iso or iso[:10] <= CLIENT_BUILD
def main():
watch = load(os.path.join(REF, 'Watch_EnumValue_3.json'))
export = load(os.path.join(REF, '2025-1-cai.json'))
catalog = load(os.path.join(ITEMS, 'avatar_items.json'))
ugc = load(os.path.join(ITEMS, 'UGC.json'))['Results']
equipments = load(os.path.join(ITEMS, 'Equipments.json'))
consumables = set()
for name in ('Consumables.json', 'AllUnlockableConsumables.json'):
p = os.path.join(ITEMS, name)
if os.path.exists(p):
d = load(p)
for c in (d if isinstance(d, list) else d.get('Results', [])):
x = c.get('ConsumableItemDesc') or ''
if x: consumables.add(x.lower()); consumables.add(short_to_uuid(x).lower())
bundles = set()
for r, _, fs in os.walk(os.path.join(DATA, 'CDN', 'AvatarBlobs')):
for f in fs: bundles.add(f.lower())
hashes = {}
hp = os.path.join(REF, 'hashes-2021.json')
if os.path.exists(hp):
hashes = {k.lower(): v for k, v in load(hp).items()}
else:
print('WARNING: RefData/hashes-2021.json missing - saves keep the Unity 6 hashes')
def rehash(rec):
n = 0
for s in rec.get('CurrentSaves') or []:
for k, hk in (('UnityAsset', 'UnityAssetHash'), ('UnityAsset2', 'UnityAsset2Hash')):
f = (s.get(k) or '').lower()
if f and f in hashes and s.get(hk) != hashes[f]:
s[hk] = hashes[f]; n += 1
return n
cat_by_desc = {}
for c in catalog:
k = norm_desc(c.get('AvatarItemDesc'))
if k and k not in cat_by_desc: cat_by_desc[k] = c
# the catalog spells every guid in full; the capture mixes short guids in, so match both ways
cat_by_udesc = {norm_desc(uuid_desc(c.get('AvatarItemDesc'))): c for c in catalog}
export_by_id = {e['CustomAvatarItemId'].lower(): e for e in export}
ugc_by_id = {u['CustomAvatarItemId'].lower(): u for u in ugc}
def saves_ok(rec):
saves = [s for s in (rec.get('CurrentSaves') or []) if (s.get('CreatedAt') or '') <= CUTOFF]
if not saves: return False
for s in saves:
for k in ('UnityAsset', 'UnityAsset2'):
if s.get(k) and s[k].lower() not in bundles: return False
return True
def cai_ok(guid):
g = (guid or '').lower()
rec = export_by_id.get(g) or ugc_by_id.get(g)
return rec is not None and (rec.get('CreatedAt') or '') <= CUTOFF and saves_ok(rec)
# ------------------------------------------------------------------ the Watch store
kept, dropped = [], collections.Counter()
floored = collections.Counter()
seen_ids = set()
top_rx = re.compile(r'shirt|jacket|dress|hoodie|sweater|coat|jersey|blouse|tee|top|tank|vest|cardigan|robe|tunic|poncho|pullover|blazer|suit', re.I)
def is_top(gd, name):
gid = (gd.get('CustomAvatarItemId') or (gd.get('AvatarItemInfo') or {}).get('DownloadableAvatarItemId') or '').lower()
rec = export_by_id.get(gid) or ugc_by_id.get(gid) if gid else None
if rec is not None and rec.get('OutfitType') in (101, 104): return True
if rec is not None and isinstance(rec.get('OutfitType'), int) and rec['OutfitType'] >= 0: return False
return bool(top_rx.search(name))
dye_rx = re.compile(r'hair\s*dye', re.I)
for it in watch['StoreItems']:
gd = it.get('GiftDrop') or {}
pid = it.get('PurchasableItemId')
if not pid or pid in seen_ids: dropped['dup/no id'] += 1; continue
name = gd.get('FriendlyName') or ''
why = None
if gd.get('CustomAvatarItemId'):
if not cai_ok(gd['CustomAvatarItemId']): why = 'custom item not held'
elif gd.get('EquipmentPrefabName'):
if not before_build(it.get('AvailableAt')): why = 'skin newer than client'
gd['AvatarItemType'] = None
elif gd.get('ConsumableItemDesc'):
d = gd['ConsumableItemDesc']
if d.lower() not in consumables and short_to_uuid(d).lower() not in consumables: why = 'consumable unknown'
gd['AvatarItemType'] = None
elif gd.get('IsQuery'):
gd['AvatarItemType'] = None
elif gd.get('AvatarItemDesc'):
desc = gd['AvatarItemDesc']
c = cat_by_desc.get(norm_desc(desc)) or cat_by_udesc.get(norm_desc(uuid_desc(desc)))
# The capture's AvatarItemInfo names a downloadable twin (a CAI assetbundle). It is
# NEVER kept: the twin makes the 20250718 client fetch the bundle instead of drawing
# the legacy item from its own db, and every such download fails in the client's
# bundle loader ("Can't load item"). The reference's 2025 store carries no
# AvatarItemInfo at all (sf3-2025.json is generated from the legacy catalog), so a
# row is kept only when the client can draw it from its built-in item db.
if c is not None:
gd['AvatarItemDesc'] = c['AvatarItemDesc'] # the catalog's spelling (full UUIDs)
if not gd.get('ThumbnailImageName') and c.get('ThumbnailImage'):
gd['ThumbnailImageName'] = c['ThumbnailImage']
elif dye_rx.search(name):
gd['AvatarItemDesc'] = uuid_desc(desc)
else:
why = 'avatar item not in catalog'
gd['AvatarItemInfo'] = None
else:
why = 'nothing to grant'
if why:
dropped[why] += 1; continue
prices = [p for p in (it.get('Prices') or []) if isinstance(p.get('Price'), int)]
if not prices: dropped['no price'] += 1; continue
for p in prices: p['StorefrontSaleData'] = None
# Price floors (owner's rule): nothing sells for 0; clothing is at least 500 tokens and a
# 5-star (rarity 50) top at least 2500. Hair dyes are exempt (the free ones are granted).
kind = ('custom' if gd.get('CustomAvatarItemId') else 'skin' if gd.get('EquipmentPrefabName')
else 'consumable' if gd.get('ConsumableItemDesc') else 'box' if gd.get('IsQuery') else 'avatar')
if not dye_rx.search(name):
floor = FLOOR_OTHER
if kind in ('avatar', 'custom'):
floor = FLOOR_CLOTHING
if gd.get('Rarity') == 50 and is_top(gd, name): floor = FLOOR_5STAR_TOP
for p in prices:
# non-clothing keeps its real price unless that price is 0
if p['CurrencyType'] == TOKENS and (p['Price'] < floor if kind in ('avatar', 'custom') else p['Price'] <= 0):
floored[kind] += 1; p['Price'] = floor
it['Prices'] = prices
it['SubscriberPrices'] = [{'CurrencyType': p['CurrencyType'], 'Price': p['Price'] * (100 - RRPLUS) // 100, 'StorefrontSaleData': None} for p in prices]
it['AvailableUntil'] = None
it['CanBeGifted'] = any(p['CurrencyType'] == TOKENS for p in prices)
seen_ids.add(pid)
kept.append(it)
root = {
'StoreItems': kept,
'SubscriberDiscountPercent': RRPLUS,
'StorefrontName': watch.get('StorefrontName'),
'StorefrontType': 3,
'NextUpdate': '2222-02-22T22:22:00Z',
'NewUntil': None,
}
if os.path.exists(STORE):
bak = STORE + '.bak-before-realstore'
if not os.path.exists(bak): shutil.copy2(STORE, bak); print('backed up old 3.json ->', os.path.basename(bak))
save(STORE, root)
kinds = collections.Counter(
'custom' if r['GiftDrop'].get('CustomAvatarItemId') else
'skin' if r['GiftDrop'].get('EquipmentPrefabName') else
'consumable' if r['GiftDrop'].get('ConsumableItemDesc') else
'box' if r['GiftDrop'].get('IsQuery') else 'avatar' for r in kept)
print(f'3.json: {len(kept)} rows kept of {len(watch["StoreItems"])}: {dict(kinds)}')
print(' with AvatarItemInfo (must be 0):', sum(1 for r in kept if r['GiftDrop'].get('AvatarItemInfo')))
print(' dropped:', dict(dropped))
print(' price floors applied:', dict(floored))
# ------------------------------------------------------------------ first-party custom items
# Every export record we hold the bundles for, priced. Where UGC.json already carries the item
# its (richer) record is kept and only a missing price is filled in, so nothing the client
# already renders changes shape.
out, priced, added, skipped = [], 0, 0, collections.Counter()
for e in export:
gid = e['CustomAvatarItemId'].lower()
if (e.get('CreatedAt') or '') > CUTOFF: skipped['newer than client'] += 1; continue
if not saves_ok(e): skipped['bundles missing'] += 1; continue
base = ugc_by_id.get(gid)
if base is not None:
rec = json.loads(json.dumps(base))
if not rec.get('Price') and e.get('Price'):
rec['Price'] = e['Price']; priced += 1
if rec.get('Accessibility', 1) == 0: rec['Accessibility'] = 1
else:
rec = json.loads(json.dumps(e)); added += 1
rec.setdefault('PurchaseInfo', None)
rec['CurrentSaves'] = [s for s in (rec.get('CurrentSaves') or []) if (s.get('CreatedAt') or '') <= CUTOFF]
rehash(rec)
out.append(rec)
save(OVERLAY, {'Results': out, 'TotalResults': len(out)})
print(f'FirstPartyUGC.json: {len(out)} first-party items ({added} new to this server, {priced} got a price); skipped {dict(skipped)}')
# ------------------------------------------------------------------ UGC.json hashes
# The swap rewrote these on the VPS; the deploy after it shipped this file's Unity 6 hashes
# straight back. Done here once, so the repo copy agrees with the bundles it points at.
if hashes:
changed = sum(rehash(r) for r in ugc)
if changed:
up = os.path.join(ITEMS, 'UGC.json')
bak = up + '.bak-orighashes'
if not os.path.exists(bak): shutil.copy2(up, bak)
save(up, {'Results': ugc, 'TotalResults': len(ugc)})
print(f'UGC.json: {changed} save hash(es) rewritten to the 2021.3 bundles (backup .bak-orighashes)')
else:
print('UGC.json: hashes already match the 2021.3 bundles')
if __name__ == '__main__':
main()