the files
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user