277 lines
14 KiB
Python
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()
|