#!/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()