pubg map: built-in intel layers (vehicle spots, drops, loot density, Blue Chip towers, secret rooms) per map
All checks were successful
Deploy production / deploy (push) Successful in 1m12s

Read-only layers from static/pubg-intel/<map>.v1.json (scripts/build-pubg-intel.py):
positions from pubg.gs (game level files), drops from guides, secret rooms and Deston
keys as approximate rings on the named place. Layer panel (K), per-player choices in
localStorage, drawn below room markers; clicks still place markers.
This commit is contained in:
2026-10-26 00:05:46 +00:00
parent b1edf7761c
commit be3473caa0
15 changed files with 1057 additions and 5 deletions

339
scripts/build-pubg-intel.py Normal file
View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Build the PUBG drop map's built-in intel layers (static/pubg-intel/<slug>.v1.json).
Exact positions come from pubg.gs, which reads them from the game's level files
(https://pubg.gs, fetched once on 2026-10-25). Only selected layers are published
(named places, bridges, normal-BR vehicle spots, Blue Chip towers, vending machines,
bear spawns); the 20k+ item spots are reduced to coarse loot-density cells.
Hot/quiet drops and secret rooms come from guides, anchored to the game's own
named-location points and flagged approximate. Nothing is placed by eye.
Usage: python3 scripts/build-pubg-intel.py static/pubg-intel
Input: the pubg.gs layer files (layers-<slug>.json) in $PUBG_GS_DIR, default the
script's folder. They are not committed: pubg.gs asks not to republish its data wholesale.
"""
import json, math, sys, collections, os
SRC = os.environ.get('PUBG_GS_DIR') or os.path.dirname(os.path.abspath(__file__))
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(SRC, 'out')
BUILT = '2026-10-25'
DEV = {'erangel': 'erangel', 'miramar': 'miramar', 'sanhok': 'sanhok', 'vikendi': 'vikendi', 'taego': 'taego',
'deston': 'deston', 'rondo': 'rondo', 'karakin': 'karakin', 'paramo': 'paramo', 'haven': 'haven'}
SOURCES = {
'gs': {'name': 'pubg.gs', 'url': 'https://pubg.gs/maps/{slug}/',
'note': 'positions read from the game level files'},
'pcg': {'name': 'PCGamesN, PUBG map list', 'url': 'https://www.pcgamesn.com/playerunknowns-battlegrounds/pubg-map'},
'psr': {'name': 'pubgsecretroom.com, best drop locations',
'url': 'https://pubgsecretroom.com/pubg-best-drop-locations-guide.html'},
'epic': {'name': 'Epic Games Store, guide to Rondo',
'url': 'https://store.epicgames.com/en-US/news/a-guide-to-pubg-battlegrounds-massive-new-map-rondo'},
'isp': {'name': 'InsideSport, Rondo hot spots',
'url': 'https://www.insidesport.in/gaming/pubg-mobile-rondo-hot-spots-check-out-the-top-landing-locations/'},
'gamepur': {'name': 'Gamepur, Deston secret rooms',
'url': 'https://www.gamepur.com/guides/all-deston-secret-room-locations-in-pubg'},
'loadout': {'name': 'The Loadout, Deston security keys',
'url': 'https://www.theloadout.com/pubg/security-key-location-deston'},
'ib': {'name': 'ImmortalBoost, PUBG secret rooms', 'url': 'https://immortalboost.com/blog/pubg/secret-locations/'},
'pn141': {'name': 'PUBG Wiki (Fandom), Erangel: Update 14.1 church basement', 'url': 'https://pubg.fandom.com/wiki/Erangel_(Remastered)'},
'pn412': {'name': 'PUBG patch notes, Update 41.2 (Miramar secret rooms)', 'url': 'https://pubg.com/en/news/10080'},
'psrooms': {'name': 'pubgsecretrooms.com (exact secret room spots)', 'url': 'https://pubgsecretrooms.com/{slug}-secret-room-locations/'},
}
NAMES = {
'erangel': {'boatyard': 'Boatyard', 'farm': 'Farm', 'ferrypier': 'Ferry Pier', 'gatka': 'Gatka', 'georgopol': 'Georgopol',
'hospital': 'Hospital', 'kameshki': 'Kameshki', 'lipovka': 'Lipovka', 'mansion': 'Mansion', 'mylta': 'Mylta',
'myltapower': 'Mylta Power', 'novorepnoye': 'Novorepnoye', 'pochinki': 'Pochinki', 'primorsk': 'Primorsk',
'prison': 'Prison', 'quarry': 'Quarry', 'rozhok': 'Rozhok', 'ruins': 'Ruins', 'school': 'School',
'severny': 'Severny', 'shelter': 'Shelter', 'shootingrange': 'Shooting Range',
'sosnovkamilitarybase': 'Military Base', 'stalber': 'Stalber', 'yasnayapolyana': 'Yasnaya Polyana',
'zharki': 'Zharki'},
'miramar': {'alcantara': 'Alcantara', 'brickyard': 'Brick Yard', 'campomilitar': 'Campo Militar', 'cantera': 'Cantera',
'chumacera': 'Chumacera', 'craterfields': 'Crater Fields', 'cruzdelvalle': 'Cruz del Valle',
'elazahar': 'El Azahar', 'elpozo': 'El Pozo', 'graveyard': 'Graveyard',
'haciendadelpatron': 'Hacienda del Patron', 'impala': 'Impala', 'lacobreria': 'La Cobreria',
'losleones': 'Los Leones', 'manisgenerales': 'Minas Generales', 'montenuevo': 'Monte Nuevo',
'oasis': 'Oasis', 'partona': 'Partona', 'pecado': 'Pecado',
'powergrid': 'Power Grid', 'prison': 'Prison', 'puertoparaiso': 'Puerto Paraiso', 'resort': 'Resort',
'sanmartin': 'San Martin', 'tierrabronca': 'Tierra Bronca', 'truckstop': 'Truck Stop',
'valledelmar': 'Valle del Mar', 'watertreatment': 'Water Treatment'},
'sanhok': {'bantai': 'Ban Tai', 'bootcamp': 'Bootcamp', 'campalpha': 'Camp Alpha', 'campbravo': 'Camp Bravo',
'campcharlie': 'Camp Charlie', 'cave': 'Cave', 'docks': 'Docks', 'hatinh': 'Ha Tinh', 'kampong': 'Kampong',
'khao': 'Khao', 'lawaki': 'Lakawi', 'mongnai': 'Mongnai', 'nakham': 'Na Kham', 'painan': 'Pai Nan',
'paradiseresort': 'Paradise Resort', 'quarry': 'Quarry', 'ruins': 'Ruins', 'sahmee': 'Sahmee',
'tambang': 'Tambang', 'tatmok': 'Tat Mok'},
'vikendi': {'castle': 'Castle', 'coalmine': 'Coal Mine', 'dekamesto': 'Deka Mesto', 'dinoland': 'Dinoland',
'kranik': 'Kranik', 'laveni': 'Laveni', 'lumberyard': 'Lumber Yard', 'naros': 'Naros',
'naznova': 'Naznova', 'observatory': 'Observatory', 'pavilka': 'Pavilka',
'trainstation': 'Train Station', 'trika': 'Trika', 'winery': 'Winery'},
'taego': {'airport': 'Airport', 'armybase': 'Army Base', 'buksansa': 'Buk San Sa', 'fishingcamp': 'Fishing Camp',
'godok': 'Go Dok', 'haemoosa': 'Hae Moo Sa', 'hapo': 'Ha Po', 'happy_hospital': 'Happy Hospital',
'hosan': 'Ho San', 'hosanprison': 'Ho San Prison', 'hospital': 'Hospital', 'kangneung': 'Kang Neung',
'ohhyang': 'Oh Hyang', 'palace': 'Palace', 'school': 'School', 'shipyard': 'Shipyard', 'songam': 'Song Am',
'terminal': 'Terminal', 'wolsong': 'Wol Song', 'yongcheon': 'Yong Cheon'},
'rondo': {'BeiLi': 'Bei Li', 'danching': 'Dan Ching', 'fongtun': 'Fong Tun', 'hemaytown': 'Hemay Town',
'hungshan': 'Hung Shan', 'jadenacity': 'Jadena City', 'jaotin': 'Jao Tin', 'kunxia': 'Kun Xia',
'lanpo': 'Lan Po', 'lohuaxing': 'Lo Hua Xing', 'longho': 'Long Ho', 'meyran': 'Mey Ran', 'minhu': 'Min Hu',
'muhopan': 'Mu Ho Pan', 'nanchuan': 'Nan Chuan', 'neoxfactory': 'NEOX Factory', 'raian': 'Rai An',
'rinjiang': 'Rin Jiang', 'stadium': 'Stadium', 'testtrack': 'Test Track', 'tinlonggarden': 'Tin Long Garden',
'tuling': 'Tu Ling', 'yulin': 'Yu Lin', 'yungu': 'Yun Gu'},
'karakin': {'alhabar': 'Al Habar', 'alhayik': 'Al Hayik', 'bahrsahir': 'Bahr Sahir', 'bashara': 'Bashara',
'hadiqanemo': 'Hadiqa Nemo'},
'paramo': {'Lab': 'Lab', 'Level_A': 'City slot A', 'Level_B': 'City slot B', 'Level_C': 'City slot C',
'Level_D': 'City slot D'},
'haven': {'coalyards': 'Coal Yards', 'docks': 'Docks', 'industrialpark': 'Industrial Park', 'railyard': 'Rail Yard',
'residential': 'Residential', 'steelmill': 'Steel Mill'},
}
# Hot, balanced and quiet drops: (poi slug or display name, tier, note, source)
DROPS = {
'erangel': [('pochinki', 'hot', 'Centre, dense housing: guaranteed early fights', 'psr'),
('georgopol', 'hot', 'Containers and warehouses, a lot of loot, narrow lanes', 'psr'),
('sosnovkamilitarybase', 'hot', 'Richest loot on the map; bridges are easy to hold, hard to leave', 'psr'),
('school', 'hot', '"Drop School if you\'re feeling feisty"', 'pcg'),
('yasnayapolyana', 'busy', 'Large city block, high loot, medium-high traffic', 'psr'),
('rozhok', 'balanced', 'Small, loot-dense, quick to clear', 'psr'),
('novorepnoye', 'balanced', 'Warehouses with less traffic', 'psr'),
('gatka', 'quiet', 'Few buildings, very little competition', 'psr'),
('lipovka', 'quiet', 'Few buildings, very little competition', 'psr')],
'miramar': [('pecado', 'hot', 'One of Miramar\'s best loot spots', 'pcg'),
('haciendadelpatron', 'hot', 'One of Miramar\'s best loot spots', 'pcg'),
('losleones', 'hot', 'Big city, one of the best loot spots', 'pcg'),
('elpozo', 'hot', 'Big city, one of the best loot spots', 'pcg')],
'sanhok': [('bootcamp', 'hot', 'Top-tier gear, early fights almost guaranteed', 'pcg'),
('ruins', 'hot', 'Top-tier gear', 'pcg'),
('paradiseresort', 'hot', 'Top-tier gear', 'pcg'),
('kampong', 'quiet', 'Landing spot to save the heated battles for later', 'pcg'),
('docks', 'quiet', 'Landing spot to save the heated battles for later', 'pcg')],
'vikendi': [('castle', 'hot', 'Centre; among the most useful loot', 'psr'),
('dinoland', 'balanced', 'Good loot (PCGamesN); a quieter alternative (pubgsecretroom.com)', 'pcg')],
'taego': [('palace', 'hot', 'One of the two big loot magnets', 'psr'),
('terminal', 'hot', 'One of the two big loot magnets', 'psr'),
('hosan', 'balanced', 'The balanced option', 'psr')],
'deston': [('Ripton', 'hot', 'Downtown core: more contested than Concert and Lodge', 'psr'),
('Concert', 'balanced', 'One of the strongest early landings, less contested than downtown', 'psr'),
('Lodge', 'balanced', 'One of the strongest early landings, less contested than downtown', 'psr')],
'rondo': [('jadenacity', 'hot', 'Dense skyscrapers and strong loot; avoid when early on the flight path', 'epic'),
('stadium', 'hot', 'High-loot bloodbath', 'isp'),
('neoxfactory', 'balanced', 'Central, lower-risk; vehicle spawns and test track to rotate', 'epic'),
('yulin', 'quiet', 'For a stealthy start', 'isp'),
('hungshan', 'quiet', 'Safer early loot', 'isp')],
}
# Secret rooms and keys anchored to a named location: (poi, layer, title, where, source)
SECRETS = {
'erangel': [('pochinki', 'secret', 'Church basement room', 'Basement of the Pochinki church; no key needed (Update 14.1)', 'pn141')],
'miramar': [('craterfields', 'secret', 'Secret room (mine)', 'West of Crater Fields, rocky area between La Cobreria and El Pozo', 'ib'),
('oasis', 'secret', 'Secret room (mine)', 'South of Oasis, close to the northern road', 'ib'),
('watertreatment', 'secret', 'Secret room (mine)', 'North of Water Treatment, on the rocks above the compound', 'ib'),
('elazahar', 'secret', 'Secret room (mine)', 'North-west of El Azahar, near the hills below Tierra Bronca', 'ib'),
('elpozo', 'secret', 'Secret room (mine)', 'East of El Pozo, on the route toward San Martin', 'ib')],
'deston': [('Barclift', 'secret', 'Security room', 'Barclift, east', 'gamepur'),
('Buxley', 'secret', 'Security room', 'Buxley, north-west part', 'gamepur'),
("Carpenter’s End", 'secret', 'Security room', "Carpenter's End, centre", 'gamepur'),
('Cavala', 'secret', 'Security room', 'Cavala, north', 'gamepur'),
('Concert', 'secret', 'Security room', 'Concert, centre', 'gamepur'),
('Holston Meadows', 'secret', 'Security room', 'Holston Meadows, centre', 'gamepur'),
('Sancarna', 'secret', 'Security room', 'Sancarna, north-east', 'gamepur'),
('Ripton', 'secret', 'Security rooms', 'Ripton, north; Ripton has the most rooms and trailers', 'gamepur'),
('Ten Forts', 'secret', 'Security room', 'Ten Forts, south', 'gamepur'),
('Turrita', 'secret', 'Security room', 'Turrita, north', 'gamepur'),
('Wind Farms', 'secret', 'Security room', 'Wind Farm, north of the bridge', 'gamepur'),
('Ten Forts', 'keys', 'Security key (drone)', 'North of Ten Forts', 'loadout'),
("Carpenter’s End", 'keys', 'Security key (drone)', "North-west of Carpenter's End", 'loadout'),
('Wind Farms', 'keys', 'Security key (drone)', 'Middle of Wind Farm', 'loadout'),
('Buxley', 'keys', 'Security key (drone)', 'South-west of Buxley', 'loadout'),
('Turrita', 'keys', 'Security key (drone)', 'South-west of Turrita', 'loadout'),
('Barclift', 'keys', 'Security key (drone)', 'South of Barclift', 'loadout'),
('Lodge', 'keys', 'Security key (drone)', 'North-east of Lodge', 'loadout'),
('Arena', 'keys', 'Security keys (drone)', 'North and south of Arena', 'loadout'),
('Cavala', 'keys', 'Security key (drone)', 'North-west of Cavala', 'loadout'),
('Sancarna', 'keys', 'Security key (drone)', 'South-west of Sancarna', 'loadout'),
('El Koro', 'keys', 'Security key (drone)', 'North of El Koro', 'loadout'),
('Holston Meadows', 'keys', 'Security key (drone)', 'North of Holston Meadows', 'loadout')],
}
# Per-map notes for things without sourceable coordinates.
NOTES = {
'erangel': [('Secret rooms', 'Keyed secret rooms with underground entrances (keys are random world loot). Exact spots are not republished here; see the source map.', 'psrooms')],
'miramar': [('Secret rooms', 'Abandoned-mine secret rooms opened with world-spawned Secret Room Keys (Update 41.2, Normal and Custom matches). Five are placed here near their landmark; more exist, see the source map for all of them.', 'pn412')],
'taego': [('Secret rooms', 'Secret rooms behind blue corrugated roofs; keys spawn randomly (glowing pots can drop one). Exact spots: see the source map.', 'psrooms')],
'vikendi': [('Secret rooms, labs and bear caves', 'Bear spawns below come from the game files (bear caves hold airdrop-quality loot). Secret rooms and lab rooms: see the source map.', 'psrooms')],
'rondo': [('Secret rooms', 'Security door and keycard rooms built into compound clusters; keys are random floor loot. Exact spots: see the source map.', 'psrooms')],
'paramo': [('Dynamic map', 'Paramo\'s cities move between the four city slots each match; the slot points below are where a city can appear.', 'gs'),
('Secret rooms', 'Stone-door secret rooms. Exact spots: see the source map.', 'psrooms')],
'deston': [('Security rooms', 'Rooms open with Pillar security keys, which sit in drone-only spots near the listed places (not in El Koro, Lodge or Arena). Positions are the named place, not the door.', 'loadout')],
}
VEH_TYPE = {
'Road': 'car', 'RoadPlus': 'car', 'Offroad': 'car', 'Rich_Houses': 'car', 'Poor_Houses': 'car', 'Farm': 'car',
'FoodTruck': 'car', 'Sports': 'car', 'Picobus': 'car', 'Start': 'car',
'Garage': 'garage', 'D_Garage': 'garage', 'Police_Stations': 'garage',
'Water': 'boat', 'WaterA': 'boat', 'WaterB': 'boat', 'WaterC': 'boat', 'WaterCave': 'boat', 'WaterSea': 'boat',
'Ocean': 'boat', 'River': 'boat', 'Dam': 'boat', 'W_Garage': 'boat', 'Water_Ferry': 'boat',
'Motorglider': 'glider', 'Hacienda_GoldMirado': 'special', 'OnlyBikeForCaveOnly': 'special',
}
LAYER_OF = {'car': 'cars', 'garage': 'garages', 'boat': 'boats', 'glider': 'gliders', 'special': 'special'}
CAT_LABEL = {'Road': 'Road spot', 'RoadPlus': 'Road spot (extra)', 'Offroad': 'Off-road spot', 'Rich_Houses': 'Rich houses',
'Poor_Houses': 'Poor houses', 'Farm': 'Farm (Pony Coupe)', 'FoodTruck': 'Food truck', 'Sports': 'Sports',
'Picobus': 'Pico Bus', 'Start': 'Start buggy', 'Garage': 'Garage', 'D_Garage': 'Downtown garage',
'Police_Stations': 'Police station (Pillar car)', 'Water': 'Boat', 'WaterA': 'Boat', 'WaterB': 'Boat',
'WaterC': 'Boat', 'WaterCave': 'Cave boat', 'WaterSea': 'Sea boat', 'Ocean': 'Ocean (Aquarail)',
'River': 'River (Airboat)', 'Dam': 'Dam (Airboat)', 'W_Garage': 'Boat garage (Airboat)',
'Water_Ferry': 'Ferry', 'Motorglider': 'Motor glider', 'Hacienda_GoldMirado': 'Gold Mirado',
'OnlyBikeForCaveOnly': 'Cave bike'}
def r5(v):
return round(v, 5)
def build(slug):
d = json.load(open(os.path.join(SRC, f'layers-{DEV[slug]}.json')))
E = d['extent']
L = d['layers']
inb = lambda x, y: 1000 < x < E - 1000 and 1000 < y < E - 1000
out = collections.OrderedDict()
skipped = collections.Counter()
# Named places (dedupe by display name; Deston has slug duplicates of the zone markers).
names = NAMES.get(slug, {})
def display(p):
if slug == 'deston':
return 'Swamp' if p['name'] == 'swamp' else p['name'].replace('’', "'")
return names[p['name']]
pois = {}
for p in L.get('pois', []):
nm = p['name']
if slug == 'deston':
if p.get('level') != 'Level_Design_Zone':
continue
disp = 'Swamp' if nm == 'swamp' else nm.replace('’', "'")
else:
disp = names.get(nm)
if not disp:
skipped['poi:' + nm] += 1
continue
if disp in pois:
continue
pois[disp] = p
pois[nm] = p
towns = []
seen = set()
for k, p in pois.items():
if id(p) in seen:
continue
seen.add(id(p))
disp = display(p)
rr = max(p.get('r') or 0, 10000)
near = sum(1 for it in L.get('items', []) if (it[0] - p['x']) ** 2 + (it[1] - p['y']) ** 2 <= rr * rr)
towns.append({'x': r5(p['x'] / E), 'y': r5(p['y'] / E), 'n': disp, 'r': r5(rr / E),
'd': f'{near} item spots within {round(rr / 100)} m', 'w': near, 's': 'gs'})
out['towns'] = towns
def anchor(key):
p = pois.get(key)
if p is None:
raise SystemExit(f'{slug}: no named location {key!r}')
disp = display(p)
return p, disp
out['bridges'] = [{'x': r5(b['x'] / E), 'y': r5(b['y'] / E), 'n': 'Bridge', 's': 'gs'}
for b in L.get('bridges', []) if inb(b['x'], b['y']) and 'IBR' not in (b.get('level') or '')]
# Vehicles: normal battle royale only (no IBR levels, no esports groups).
rules = d.get('vehicleRules', {}).get('br', {}).get('groups', {})
spots = collections.defaultdict(list)
for v in L.get('vehicles', []):
lvl = v.get('level') or ''
if 'IBR' in lvl.upper() or 'IBR' in (v.get('tag') or '').upper():
continue
g = v.get('group') or 'Default'
spots[g].append(v)
for g, vs in spots.items():
if g == 'Default':
cat, rule = 'Road', rules.get('A', [{}])[0]
else:
rs = rules.get(g)
if not rs:
skipped[f'vehicles:{g} (no rule)'] += len(vs)
continue
rule = rs[-1] if slug == 'rondo' and g == 'E' else rs[0]
cat = rule['categories'][0]
if cat.lower().startswith('esports') or cat in ('Special', 'Air'):
skipped[f'vehicles:{cat}'] += len(vs)
continue
cnt = rule.get('count') or [0, 0]
if not cnt or max(cnt) == 0:
skipped[f'vehicles:{cat} (0 per match)'] += len(vs)
continue
t = VEH_TYPE.get(cat)
if not t:
skipped[f'vehicles:{cat}'] += len(vs)
continue
n_spots = len(vs) + (len(spots.get('A', [])) if g == 'Default' else 0)
if g == 'A':
n_spots += len(spots.get('Default', []))
lo, hi = cnt[0], cnt[-1]
pool = [x if isinstance(x, str) else (x.get('name') or x.get('vehicle')) for x in (rule.get('vehicles') or [])]
chance = min(1.0, hi / max(1, n_spots))
note = f"{lo if lo == hi else f'{lo}-{hi}'} of {n_spots} spots spawn a vehicle each match (~{round(chance * 100)}%)"
if pool:
note += '; pool: ' + ', '.join(dict.fromkeys(p for p in pool if p))
lay = LAYER_OF[t]
for v in vs:
if not inb(v['x'], v['y']):
continue
out.setdefault(lay, []).append({'x': r5(v['x'] / E), 'y': r5(v['y'] / E), 'n': CAT_LABEL.get(cat, cat),
't': t, 'd': note, 'p': round(chance, 2), 's': 'gs'})
# Drops (hot / balanced / quiet), anchored on the named place.
for key, tier, note, src in DROPS.get(slug, []):
p, disp = anchor(key)
out.setdefault('drops', []).append({'x': r5(p['x'] / E), 'y': r5(p['y'] / E), 'n': disp, 't': tier, 'd': note, 's': src})
# Secret rooms and keys: approximate, shown as a ring around the named place.
for key, lay, title, where, src in SECRETS.get(slug, []):
p, disp = anchor(key)
out.setdefault(lay, []).append({'x': r5(p['x'] / E), 'y': r5(p['y'] / E), 'n': title, 'd': where + ' (approximate: drawn on the named place)',
'a': 1, 'r': r5(max(p.get('r') or 0, 20000) / E), 's': src})
out['towers'] = [{'x': r5(t['x'] / E), 'y': r5(t['y'] / E), 'n': 'Blue Chip tower', 'd': 'Blue Chip Transmitter spot', 's': 'gs'}
for t in L.get('towers', []) if inb(t['x'], t['y']) and 'IBR' not in (t.get('level') or '')]
out['vending'] = [{'x': r5(t['x'] / E), 'y': r5(t['y'] / E), 'n': 'Vending machine', 's': 'gs'}
for t in L.get('vending', []) if inb(t['x'], t['y'])]
out['bears'] = [{'x': r5(t['x'] / E), 'y': r5(t['y'] / E), 'n': 'Polar bear spawn', 'd': 'Bear cave spawn; the caves hold airdrop-tier loot (see ImmortalBoost)', 's': 'gs'}
for t in L.get('other', []) if t.get('cls') == 'BP_BearSpawnStart_C' and inb(t['x'], t['y'])]
# Loot density: item spots per ~200 m cell (normal BR levels only), top cells kept.
cell = 20000 if E >= 400000 else 10000
grid = collections.Counter()
for it in L.get('items', []):
x, y = it[0], it[1]
if inb(x, y):
grid[(int(x // cell), int(y // cell))] += 1
if grid:
counts = sorted(grid.values(), reverse=True)
keep = max(10, len(counts) // 12)
floor = counts[min(keep, len(counts)) - 1]
top = counts[0]
out['loot'] = [{'x': r5((i + 0.5) * cell / E), 'y': r5((j + 0.5) * cell / E), 'r': r5(cell / E / 2),
'w': round(n / top, 2), 'n': f'{n} item spots in this {cell // 100} m square', 's': 'gs'}
for (i, j), n in sorted(grid.items(), key=lambda kv: -kv[1]) if n >= floor]
out = {k: v for k, v in out.items() if v}
notes = [{'title': t, 'text': x, 's': s} for t, x, s in NOTES.get(slug, [])]
used = {p['s'] for v in out.values() for p in v} | {n['s'] for n in notes} | ({'ib'} if out.get('bears') else set())
srcs = {k: {kk: (vv.replace('{slug}', slug) if isinstance(vv, str) else vv) for kk, vv in SOURCES[k].items()} for k in sorted(used)}
doc = {'v': 1, 'map': slug, 'built': BUILT, 'sources': srcs, 'notes': notes, 'layers': out}
os.makedirs(OUT, exist_ok=True)
path = os.path.join(OUT, f'{slug}.v1.json')
with open(path, 'w') as f:
json.dump(doc, f, ensure_ascii=False, separators=(',', ':'))
print(f'{slug:8s} {os.path.getsize(path) // 1024:4d} KB', {k: len(v) for k, v in out.items()}, dict(skipped) if skipped else '')
for s in DEV:
build(s)