Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
261c99f46e
|
|||
| 759a1a4089 | |||
|
922c63f13d
|
|||
| 9e76165638 | |||
|
8ae301ab2d
|
|||
|
c04356f6a0
|
|||
|
d757468b7c
|
|||
|
79915431fa
|
|||
|
2a0b162a02
|
|||
|
7ce98d6275
|
|||
|
3fdc4fda38
|
|||
|
8d4d1772ca
|
|||
|
733e3149c4
|
|||
| 1865a385ad | |||
|
b9e0cccfe1
|
|||
|
934581772d
|
|||
|
7c8dd807da
|
|||
|
8c816e2e6b
|
|||
|
cc51e1c9a7
|
|||
|
ae5105f40e
|
|||
|
4a9b9dd40b
|
|||
|
1e822a410e
|
|||
| 1dfbff91c7 | |||
|
7810f04e8b
|
|||
|
484a289112
|
|||
| d495e80209 | |||
|
30fc283aa0
|
|||
|
2d48838fe6
|
|||
|
32df06f583
|
|||
|
1367eab480
|
|||
|
773511a6d3
|
|||
| d1965b01e4 | |||
|
b5eae83293
|
|||
| 88fee8a7d1 | |||
|
d10054477f
|
|||
| 74ab464497 | |||
|
a4a7bb265d
|
|||
| 572ca5c2b2 | |||
|
acadbbe5d2
|
|||
|
429228bf18
|
|||
| 8dc372b8a9 |
@@ -39,3 +39,8 @@ yarn-error.log*
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
source-bestiary/
|
||||
source-drops/
|
||||
site/server-status.json
|
||||
site/server-status.json.tmp
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# PSO Peeps Site Assets
|
||||
|
||||
## Included
|
||||
|
||||
- `hero.jpg` — cropped from the supplied Pioneer 2 screenshot for the homepage hero area.
|
||||
|
||||
## Still expected / placeholders
|
||||
|
||||
- `logo.png` — PSO Peeps logo.
|
||||
- `icons/discord.png` — Discord icon.
|
||||
- `icons/mastodon.png` — Mastodon icon.
|
||||
- `icons/bluesky.png` — Bluesky icon.
|
||||
|
||||
The HTML references these paths directly so the final assets can be dropped in without changing markup.
|
||||
+227
-14
@@ -445,13 +445,13 @@ def bb_sync_info(account_id):
|
||||
regions = {
|
||||
"us": {
|
||||
"host": "psopeeps_us",
|
||||
"targets": ["us-live", "us-test", "us-hardcore"],
|
||||
"targets": ["us-live", "us-hardcore"],
|
||||
"status": "pending",
|
||||
"applied_at": None,
|
||||
},
|
||||
"eu": {
|
||||
"host": "psopeeps_eu",
|
||||
"targets": ["eu-live", "eu-test", "eu-hardcore"],
|
||||
"targets": ["eu-live", "eu-hardcore"],
|
||||
"status": "pending",
|
||||
"applied_at": None,
|
||||
},
|
||||
@@ -1055,8 +1055,16 @@ def local_syncer_save_summary(account_id):
|
||||
applied_dir = root / "state" / "applied"
|
||||
|
||||
paths = {
|
||||
"us": applied_dir / f"psopeeps_us.site.{account}.json",
|
||||
"eu": applied_dir / f"psopeeps_eu.site.{account}.json",
|
||||
"us": [
|
||||
applied_dir / f"psopeeps_us.us-live.site.{account}.json",
|
||||
applied_dir / f"psopeeps_us.us-test.site.{account}.json",
|
||||
applied_dir / f"psopeeps_us.us-hardcore.site.{account}.json",
|
||||
],
|
||||
"eu": [
|
||||
applied_dir / f"psopeeps_eu.eu-live.site.{account}.json",
|
||||
applied_dir / f"psopeeps_eu.eu-test.site.{account}.json",
|
||||
applied_dir / f"psopeeps_eu.eu-hardcore.site.{account}.json",
|
||||
],
|
||||
}
|
||||
|
||||
def parse_time(value):
|
||||
@@ -1079,19 +1087,36 @@ def local_syncer_save_summary(account_id):
|
||||
"manifest_sha256": None,
|
||||
}
|
||||
|
||||
if path.exists():
|
||||
states = []
|
||||
errors = []
|
||||
|
||||
for path in paths[region]:
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
info.update({
|
||||
"status": "seen",
|
||||
"label": "Seen",
|
||||
"style": "warn",
|
||||
"host": data.get("host"),
|
||||
"applied_at": data.get("applied_at"),
|
||||
"manifest_sha256": data.get("manifest_sha256"),
|
||||
})
|
||||
data["_path"] = str(path)
|
||||
states.append(data)
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
errors.append(f"{path.name}: {e}")
|
||||
|
||||
if states:
|
||||
latest_state = max(states, key=lambda x: str(x.get("applied_at") or ""))
|
||||
hashes = {x.get("manifest_sha256") for x in states if x.get("manifest_sha256")}
|
||||
info.update({
|
||||
"status": "seen",
|
||||
"label": "Seen",
|
||||
"style": "warn",
|
||||
"host": latest_state.get("host"),
|
||||
"applied_at": latest_state.get("applied_at"),
|
||||
"manifest_sha256": latest_state.get("manifest_sha256"),
|
||||
"targets_seen": len(states),
|
||||
"targets_expected": len(paths[region]),
|
||||
"all_targets_same_hash": len(hashes) == 1,
|
||||
})
|
||||
|
||||
if errors:
|
||||
info["errors"] = errors
|
||||
|
||||
regions[region] = info
|
||||
|
||||
@@ -1702,6 +1727,7 @@ def _hc_merge_character_rows(rows):
|
||||
merged["total_enemies_killed"] = total_kills
|
||||
merged["alive"] = alive
|
||||
merged["dead_at"] = dead_at
|
||||
_hc_apply_canonical_death_overlay(merged)
|
||||
merged["last_seen_at"] = last_seen_at or None
|
||||
merged["updated_at"] = updated_at or None
|
||||
|
||||
@@ -1709,6 +1735,79 @@ def _hc_merge_character_rows(rows):
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def _hc_death_overlay_int(value):
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _hc_canonical_accounts_root():
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
return Path(os.environ.get(
|
||||
"PSOPEEPS_HC_CANONICAL_ACCOUNTS_ROOT",
|
||||
"/home/rbatty/.local/share/psopeeps_account_sync/canonical/accounts",
|
||||
))
|
||||
|
||||
|
||||
def _hc_canonical_slot_is_dead(guild_card, character_slot):
|
||||
import json
|
||||
|
||||
account_id = _hc_death_overlay_int(guild_card)
|
||||
slot = _hc_death_overlay_int(character_slot)
|
||||
if account_id is None or slot is None:
|
||||
return False
|
||||
|
||||
players_dir = _hc_canonical_accounts_root() / f"{account_id:010d}" / "system" / "players"
|
||||
if not players_dir.exists():
|
||||
return False
|
||||
|
||||
if any(players_dir.glob(f"player_*_{slot}.psochar.hardcore-dead")):
|
||||
return True
|
||||
|
||||
deaths_log = players_dir / "hardcore-deaths.jsonl"
|
||||
if not deaths_log.exists():
|
||||
return False
|
||||
|
||||
suffix = f"_{slot}.psochar"
|
||||
try:
|
||||
lines = deaths_log.read_text(errors="replace").splitlines()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if _hc_death_overlay_int(entry.get("account_id")) != account_id:
|
||||
continue
|
||||
|
||||
if str(entry.get("character_file") or "").endswith(suffix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _hc_apply_canonical_death_overlay(row):
|
||||
if _hc_canonical_slot_is_dead(
|
||||
row.get("guild_card") or row.get("account_id"),
|
||||
row.get("character_slot"),
|
||||
):
|
||||
row["alive"] = False
|
||||
row.setdefault("dead_at", "canonical-hardcore-dead")
|
||||
return row
|
||||
|
||||
|
||||
def _hc_combined_payload():
|
||||
source_rows, errors = _hc_get_source_characters()
|
||||
combined = _hc_merge_character_rows(source_rows)
|
||||
@@ -1840,3 +1939,117 @@ def hardcore_leaderboard_points_combined():
|
||||
return jsonify(rows[:100])
|
||||
|
||||
# --- end Hardcore stats aggregator -------------------------------------------
|
||||
|
||||
|
||||
@app.get("/server-status")
|
||||
@app.get("/api/server-status")
|
||||
def public_server_status():
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
prometheus_url = os.environ.get("PROMETHEUS_URL", "http://5.0.0.20:9090").rstrip("/")
|
||||
|
||||
def prom_value(query):
|
||||
url = prometheus_url + "/api/v1/query?" + urllib.parse.urlencode({"query": query})
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2.5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
if data.get("status") != "success":
|
||||
return 0
|
||||
|
||||
total = 0.0
|
||||
for result in data.get("data", {}).get("result", []):
|
||||
value = result.get("value", [None, "0"])[1]
|
||||
try:
|
||||
total += float(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return int(total)
|
||||
|
||||
def q(metric, labels):
|
||||
label_text = ",".join(f'{k}="{v}"' for k, v in labels.items())
|
||||
return f'sum({metric}{{{label_text}}}) or vector(0)'
|
||||
|
||||
def newserv(region, service, ship, version):
|
||||
return prom_value(q("pso_newserv_clients_connected", {
|
||||
"region": region,
|
||||
"service": service,
|
||||
"ship": ship,
|
||||
"version": version,
|
||||
}))
|
||||
|
||||
def adhoc(region, game):
|
||||
return prom_value(q("psppeeps_adhoc_connected_clients_by_product", {
|
||||
"region": region,
|
||||
"service": f"{region}-psppeeps-adhoc",
|
||||
"ship": "psp",
|
||||
"game": game,
|
||||
}))
|
||||
|
||||
us = {
|
||||
"alis_v2": newserv("us", "us-newserv-live", "live", "v2"),
|
||||
"alis_v3": newserv("us", "us-newserv-live", "live", "v3"),
|
||||
"alis_bb": newserv("us", "us-newserv-live", "live", "v4"),
|
||||
"abion_hcbb": newserv("us", "us-newserv-hardcore", "hardcore", "v4"),
|
||||
"adhoc_psp1": adhoc("us", "psp1"),
|
||||
"adhoc_psp2i": adhoc("us", "psp2i"),
|
||||
}
|
||||
|
||||
eu = {
|
||||
"palma_v2": newserv("eu", "eu-newserv-live", "live", "v2"),
|
||||
"palma_v3": newserv("eu", "eu-newserv-live", "live", "v3"),
|
||||
"palma_bb": newserv("eu", "eu-newserv-live", "live", "v4"),
|
||||
"aiedo_hcbb": newserv("eu", "eu-newserv-hardcore", "hardcore", "v4"),
|
||||
"adhoc_psp1": adhoc("eu", "psp1"),
|
||||
"adhoc_psp2i": adhoc("eu", "psp2i"),
|
||||
}
|
||||
|
||||
us_total = sum(us.values())
|
||||
eu_total = sum(eu.values())
|
||||
|
||||
return jsonify({
|
||||
"servers": [
|
||||
{
|
||||
"label": "US Server",
|
||||
"players": us_total,
|
||||
"ships": [
|
||||
{"label": "Alis", "rows": [
|
||||
{"label": "V2", "players": us["alis_v2"]},
|
||||
{"label": "V3", "players": us["alis_v3"]},
|
||||
{"label": "BB", "players": us["alis_bb"]},
|
||||
]},
|
||||
{"label": "Abion", "rows": [
|
||||
{"label": "HC/BB", "players": us["abion_hcbb"]},
|
||||
]},
|
||||
{"label": "AdHoc-US", "rows": [
|
||||
{"label": "PSP1", "players": us["adhoc_psp1"]},
|
||||
{"label": "PSP2i", "players": us["adhoc_psp2i"]},
|
||||
]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "EU Server",
|
||||
"players": eu_total,
|
||||
"ships": [
|
||||
{"label": "Palma", "rows": [
|
||||
{"label": "V2", "players": eu["palma_v2"]},
|
||||
{"label": "V3", "players": eu["palma_v3"]},
|
||||
{"label": "BB", "players": eu["palma_bb"]},
|
||||
]},
|
||||
{"label": "Aiedo", "rows": [
|
||||
{"label": "HC/BB", "players": eu["aiedo_hcbb"]},
|
||||
]},
|
||||
{"label": "AdHoc-EU", "rows": [
|
||||
{"label": "PSP1", "players": eu["adhoc_psp1"]},
|
||||
{"label": "PSP2i", "players": eu["adhoc_psp2i"]},
|
||||
]},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path("source-bestiary/local-newserv/system/tables/battle-params.json")
|
||||
OUT = Path("site/generated/bestiary/bb")
|
||||
|
||||
DIFFICULTY_ORDER = {
|
||||
"Normal": 0,
|
||||
"Hard": 1,
|
||||
"Very Hard": 2,
|
||||
"Ultimate": 3,
|
||||
}
|
||||
|
||||
def label_episode(table_name):
|
||||
ep, mode = table_name.split("-", 1)
|
||||
return ep.replace("Episode", "Episode "), mode
|
||||
|
||||
def enemy_label(value):
|
||||
return str(value).replace("_", " ").title()
|
||||
|
||||
def main():
|
||||
data = json.loads(SOURCE.read_text())
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = []
|
||||
|
||||
for table_name, difficulties in data.items():
|
||||
episode, mode = label_episode(table_name)
|
||||
|
||||
for difficulty, entries in difficulties.items():
|
||||
for entry in entries:
|
||||
stats = entry.get("Stats", {})
|
||||
resist = entry.get("ResistData", {})
|
||||
enemies = stats.get("Enemies") or []
|
||||
|
||||
for enemy in enemies:
|
||||
rows.append({
|
||||
"version": "bb",
|
||||
"episode": episode,
|
||||
"mode": mode,
|
||||
"difficulty": difficulty,
|
||||
"enemy": enemy_label(enemy),
|
||||
"enemy_key": enemy,
|
||||
"bp_index": entry.get("BPIndex"),
|
||||
"hp": stats.get("HP", 0),
|
||||
"atp": stats.get("ATP", 0),
|
||||
"dfp": stats.get("DFP", 0),
|
||||
"mst": stats.get("MST", 0),
|
||||
"ata": stats.get("ATA", 0),
|
||||
"evp": stats.get("EVP", 0),
|
||||
"lck": stats.get("LCK", 0),
|
||||
"esp": stats.get("ESP", 0),
|
||||
"exp": stats.get("EXP", 0),
|
||||
"meseta": stats.get("Meseta", 0),
|
||||
"efr": resist.get("EFR", 0),
|
||||
"eic": resist.get("EIC", 0),
|
||||
"eth": resist.get("ETH", 0),
|
||||
"elt": resist.get("ELT", 0),
|
||||
"edk": resist.get("EDK", 0),
|
||||
})
|
||||
|
||||
rows.sort(key=lambda row: (
|
||||
row["episode"],
|
||||
row["mode"],
|
||||
DIFFICULTY_ORDER.get(row["difficulty"], 999),
|
||||
row["enemy"],
|
||||
row["bp_index"] if row["bp_index"] is not None else 9999,
|
||||
))
|
||||
|
||||
(OUT / "bb.json").write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
|
||||
(OUT / "index.json").write_text(json.dumps({
|
||||
"mode": "bestiary",
|
||||
"label": "BB",
|
||||
"tables": [{
|
||||
"version": "bb",
|
||||
"label": "BB",
|
||||
"path": "bb.json",
|
||||
"rows": len(rows),
|
||||
}],
|
||||
}, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
print(f"bb: {len(rows)} rows -> {OUT / 'bb.json'}")
|
||||
print(f"index -> {OUT / 'index.json'}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
NEWSERV = Path.home() / ".local/share/github/psopeeps-newserv"
|
||||
PEEPS_OUT = Path("site/generated/drops/peeps")
|
||||
HARDCORE_OUT = Path("site/generated/drops/hardcore")
|
||||
|
||||
PEEPS_TABLES = {
|
||||
"v1": NEWSERV / "system/tables/rare-table-v1.json",
|
||||
"v2": NEWSERV / "system/tables/rare-table-v2.json",
|
||||
"v3": NEWSERV / "system/tables/rare-table-v3.json",
|
||||
"bb": NEWSERV / "system/tables/rare-table-v4.json",
|
||||
}
|
||||
|
||||
HARDCORE_TABLES = {
|
||||
"bb": Path("source-drops/hardcore/rare-table-v4.json"),
|
||||
}
|
||||
|
||||
def strip_json_comments(text):
|
||||
out = []
|
||||
in_str = False
|
||||
esc = False
|
||||
i = 0
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
n = text[i + 1] if i + 1 < len(text) else ""
|
||||
|
||||
if in_str:
|
||||
out.append(c)
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if c == '"':
|
||||
in_str = True
|
||||
out.append(c)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if c == "/" and n == "/":
|
||||
while i < len(text) and text[i] not in "\r\n":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
out.append(c)
|
||||
i += 1
|
||||
|
||||
return "".join(out)
|
||||
|
||||
def quote_hex_numbers(text):
|
||||
return re.sub(r'(?<!["\w])0x[0-9A-Fa-f]+', lambda m: f'"{m.group(0)}"', text)
|
||||
|
||||
|
||||
def remove_trailing_commas(text):
|
||||
out = []
|
||||
in_str = False
|
||||
esc = False
|
||||
i = 0
|
||||
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
|
||||
if in_str:
|
||||
out.append(c)
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if c == '"':
|
||||
in_str = True
|
||||
out.append(c)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if c == ",":
|
||||
j = i + 1
|
||||
while j < len(text) and text[j] in " \t\r\n":
|
||||
j += 1
|
||||
if j < len(text) and text[j] in "}]":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
out.append(c)
|
||||
i += 1
|
||||
|
||||
return "".join(out)
|
||||
|
||||
def load_newserv_jsonish(path):
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
text = strip_json_comments(text)
|
||||
text = quote_hex_numbers(text)
|
||||
text = remove_trailing_commas(text)
|
||||
return json.loads(text)
|
||||
|
||||
def normalize_item_code(value):
|
||||
if isinstance(value, str) and value.startswith("0x"):
|
||||
return "0x" + value[2:].upper()
|
||||
if isinstance(value, int):
|
||||
return f"0x{value:06X}"
|
||||
return str(value)
|
||||
|
||||
def flatten_table(version, table):
|
||||
rows = []
|
||||
|
||||
for game_mode, episodes in table.items():
|
||||
if not isinstance(episodes, dict):
|
||||
continue
|
||||
for episode, difficulties in episodes.items():
|
||||
if not isinstance(difficulties, dict):
|
||||
continue
|
||||
for difficulty, section_ids in difficulties.items():
|
||||
if not isinstance(section_ids, dict):
|
||||
continue
|
||||
for section_id, sources in section_ids.items():
|
||||
if not isinstance(sources, dict):
|
||||
continue
|
||||
for source, drops in sources.items():
|
||||
if not isinstance(drops, list):
|
||||
continue
|
||||
for drop in drops:
|
||||
if not isinstance(drop, list) or len(drop) < 2:
|
||||
continue
|
||||
|
||||
rows.append({
|
||||
"version": version,
|
||||
"mode": str(game_mode),
|
||||
"episode": str(episode),
|
||||
"difficulty": str(difficulty),
|
||||
"section_id": str(section_id),
|
||||
"source": str(source),
|
||||
"rate": str(drop[0]),
|
||||
"item_code": normalize_item_code(drop[1]),
|
||||
"item": str(drop[2]) if len(drop) >= 3 and drop[2] else "",
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
def write_group(mode, label, tables, out_dir):
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
index = {
|
||||
"mode": mode,
|
||||
"label": label,
|
||||
"tables": [],
|
||||
}
|
||||
|
||||
labels = {"v1": "V1", "v2": "V2", "v3": "V3", "bb": "BB"}
|
||||
|
||||
for version, src in tables.items():
|
||||
table = load_newserv_jsonish(src)
|
||||
rows = flatten_table(version, table)
|
||||
|
||||
out_name = f"{version}.json"
|
||||
out_path = out_dir / out_name
|
||||
out_path.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
index["tables"].append({
|
||||
"version": version,
|
||||
"label": labels[version],
|
||||
"path": out_name,
|
||||
"rows": len(rows),
|
||||
})
|
||||
|
||||
print(f"{mode} {version}: {len(rows)} rows -> {out_path}")
|
||||
|
||||
index_path = out_dir / "index.json"
|
||||
index_path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n")
|
||||
print(f"{mode} index -> {index_path}")
|
||||
|
||||
def main():
|
||||
write_group("peeps", "Peeps", PEEPS_TABLES, PEEPS_OUT)
|
||||
write_group("hardcore", "Hardcore", HARDCORE_TABLES, HARDCORE_OUT)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
PROMETHEUS_URL = os.environ.get("PROMETHEUS_URL", "http://5.0.0.20:9090").rstrip("/")
|
||||
SOURCE_URL = os.environ.get("SERVER_STATUS_SOURCE_URL", "").strip()
|
||||
OUTPUT_PATH = Path(os.environ.get("SERVER_STATUS_JSON", "site/server-status.json"))
|
||||
TIMEOUT_SECONDS = float(os.environ.get("PROMETHEUS_TIMEOUT_SECONDS", "5"))
|
||||
|
||||
def write_data(data):
|
||||
if not isinstance(data.get("servers"), list):
|
||||
raise SystemExit("server status JSON missing servers array")
|
||||
|
||||
data["generated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = OUTPUT_PATH.with_name(OUTPUT_PATH.name + ".tmp")
|
||||
tmp_path.write_text(json.dumps(data, separators=(",", ":")) + "\n")
|
||||
os.replace(tmp_path, OUTPUT_PATH)
|
||||
print(f"wrote {OUTPUT_PATH}")
|
||||
|
||||
def prom_value(query):
|
||||
url = PROMETHEUS_URL + "/api/v1/query?" + urllib.parse.urlencode({"query": query})
|
||||
with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
result = body.get("data", {}).get("result", [])
|
||||
if not result:
|
||||
return 0
|
||||
return int(float(result[0]["value"][1]))
|
||||
|
||||
def q(metric, labels):
|
||||
label_text = ",".join(f'{key}="{value}"' for key, value in labels.items())
|
||||
return f"sum({metric}{{{label_text}}}) or vector(0)"
|
||||
|
||||
def newserv(region, service, ship, version):
|
||||
return prom_value(q("pso_newserv_clients_connected", {
|
||||
"region": region,
|
||||
"service": service,
|
||||
"ship": ship,
|
||||
"version": version,
|
||||
}))
|
||||
|
||||
def adhoc(region, game):
|
||||
return prom_value(q("psppeeps_adhoc_connected_clients_by_product", {
|
||||
"region": region,
|
||||
"service": f"{region}-psppeeps-adhoc",
|
||||
"ship": "psp",
|
||||
"game": game,
|
||||
}))
|
||||
|
||||
def row(label, players):
|
||||
return {"label": label, "players": int(players)}
|
||||
|
||||
def main():
|
||||
if SOURCE_URL:
|
||||
with urllib.request.urlopen(SOURCE_URL, timeout=TIMEOUT_SECONDS) as response:
|
||||
write_data(json.loads(response.read().decode("utf-8")))
|
||||
return
|
||||
|
||||
us_alis_v2 = newserv("us", "us-newserv-live", "live", "v2")
|
||||
us_alis_v3 = newserv("us", "us-newserv-live", "live", "v3")
|
||||
us_alis_bb = newserv("us", "us-newserv-live", "live", "v4")
|
||||
us_abion_hcbb = newserv("us", "us-newserv-hardcore", "hardcore", "v4")
|
||||
us_adhoc_psp1 = adhoc("us", "psp1")
|
||||
us_adhoc_psp2i = adhoc("us", "psp2i")
|
||||
|
||||
eu_palma_v2 = newserv("eu", "eu-newserv-live", "live", "v2")
|
||||
eu_palma_v3 = newserv("eu", "eu-newserv-live", "live", "v3")
|
||||
eu_palma_bb = newserv("eu", "eu-newserv-live", "live", "v4")
|
||||
eu_aiedo_hcbb = newserv("eu", "eu-newserv-hardcore", "hardcore", "v4")
|
||||
eu_adhoc_psp1 = adhoc("eu", "psp1")
|
||||
eu_adhoc_psp2i = adhoc("eu", "psp2i")
|
||||
|
||||
write_data({
|
||||
"servers": [
|
||||
{
|
||||
"label": "US Server",
|
||||
"players": us_alis_v2 + us_alis_v3 + us_alis_bb + us_abion_hcbb + us_adhoc_psp1 + us_adhoc_psp2i,
|
||||
"ships": [
|
||||
{"label": "Alis", "rows": [row("V2", us_alis_v2), row("V3", us_alis_v3), row("BB", us_alis_bb)]},
|
||||
{"label": "Abion", "rows": [row("HC/BB", us_abion_hcbb)]},
|
||||
{"label": "AdHoc-US", "rows": [row("PSP1", us_adhoc_psp1), row("PSP2i", us_adhoc_psp2i)]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "EU Server",
|
||||
"players": eu_palma_v2 + eu_palma_v3 + eu_palma_bb + eu_aiedo_hcbb + eu_adhoc_psp1 + eu_adhoc_psp2i,
|
||||
"ships": [
|
||||
{"label": "Palma", "rows": [row("V2", eu_palma_v2), row("V3", eu_palma_v3), row("BB", eu_palma_bb)]},
|
||||
{"label": "Aiedo", "rows": [row("HC/BB", eu_aiedo_hcbb)]},
|
||||
{"label": "AdHoc-EU", "rows": [row("PSP1", eu_adhoc_psp1), row("PSP2i", eu_adhoc_psp2i)]},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,14 +0,0 @@
|
||||
# PSO Peeps Site Assets
|
||||
|
||||
## Included
|
||||
|
||||
- `hero.jpg` — cropped from the supplied Pioneer 2 screenshot for the homepage hero area.
|
||||
|
||||
## Still expected / placeholders
|
||||
|
||||
- `logo.png` — PSO Peeps logo.
|
||||
- `icons/discord.png` — Discord icon.
|
||||
- `icons/mastodon.png` — Mastodon icon.
|
||||
- `icons/bluesky.png` — Bluesky icon.
|
||||
|
||||
The HTML references these paths directly so the final assets can be dropped in without changing markup.
|
||||
+26
-14
@@ -24,7 +24,7 @@
|
||||
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
<a href="guide.html">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
</nav>
|
||||
|
||||
<main class="account-layout">
|
||||
@@ -32,13 +32,9 @@
|
||||
<div>
|
||||
<p class="eyebrow">Account Dashboard</p>
|
||||
<h1 id="account-title">chuudoku</h1>
|
||||
<p>
|
||||
Manage your Blue Burst login and the serial/access keys you use for DC V2, PC V2, and GC V3.
|
||||
Linked saves are mirrored between US and EU automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div class="status-badges" aria-label="Account setup status">
|
||||
<span class="badge badge--ok">BB account ready</span>
|
||||
<span class="badge badge--ok">Account ready</span>
|
||||
<span class="badge badge--ok">Saves synced</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -54,12 +50,25 @@
|
||||
|
||||
<section class="dashboard-grid dashboard-grid--setup">
|
||||
<section class="card setup-card setup-card--bb" aria-labelledby="bb-heading">
|
||||
<h2 id="bb-heading" class="section-title">Blue Burst Account</h2>
|
||||
<dl class="account-summary account-summary--large">
|
||||
<div><dt>BB username</dt><dd>chuudoku</dd></div>
|
||||
<div><dt>BB account ID</dt><dd>0126326509</dd></div>
|
||||
</dl>
|
||||
<p class="fine-print">Blue Burst is limited to one account per website account. Password reset can come later.</p>
|
||||
<h2 id="bb-heading" class="section-title">Blue Burst</h2>
|
||||
<p>BB username <strong>chuudoku</strong><br>BB account ID <strong>0126326509</strong></p>
|
||||
|
||||
<form class="bb-account-form" data-bb-action="change-password">
|
||||
<p class="muted">Change your Blue Burst login password. This updates the account file, then it needs to sync to the ships.</p>
|
||||
|
||||
<label>
|
||||
New BB password
|
||||
<input name="password" type="password" autocomplete="new-password" maxlength="16" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Confirm new BB password
|
||||
<input name="confirm_password" type="password" autocomplete="new-password" maxlength="16" required>
|
||||
</label>
|
||||
|
||||
<button class="button" type="submit">Change Blue Burst Password</button>
|
||||
<div class="bb-message" role="status"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card setup-card setup-card--key-sync" aria-labelledby="key-sync-heading">
|
||||
@@ -88,10 +97,13 @@
|
||||
<input id="key-label" name="key-label" type="text" placeholder="Dreamcast US disc, GameCube JP, etc.">
|
||||
|
||||
<label for="key-serial">Serial Number</label>
|
||||
<input id="key-serial" name="key-serial" type="text" inputmode="numeric">
|
||||
<input id="key-serial" name="key-serial" type="text" inputmode="numeric" placeholder="DC V2 serial number">
|
||||
|
||||
<label for="key-display-serial">Confirm Serial Number</label>
|
||||
<input id="key-display-serial" name="display_serial" autocomplete="off" type="text" inputmode="numeric" placeholder="confirm serial number">
|
||||
|
||||
<label for="key-access">Access Key</label>
|
||||
<input id="key-access" name="key-access" type="text">
|
||||
<input id="key-access" name="key-access" type="text" placeholder="access key">
|
||||
<button type="button">Register Key Profile</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a>
|
||||
<a href="guide.html">Connection Guide</a>
|
||||
<a href="leaderboards.html">Leaderboards</a>
|
||||
<a href="drops.html">Drops</a>
|
||||
<a href="bestiary.html">Bestiary</a>
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
</section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a>
|
||||
<a href="guide.html">Connection Guide</a>
|
||||
<a href="leaderboards.html">Leaderboards</a>
|
||||
<a href="drops.html">Drops</a>
|
||||
<a href="bestiary.html">Bestiary</a>
|
||||
|
||||
+8
-2
@@ -388,7 +388,10 @@
|
||||
if (!hero || !title) return;
|
||||
|
||||
for (const p of Array.from(hero.querySelectorAll("p"))) {
|
||||
if (p.textContent.includes("Manage your Blue Burst login")) {
|
||||
if (
|
||||
p.textContent.includes("Manage your Blue Burst login") ||
|
||||
p.classList.contains("account-email-line")
|
||||
) {
|
||||
p.remove();
|
||||
}
|
||||
}
|
||||
@@ -575,7 +578,10 @@
|
||||
|
||||
renderAccountEmail(accountData);
|
||||
updateAccountStatusBadges(accountData);
|
||||
renderBBCard(accountData);
|
||||
|
||||
// Account dashboard BB card is server-rendered.
|
||||
// Do not let the generic app bootstrap rewrite it into a stale layout.
|
||||
return;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const qs = (sel) => document.querySelector(sel);
|
||||
|
||||
const DIFFICULTY_ORDER = {
|
||||
Normal: 0,
|
||||
Hard: 1,
|
||||
"Very Hard": 2,
|
||||
Ultimate: 3,
|
||||
};
|
||||
|
||||
const NUMERIC_COLUMNS = new Set([
|
||||
"hp", "atp", "dfp", "mst", "ata", "evp", "lck", "esp", "exp",
|
||||
"efr", "eic", "eth", "elt", "edk",
|
||||
]);
|
||||
|
||||
const BP_TIERS = {
|
||||
0: { hp: 1.00, atp: 1.00, exp: 1.00 },
|
||||
1: { hp: 1.10, atp: 1.01, exp: 1.10 },
|
||||
2: { hp: 1.15, atp: 1.02, exp: 1.15 },
|
||||
3: { hp: 1.20, atp: 1.03, exp: 1.20 },
|
||||
4: { hp: 1.30, atp: 1.04, exp: 1.30 },
|
||||
5: { hp: 1.40, atp: 1.05, exp: 1.40 },
|
||||
6: { hp: 1.50, atp: 1.06, exp: 1.50 },
|
||||
7: { hp: 1.75, atp: 1.07, exp: 1.75 },
|
||||
8: { hp: 2.00, atp: 1.08, exp: 2.00 },
|
||||
9: { hp: 2.50, atp: 1.09, exp: 2.50 },
|
||||
10: { hp: 3.00, atp: 1.10, exp: 3.00 },
|
||||
11: { hp: 4.00, atp: 1.10, exp: 3.00 },
|
||||
};
|
||||
|
||||
const COLUMN_GROUPS = {
|
||||
stats: [
|
||||
["enemy", "Enemy"],
|
||||
["mode", "Mode"],
|
||||
["episode", "Episode"],
|
||||
["difficulty", "Difficulty"],
|
||||
["hp", "HP"],
|
||||
["atp", "ATP"],
|
||||
["dfp", "DFP"],
|
||||
["ata", "ATA"],
|
||||
["evp", "EVP"],
|
||||
["lck", "LCK"],
|
||||
["exp", "EXP"],
|
||||
],
|
||||
resists: [
|
||||
["enemy", "Enemy"],
|
||||
["mode", "Mode"],
|
||||
["episode", "Episode"],
|
||||
["difficulty", "Difficulty"],
|
||||
["efr", "EFR"],
|
||||
["eic", "EIC"],
|
||||
["eth", "ETH"],
|
||||
["elt", "ELT"],
|
||||
["edk", "EDK"],
|
||||
["esp", "ESP"],
|
||||
],
|
||||
all: [
|
||||
["enemy", "Enemy"],
|
||||
["mode", "Mode"],
|
||||
["episode", "Episode"],
|
||||
["difficulty", "Difficulty"],
|
||||
["hp", "HP"],
|
||||
["atp", "ATP"],
|
||||
["dfp", "DFP"],
|
||||
["mst", "MST"],
|
||||
["ata", "ATA"],
|
||||
["evp", "EVP"],
|
||||
["lck", "LCK"],
|
||||
["esp", "ESP"],
|
||||
["exp", "EXP"],
|
||||
["efr", "EFR"],
|
||||
["eic", "EIC"],
|
||||
["eth", "ETH"],
|
||||
["elt", "ELT"],
|
||||
["edk", "EDK"],
|
||||
],
|
||||
};
|
||||
|
||||
function currentColumns() {
|
||||
const view = qs("#bestiary-view")?.value || "stats";
|
||||
return COLUMN_GROUPS[view] || COLUMN_GROUPS.stats;
|
||||
}
|
||||
|
||||
const state = {
|
||||
rows: [],
|
||||
index: null,
|
||||
table: null,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sort: {
|
||||
key: "enemy",
|
||||
dir: "asc",
|
||||
},
|
||||
filters: {
|
||||
mode: "",
|
||||
episode: "",
|
||||
difficulty: "",
|
||||
search: "",
|
||||
},
|
||||
};
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
const res = await fetch(path, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
const box = qs("#bestiary-placeholder");
|
||||
if (!box) return;
|
||||
box.innerHTML = `<div class="drops-status ${kind ? `drops-status--${kind}` : ""}">${esc(message)}</div>`;
|
||||
}
|
||||
|
||||
function uniqueSorted(rows, key) {
|
||||
return [...new Set(rows.map((row) => row[key]).filter(Boolean))]
|
||||
.sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
function fillSelect(select, values, allLabel) {
|
||||
if (!select) return;
|
||||
const previous = select.value;
|
||||
select.innerHTML = `<option value="">${esc(allLabel)}</option>`;
|
||||
for (const value of values) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = value;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if ([...select.options].some((opt) => opt.value === previous)) {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function orderedDifficulties(rows) {
|
||||
const present = new Set(rows.map((row) => row.difficulty).filter(Boolean));
|
||||
return Object.keys(DIFFICULTY_ORDER).filter((value) => present.has(value));
|
||||
}
|
||||
|
||||
function bpTier() {
|
||||
const select = qs("#bestiary-tier");
|
||||
const raw = String(select?.value || "");
|
||||
const label = String(select?.selectedOptions?.[0]?.textContent || "");
|
||||
const match = raw.match(/(\d+)$/) || label.match(/(\d+)/);
|
||||
const tier = match ? Number(match[1]) : 0;
|
||||
return BP_TIERS[tier] || BP_TIERS[0];
|
||||
}
|
||||
|
||||
function xpBonusMultiplier() {
|
||||
return Number(qs("#bestiary-xp-bonus")?.value || 1);
|
||||
}
|
||||
|
||||
function displayValue(row, key) {
|
||||
const value = row[key];
|
||||
|
||||
if (key === "exp" && row.difficulty !== "Ultimate") {
|
||||
return Math.round(Number(value || 0) * xpBonusMultiplier());
|
||||
}
|
||||
|
||||
if (row.difficulty !== "Ultimate") {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
const tier = bpTier();
|
||||
|
||||
if (key === "hp") {
|
||||
return Math.round(Number(value || 0) * tier.hp);
|
||||
}
|
||||
|
||||
if (key === "atp") {
|
||||
return Math.round(Number(value || 0) * tier.atp);
|
||||
}
|
||||
|
||||
if (key === "exp" && tier.exp !== null) {
|
||||
return Math.round(Number(value || 0) * tier.exp * xpBonusMultiplier());
|
||||
}
|
||||
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function hasAnyBestiaryValue(row) {
|
||||
const keys = ["hp", "atp", "dfp", "mst", "ata", "evp", "lck", "esp", "exp", "efr", "eic", "eth", "elt", "edk"];
|
||||
return keys.some((key) => Number(row[key] || 0) !== 0);
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
const search = state.filters.search.trim().toLowerCase();
|
||||
|
||||
return state.rows.filter((row) => {
|
||||
if (!hasAnyBestiaryValue(row)) return false;
|
||||
if (state.filters.mode && row.mode !== state.filters.mode) return false;
|
||||
if (state.filters.episode && row.episode !== state.filters.episode) return false;
|
||||
if (state.filters.difficulty && row.difficulty !== state.filters.difficulty) return false;
|
||||
|
||||
if (search) {
|
||||
const haystack = [row.enemy, row.enemy_key, row.mode, row.episode, row.difficulty]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortValue(row, key) {
|
||||
if (key === "difficulty") return DIFFICULTY_ORDER[row.difficulty] ?? 999;
|
||||
if (NUMERIC_COLUMNS.has(key)) return Number(displayValue(row, key) || 0);
|
||||
return row[key] || "";
|
||||
}
|
||||
|
||||
function sortedRows(rows) {
|
||||
const { key, dir } = state.sort;
|
||||
const factor = dir === "desc" ? -1 : 1;
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = sortValue(a, key);
|
||||
const bv = sortValue(b, key);
|
||||
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
return (av - bv) * factor;
|
||||
}
|
||||
|
||||
return String(av).localeCompare(String(bv), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}) * factor;
|
||||
});
|
||||
}
|
||||
|
||||
function sortHeader(key, label) {
|
||||
const active = state.sort.key === key;
|
||||
const arrow = active ? (state.sort.dir === "asc" ? "▲" : "▼") : "";
|
||||
const ariaSort = active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none";
|
||||
|
||||
return `<th aria-sort="${ariaSort}">
|
||||
<button class="drops-sort-button ${active ? "is-active" : ""}" type="button" data-bestiary-sort="${esc(key)}">
|
||||
<span>${esc(label)}</span>
|
||||
<span class="drops-sort-arrow" aria-hidden="true">${arrow}</span>
|
||||
</button>
|
||||
</th>`;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const box = qs("#bestiary-placeholder");
|
||||
if (!box) return;
|
||||
|
||||
const rows = sortedRows(visibleRows());
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / state.pageSize));
|
||||
state.page = Math.min(Math.max(1, state.page), totalPages);
|
||||
|
||||
const start = (state.page - 1) * state.pageSize;
|
||||
const shown = rows.slice(start, start + state.pageSize);
|
||||
const end = start + shown.length;
|
||||
|
||||
const columns = currentColumns();
|
||||
if (!columns.some(([key]) => key === state.sort.key)) {
|
||||
state.sort.key = "enemy";
|
||||
state.sort.dir = "asc";
|
||||
}
|
||||
|
||||
const body = shown.map((row) => `
|
||||
<tr>
|
||||
${columns.map(([key]) => `<td data-label="${esc(key)}">${esc(displayValue(row, key))}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
const rangeText = rows.length
|
||||
? ` Showing ${Number(start + 1).toLocaleString()}-${Number(end).toLocaleString()}.`
|
||||
: "";
|
||||
|
||||
box.innerHTML = `
|
||||
<div class="drops-summary">
|
||||
<div>
|
||||
<strong>BB Bestiary</strong>
|
||||
<span>${rows.length.toLocaleString()} matching rows.${rangeText}</span>
|
||||
</div>
|
||||
<span>${state.rows.length.toLocaleString()} total rows</span>
|
||||
</div>
|
||||
<div class="drops-table-wrap">
|
||||
<table class="drops-table bestiary-table">
|
||||
<thead>
|
||||
<tr>${columns.map(([key, label]) => sortHeader(key, label)).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>${body || `<tr><td colspan="${columns.length}">No enemies match these filters.</td></tr>`}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="leaderboard-pager drops-pager">
|
||||
<button type="button" data-bestiary-page="prev" ${state.page <= 1 ? "disabled" : ""}>Previous</button>
|
||||
<span>Page ${state.page} of ${totalPages}</span>
|
||||
<button type="button" data-bestiary-page="next" ${state.page >= totalPages ? "disabled" : ""}>Next</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
fillSelect(qs("#bestiary-mode"), uniqueSorted(state.rows, "mode"), "All modes");
|
||||
fillSelect(qs("#bestiary-episode"), uniqueSorted(state.rows, "episode"), "All episodes");
|
||||
fillSelect(qs("#bestiary-difficulty"), orderedDifficulties(state.rows), "All difficulties");
|
||||
}
|
||||
|
||||
async function loadBestiary() {
|
||||
setStatus("Loading BB bestiary...");
|
||||
|
||||
state.index = await fetchJson("generated/bestiary/bb/index.json");
|
||||
state.table = state.index.tables?.[0] || null;
|
||||
state.rows = await fetchJson(`generated/bestiary/bb/${state.table.path}`);
|
||||
|
||||
populateFilters();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
qs("#bestiary-mode")?.addEventListener("change", (event) => {
|
||||
state.filters.mode = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-episode")?.addEventListener("change", (event) => {
|
||||
state.filters.episode = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-difficulty")?.addEventListener("change", (event) => {
|
||||
state.filters.difficulty = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-view")?.addEventListener("change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-search")?.addEventListener("input", (event) => {
|
||||
state.filters.search = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-tier")?.addEventListener("change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-xp-bonus")?.addEventListener("change", () => {
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-placeholder")?.addEventListener("click", (event) => {
|
||||
const pageButton = event.target.closest("[data-bestiary-page]");
|
||||
if (pageButton) {
|
||||
state.page += pageButton.dataset.bestiaryPage === "next" ? 1 : -1;
|
||||
renderTable();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = event.target.closest("[data-bestiary-sort]");
|
||||
if (!button) return;
|
||||
|
||||
const key = button.dataset.bestiarySort;
|
||||
if (state.sort.key === key) {
|
||||
state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
state.sort.key = key;
|
||||
state.sort.dir = "asc";
|
||||
}
|
||||
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
loadBestiary().catch((err) => {
|
||||
setStatus(err?.message || "Unable to load bestiary.", "error");
|
||||
});
|
||||
});
|
||||
})();
|
||||
+37
-12
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="style.css?v=bestiary-table-viewer-20260613-3">
|
||||
<script src="app.js?v=saves-synced-20260609-2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -24,7 +24,7 @@
|
||||
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
<a href="guide.html">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
</nav>
|
||||
|
||||
<main class="placeholder-layout">
|
||||
@@ -33,21 +33,46 @@
|
||||
<form class="placeholder-form">
|
||||
<label for="bestiary-version">Version</label>
|
||||
<select id="bestiary-version">
|
||||
<option>V2</option>
|
||||
<option>V3</option>
|
||||
<option>V4</option>
|
||||
<option value="bb">BB</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-episode" data-bestiary-episode-wrap hidden>Episode</label>
|
||||
<select id="bestiary-episode" data-bestiary-episode-wrap hidden>
|
||||
<option>Episode 1</option>
|
||||
<option>Episode 2</option>
|
||||
<option>Episode 4</option>
|
||||
<label for="bestiary-mode">Mode</label>
|
||||
<select id="bestiary-mode">
|
||||
<option value="">All modes</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-episode">Episode</label>
|
||||
<select id="bestiary-episode">
|
||||
<option value="">All episodes</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-difficulty">Difficulty</label>
|
||||
<select id="bestiary-difficulty">
|
||||
<option value="">All difficulties</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-xp-bonus">XP Bonus</label>
|
||||
<select id="bestiary-xp-bonus">
|
||||
<option value="1">No modifier</option>
|
||||
<option value="5">5x</option>
|
||||
<option value="10">10x</option>
|
||||
</select>
|
||||
<p class="drops-field-note">Applies EXP multiplier only.</p>
|
||||
|
||||
<label for="bestiary-view">View</label>
|
||||
<select id="bestiary-view">
|
||||
<option value="stats">Stats</option>
|
||||
<option value="resists">Resists</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-search">Search</label>
|
||||
<input id="bestiary-search" type="search" placeholder="Enemy name...">
|
||||
|
||||
<label for="bestiary-tier" data-bestiary-tier-wrap>BP Tier</label>
|
||||
<select id="bestiary-tier" data-bestiary-tier-wrap>
|
||||
<option>BP+1</option><option>BP+2</option><option>BP+3</option>
|
||||
|
||||
<option value="0">No modifier</option><option>BP+1</option><option>BP+2</option><option>BP+3</option>
|
||||
<option>BP+4</option><option>BP+5</option><option>BP+6</option>
|
||||
<option>BP+7</option><option>BP+8</option><option>BP+9</option>
|
||||
<option>BP+10</option><option>BP+11</option>
|
||||
@@ -89,6 +114,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="placeholder-pages.js?v=basic-pages-fixed-1" defer></script>
|
||||
<script src="bestiary-tables.js?v=hide-zero-bestiary-rows-1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Common Problems
|
||||
|
||||
Common connection and setup problems.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Flycast BBA
|
||||
|
||||
Connect Flycast using the broadband adapter style setup.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Flycast Dialup
|
||||
|
||||
Connect Flycast using the dialup/modem style setup.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,48 @@
|
||||
# V2 - Dreamcast
|
||||
|
||||
Most Dreamcast PSO V2 versions can likely connect. PSO Peeps officially supports:
|
||||
|
||||
Version 2 Final Revision US
|
||||
|
||||
This support statement includes PSO Peeps patches; patches are built and tested against that version. Direct support for Version 2 Final Revision JP is planned.
|
||||
|
||||
## Network settings
|
||||
|
||||
Use the PSO Peeps server address:
|
||||
|
||||
65.21.79.231 (EU)
|
||||
108.175.11.140 (US)
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
- A real Dreamcast
|
||||
- A normal retail PSO Ver.2 disc
|
||||
- A working Dreamcast online setup, such as DreamPi or another working modem/BBA setup
|
||||
|
||||
## Retail disc + DNS method
|
||||
|
||||
Some Dreamcast setups can connect with a normal retail PSO Ver.2 disc by setting the Dreamcast DNS server to PSO Peeps.
|
||||
|
||||
Set the Dreamcast DNS to:
|
||||
|
||||
Primary DNS: 65.21.79.231 (EU) or 108.175.11.140 (US)
|
||||
Secondary DNS: 0.0.0.0 / blank / your normal fallback DNS
|
||||
|
||||
Use automatic IP settings unless your Dreamcast network setup requires manual values.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Confirm your Dreamcast online setup works.
|
||||
2. Boot the normal retail PSO Ver.2 disc.
|
||||
3. Open the Dreamcast/PSO network settings.
|
||||
4. Set the primary DNS to:
|
||||
|
||||
65.21.79.231 (EU) or 108.175.11.140 (US)
|
||||
|
||||
5. Save the network settings.
|
||||
6. Select your character.
|
||||
7. Choose **Online Game**.
|
||||
8. Connect to PSO Peeps.
|
||||
9. Select the PSO Peeps ship/destination.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Dolphin
|
||||
|
||||
Connect through Dolphin.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# GameCube Hardware BBA
|
||||
|
||||
Connect a real GameCube using a broadband adapter.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Nintendont
|
||||
|
||||
Connect through Nintendont.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,54 @@
|
||||
# Connection Guide
|
||||
|
||||
This guide explains how to connect to PSO Peeps Online Live from Dreamcast, GameCube, PC V2, Blue Burst, Flycast, Dolphin, and Nintendont. Android via Winlator is on the roadmap and has its own section.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- **US Server:** `108.175.11.140`
|
||||
- **EU Server:** `65.21.79.231`
|
||||
|
||||
Characters on US and EU servers cannot play together. However, you can easily switch servers by updating your DNS settings for your client or console and retain your saves for V2 and V3 if you ever need to.
|
||||
|
||||
## Main supported versions
|
||||
|
||||
- Dreamcast PSO Ver.2
|
||||
- PC PSO Ver.2
|
||||
- GameCube Episode I & II
|
||||
- GameCube Episode III
|
||||
- Blue Burst
|
||||
|
||||
PSO Peeps supports crossplay between supported client versions. Some features and behavior can differ by client version because Dreamcast V2, PC V2, GameCube, Episode III, and Blue Burst are not identical games. See the Crossplay sections for more.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
Do not use a client that is still configured for Sylverant, Schtserv, Ephinea, Ultima, or another server.
|
||||
|
||||
Do not publicly post your serial number, access key, guild card, or account credentials.
|
||||
|
||||
## Supported discs and clients
|
||||
|
||||
PSO Peeps officially supports the following versions for direct support:
|
||||
|
||||
Dreamcast - Version 2 Final Revision US
|
||||
PC - Version 2 with our patched exe files tracked by the PC patch server
|
||||
GameCube - V1.2 Plus US
|
||||
Blue Burst - Tethella Ver12513_Multi with our patched exe files tracked by the BB patch server
|
||||
|
||||
All PSO discs and clients are supported for connection to PSO Peeps. We cannot offer direct support on versions outside the list above.
|
||||
|
||||
If you are using a pre-patched disc from another server, such as Sylverant, Schtserv, Ephinea, or Ultima, we cannot support issues related to that disc. We can only support issues related to our own patches. Convert to the PSO Peeps client or disc before requesting support.
|
||||
|
||||
If an issue is specific to newserv, Sylverant Patcher, or services like Nintendont, refer to those services and communities to open issues or get help.
|
||||
|
||||
## Reporting connection issues
|
||||
|
||||
If you are reporting a connection issue, always include:
|
||||
|
||||
Game version:
|
||||
Hardware/emulator:
|
||||
Client/disc used:
|
||||
Ship selected:
|
||||
Approximate time of the issue:
|
||||
What happened:
|
||||
@@ -0,0 +1,24 @@
|
||||
# Blue Burst on Linux
|
||||
|
||||
Connect the Blue Burst client on Linux.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Blue Burst on Windows
|
||||
|
||||
Connect the Blue Burst client on Windows.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# PSO PC on Linux
|
||||
|
||||
Connect the original PSO PC client on Linux.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# PSO PC on Windows
|
||||
|
||||
Connect the original PSO PC client on Windows.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Phantasy Star Portable
|
||||
|
||||
Phantasy Star Portable connection notes.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,22 @@
|
||||
# Quick Reference
|
||||
|
||||
Fast connection reference for PSO Peeps.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Guide sections
|
||||
|
||||
- Dreamcast: Hardware, Flycast Dialup, Flycast BBA
|
||||
- PC: PSO PC on Windows/Linux, Blue Burst on Windows/Linux
|
||||
- GameCube: Hardware BBA, Dolphin, Nintendont
|
||||
- Xbox
|
||||
- Phantasy Star Portable
|
||||
- Server-side saves
|
||||
- Common problems
|
||||
|
||||
## Notes
|
||||
|
||||
TODO: Add ports, DNS/proxy notes, account/key requirements, and client-specific quirks here.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Server-side Saves
|
||||
|
||||
How PSO Peeps server-side saves work.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Xbox
|
||||
|
||||
Xbox connection notes.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Hardcore Brutal Peeps
|
||||
|
||||
Brutal Peeps information for Hardcore.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Hardcore Getting Started
|
||||
|
||||
Start here for Hardcore Peeps.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Meseta and Bank Limits
|
||||
|
||||
Hardcore meseta and bank limits.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Points System
|
||||
|
||||
Hardcore points system information.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Hardcore Progression
|
||||
|
||||
Hardcore progression rules and expectations.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Brutal Peeps
|
||||
|
||||
Brutal Peeps information for normal play.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Crossplay Rooms
|
||||
|
||||
How crossplay rooms work on PSO Peeps.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# Peeps Getting Started
|
||||
|
||||
Start here for normal PSO Peeps play.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,24 @@
|
||||
# XP Periods
|
||||
|
||||
XP period information for PSO Peeps.
|
||||
|
||||
## Server addresses
|
||||
|
||||
- US Server: `108.175.11.140`
|
||||
- EU Server: `65.21.79.231`
|
||||
|
||||
## Overview
|
||||
|
||||
TODO: Fill in the converted guide content for this page.
|
||||
|
||||
## Requirements
|
||||
|
||||
TODO
|
||||
|
||||
## Setup
|
||||
|
||||
TODO
|
||||
|
||||
## Notes
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,498 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const qs = (sel) => document.querySelector(sel);
|
||||
|
||||
const state = {
|
||||
index: null,
|
||||
rows: [],
|
||||
table: null,
|
||||
filters: {
|
||||
mode: "",
|
||||
episode: "",
|
||||
difficulty: "",
|
||||
section: "",
|
||||
search: "",
|
||||
},
|
||||
sort: {
|
||||
key: "source",
|
||||
dir: "asc",
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
};
|
||||
|
||||
const RARE_MODIFIER_VERSIONS = new Set(["v2", "bb"]);
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function labelValue(value) {
|
||||
return String(value || "")
|
||||
.replace(/^Episode(\d+)$/, "Episode $1")
|
||||
.replace(/^VeryHard$/, "Very Hard")
|
||||
.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
const box = qs("#drops-placeholder");
|
||||
if (!box) return;
|
||||
box.innerHTML = `<div class="drops-status ${kind ? `drops-status--${kind}` : ""}">${esc(message)}</div>`;
|
||||
}
|
||||
|
||||
function currentVersion() {
|
||||
return qs("#drops-version")?.value || "v1";
|
||||
}
|
||||
|
||||
function rareModifierEnabled() {
|
||||
const selectedMode = qs("#drops-mode")?.value || "peeps";
|
||||
return selectedMode === "peeps" && RARE_MODIFIER_VERSIONS.has(currentVersion());
|
||||
}
|
||||
|
||||
function currentRareModifier() {
|
||||
const select = qs("#drops-rare-modifier");
|
||||
const pct = rareModifierEnabled() ? Number(select?.value || 0) : 0;
|
||||
const label = select?.selectedOptions?.[0]?.textContent || "No modifier";
|
||||
return { pct, label, multiplier: 1 + (pct / 100) };
|
||||
}
|
||||
|
||||
function modifierMultiplierLabel(modifier) {
|
||||
return `x${modifier.multiplier.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}`;
|
||||
}
|
||||
|
||||
function updateRareModifierControls() {
|
||||
const wrap = qs("#drops-rare-modifier-wrap");
|
||||
const v2Note = qs("#drops-v2-note");
|
||||
const select = qs("#drops-rare-modifier");
|
||||
const enabled = rareModifierEnabled();
|
||||
|
||||
if (wrap) wrap.hidden = !enabled;
|
||||
if (v2Note) v2Note.hidden = currentVersion() !== "v2";
|
||||
|
||||
if (!enabled && select) {
|
||||
select.value = "0";
|
||||
}
|
||||
}
|
||||
|
||||
function formatOddsDenominator(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "—";
|
||||
|
||||
if (value >= 1000) {
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
if (value >= 100) {
|
||||
return value.toFixed(1).replace(/\.0$/, "");
|
||||
}
|
||||
|
||||
return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function adjustedRate(rate) {
|
||||
const text = String(rate || "");
|
||||
const match = text.match(/^(\d+)\/(\d+)$/);
|
||||
if (!match) return text || "—";
|
||||
|
||||
const num = Number(match[1]);
|
||||
const den = Number(match[2]);
|
||||
const modifier = currentRareModifier();
|
||||
|
||||
if (!rareModifierEnabled() || modifier.pct <= 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const baseProbability = num / den;
|
||||
const adjustedProbability = Math.min(1, baseProbability * modifier.multiplier);
|
||||
|
||||
if (adjustedProbability >= 1) {
|
||||
return "1/1";
|
||||
}
|
||||
|
||||
return `1/${formatOddsDenominator(1 / adjustedProbability)}`;
|
||||
}
|
||||
|
||||
function rateCellHtml(rate) {
|
||||
const base = String(rate || "—");
|
||||
const adjusted = adjustedRate(base);
|
||||
const modifier = currentRareModifier();
|
||||
|
||||
if (!rareModifierEnabled() || modifier.pct <= 0 || adjusted === base) {
|
||||
return esc(base);
|
||||
}
|
||||
|
||||
return esc(adjusted);
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
const res = await fetch(path, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function uniqueSorted(rows, key) {
|
||||
return [...new Set(rows.map((row) => row[key]).filter(Boolean))]
|
||||
.sort((a, b) => String(a).localeCompare(String(b)));
|
||||
}
|
||||
|
||||
function fillSelect(select, values, allLabel) {
|
||||
if (!select) return;
|
||||
const previous = select.value;
|
||||
select.innerHTML = `<option value="">${esc(allLabel)}</option>`;
|
||||
for (const value of values) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = labelValue(value);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if ([...select.options].some((opt) => opt.value === previous)) {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function populateVersions(index) {
|
||||
const select = qs("#drops-version");
|
||||
if (!select) return;
|
||||
|
||||
const previous = select.value || "v1";
|
||||
select.innerHTML = "";
|
||||
|
||||
for (const table of index.tables || []) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = table.version;
|
||||
opt.textContent = table.label;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
if ([...select.options].some((opt) => opt.value === previous)) {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function orderedDifficultyValues(rows) {
|
||||
const order = ["Normal", "Hard", "VeryHard", "Ultimate"];
|
||||
const present = new Set(rows.map((row) => row.difficulty).filter(Boolean));
|
||||
const ordered = order.filter((value) => present.has(value));
|
||||
const extras = [...present]
|
||||
.filter((value) => !order.includes(value))
|
||||
.sort((a, b) => String(a).localeCompare(String(b)));
|
||||
return [...ordered, ...extras];
|
||||
}
|
||||
|
||||
function populateFilters(rows) {
|
||||
fillSelect(qs("#drops-rare-mode"), uniqueSorted(rows, "mode"), "All modes");
|
||||
fillSelect(qs("#drops-episode"), uniqueSorted(rows, "episode"), "All episodes");
|
||||
fillSelect(qs("#drops-difficulty"), orderedDifficultyValues(rows), "All difficulties");
|
||||
fillSelect(qs("#drops-section"), uniqueSorted(rows, "section_id"), "All Section IDs");
|
||||
}
|
||||
|
||||
const SORT_COLUMNS = [
|
||||
["mode", "Mode"],
|
||||
["episode", "Episode"],
|
||||
["difficulty", "Difficulty"],
|
||||
["section_id", "SECID"],
|
||||
["source", "Source"],
|
||||
["item", "Item"],
|
||||
["item_code", "Code"],
|
||||
["rate", "Rate"],
|
||||
];
|
||||
|
||||
const DIFFICULTY_SORT_ORDER = {
|
||||
Normal: 0,
|
||||
Hard: 1,
|
||||
VeryHard: 2,
|
||||
Ultimate: 3,
|
||||
};
|
||||
|
||||
function rateSortValue(rate) {
|
||||
const text = String(rate || "").replaceAll(",", "");
|
||||
const match = text.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return Number.NEGATIVE_INFINITY;
|
||||
|
||||
const num = Number(match[1]);
|
||||
const den = Number(match[2]);
|
||||
|
||||
if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
// Sort by actual probability, same as percentage conversion.
|
||||
// 5/8 => 0.625, 1/8192 => 0.000122...
|
||||
return num / den;
|
||||
}
|
||||
|
||||
function sortValue(row, key) {
|
||||
if (key === "difficulty") {
|
||||
return DIFFICULTY_SORT_ORDER[row.difficulty] ?? 999;
|
||||
}
|
||||
|
||||
if (key === "rate") {
|
||||
return rateSortValue(adjustedRate(row.rate));
|
||||
}
|
||||
|
||||
if (key === "item") {
|
||||
return row.item || row.item_code || "";
|
||||
}
|
||||
|
||||
return row[key] || "";
|
||||
}
|
||||
|
||||
function sortedRows(rows) {
|
||||
const { key, dir } = state.sort;
|
||||
const factor = dir === "desc" ? -1 : 1;
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = sortValue(a, key);
|
||||
const bv = sortValue(b, key);
|
||||
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
return (av - bv) * factor;
|
||||
}
|
||||
|
||||
return String(av).localeCompare(String(bv), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}) * factor;
|
||||
});
|
||||
}
|
||||
|
||||
function sortHeader(key, label) {
|
||||
const active = state.sort.key === key;
|
||||
const arrow = active ? (state.sort.dir === "asc" ? "▲" : "▼") : "";
|
||||
const ariaSort = active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none";
|
||||
|
||||
return `<th aria-sort="${ariaSort}">
|
||||
<button class="drops-sort-button ${active ? "is-active" : ""}" type="button" data-drops-sort="${esc(key)}">
|
||||
<span>${esc(label)}</span>
|
||||
<span class="drops-sort-arrow" aria-hidden="true">${arrow}</span>
|
||||
</button>
|
||||
</th>`;
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
const search = state.filters.search.trim().toLowerCase();
|
||||
|
||||
return state.rows.filter((row) => {
|
||||
if (state.filters.mode && row.mode !== state.filters.mode) return false;
|
||||
if (state.filters.episode && row.episode !== state.filters.episode) return false;
|
||||
if (state.filters.difficulty && row.difficulty !== state.filters.difficulty) return false;
|
||||
if (state.filters.section && row.section_id !== state.filters.section) return false;
|
||||
|
||||
if (search) {
|
||||
const haystack = [
|
||||
row.mode,
|
||||
row.episode,
|
||||
row.difficulty,
|
||||
row.section_id,
|
||||
row.source,
|
||||
row.item,
|
||||
row.item_code,
|
||||
row.rate,
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const box = qs("#drops-placeholder");
|
||||
if (!box) return;
|
||||
|
||||
const rows = sortedRows(visibleRows());
|
||||
const tableLabel = state.table?.label || "BB";
|
||||
const groupLabel = state.table?.groupLabel || "Peeps";
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / state.pageSize));
|
||||
state.page = Math.min(Math.max(1, state.page), totalPages);
|
||||
|
||||
const start = (state.page - 1) * state.pageSize;
|
||||
const shown = rows.slice(start, start + state.pageSize);
|
||||
const end = start + shown.length;
|
||||
|
||||
const body = shown.map((row) => {
|
||||
const item = row.item || row.item_code || "—";
|
||||
const itemCode = row.item_code || "—";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td data-label="Mode">${esc(labelValue(row.mode))}</td>
|
||||
<td data-label="Episode">${esc(labelValue(row.episode))}</td>
|
||||
<td data-label="Difficulty">${esc(labelValue(row.difficulty))}</td>
|
||||
<td data-label="Section ID">${esc(row.section_id || "—")}</td>
|
||||
<td data-label="Source">${esc(labelValue(row.source || "—"))}</td>
|
||||
<td data-label="Item">${esc(item)}</td>
|
||||
<td data-label="Item Code">${esc(itemCode)}</td>
|
||||
<td data-label="Rate">${rateCellHtml(row.rate || "—")}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
const rangeText = rows.length
|
||||
? ` Showing ${Number(start + 1).toLocaleString()}-${Number(end).toLocaleString()}.`
|
||||
: "";
|
||||
const modifier = currentRareModifier();
|
||||
const modifierNote = rareModifierEnabled() && modifier.pct > 0
|
||||
? `<span>Rate modifier: ${esc(modifier.label)} / ${esc(modifierMultiplierLabel(modifier))}</span>`
|
||||
: "";
|
||||
|
||||
box.innerHTML = `
|
||||
<div class="drops-summary">
|
||||
<div>
|
||||
<strong>${esc(groupLabel)} ${esc(tableLabel)} drop table</strong>
|
||||
<span>${rows.length.toLocaleString()} matching rows.${rangeText}</span>
|
||||
${modifierNote}
|
||||
</div>
|
||||
<span>${state.rows.length.toLocaleString()} total rows</span>
|
||||
</div>
|
||||
<div class="drops-table-wrap">
|
||||
<table class="drops-table">
|
||||
<thead>
|
||||
<tr>
|
||||
${SORT_COLUMNS.map(([key, label]) => sortHeader(key, label)).join("")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${body || `<tr><td colspan="8">No drops match these filters.</td></tr>`}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="leaderboard-pager drops-pager">
|
||||
<button type="button" data-drops-page="prev" ${state.page <= 1 ? "disabled" : ""}>Previous</button>
|
||||
<span>Page ${state.page} of ${totalPages}</span>
|
||||
<button type="button" data-drops-page="next" ${state.page >= totalPages ? "disabled" : ""}>Next</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadDropGroup() {
|
||||
const selectedMode = qs("#drops-mode")?.value || "peeps";
|
||||
const groupPath = selectedMode === "hardcore" ? "hardcore" : "peeps";
|
||||
const groupLabel = selectedMode === "hardcore" ? "Hardcore" : "Peeps";
|
||||
|
||||
setStatus(`Loading ${groupLabel} drop tables...`);
|
||||
|
||||
state.index = await fetchJson(`generated/drops/${groupPath}/index.json`);
|
||||
|
||||
if (selectedMode === "peeps") {
|
||||
populateVersions(state.index);
|
||||
}
|
||||
|
||||
const version = selectedMode === "hardcore" ? "bb" : currentVersion();
|
||||
const table = (state.index.tables || []).find((entry) => entry.version === version);
|
||||
|
||||
if (!table) {
|
||||
setStatus(`No ${groupLabel} drop table is configured.`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
state.table = { ...table, groupLabel };
|
||||
state.rows = await fetchJson(`generated/drops/${groupPath}/${table.path}`);
|
||||
|
||||
state.filters.mode = "";
|
||||
state.filters.episode = "";
|
||||
state.filters.difficulty = "";
|
||||
state.filters.section = "";
|
||||
state.filters.search = "";
|
||||
state.page = 1;
|
||||
|
||||
if (qs("#drops-search")) qs("#drops-search").value = "";
|
||||
|
||||
populateFilters(state.rows);
|
||||
updateRareModifierControls();
|
||||
|
||||
if (qs("#drops-rare-mode")) qs("#drops-rare-mode").value = "";
|
||||
if (qs("#drops-episode")) qs("#drops-episode").value = "";
|
||||
if (qs("#drops-difficulty")) qs("#drops-difficulty").value = "";
|
||||
if (qs("#drops-section")) qs("#drops-section").value = "";
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
async function updateMode() {
|
||||
const mode = qs("#drops-mode")?.value || "peeps";
|
||||
const versionSelect = qs("#drops-version");
|
||||
const versionLabel = document.querySelector('label[for="drops-version"]');
|
||||
const v2Note = qs("#drops-v2-note");
|
||||
const hardcoreNote = qs("#drops-hardcore-note");
|
||||
|
||||
if (versionSelect) versionSelect.hidden = mode === "hardcore";
|
||||
if (versionLabel) versionLabel.hidden = mode === "hardcore";
|
||||
if (v2Note) v2Note.hidden = true;
|
||||
if (hardcoreNote) hardcoreNote.hidden = mode !== "hardcore";
|
||||
|
||||
try {
|
||||
await loadDropGroup();
|
||||
} catch (err) {
|
||||
setStatus(err?.message || "Unable to load drop table.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
qs("#drops-mode")?.addEventListener("change", updateMode);
|
||||
qs("#drops-version")?.addEventListener("change", loadDropGroup);
|
||||
qs("#drops-rare-modifier")?.addEventListener("change", () => {
|
||||
state.page = 1;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#drops-rare-mode")?.addEventListener("change", (event) => {
|
||||
state.filters.mode = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#drops-episode")?.addEventListener("change", (event) => {
|
||||
state.filters.episode = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#drops-difficulty")?.addEventListener("change", (event) => {
|
||||
state.filters.difficulty = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#drops-section")?.addEventListener("change", (event) => {
|
||||
state.filters.section = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#drops-search")?.addEventListener("input", (event) => {
|
||||
state.filters.search = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#drops-placeholder")?.addEventListener("click", (event) => {
|
||||
const pageButton = event.target.closest("[data-drops-page]");
|
||||
if (pageButton) {
|
||||
state.page += pageButton.dataset.dropsPage === "next" ? 1 : -1;
|
||||
renderTable();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = event.target.closest("[data-drops-sort]");
|
||||
if (!button) return;
|
||||
|
||||
const key = button.dataset.dropsSort;
|
||||
if (state.sort.key === key) {
|
||||
state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
state.sort.key = key;
|
||||
state.sort.dir = "asc";
|
||||
}
|
||||
|
||||
renderTable();
|
||||
});
|
||||
|
||||
updateMode();
|
||||
});
|
||||
})();
|
||||
+56
-17
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="style.css?v=drops-peeps-table-viewer-20260613-3">
|
||||
<script src="app.js?v=saves-synced-20260609-2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -24,7 +24,7 @@
|
||||
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
<a href="guide.html">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
</nav>
|
||||
|
||||
<main class="placeholder-layout">
|
||||
@@ -37,25 +37,64 @@
|
||||
<option value="hardcore">Hardcore</option>
|
||||
</select>
|
||||
|
||||
<label for="drops-version" data-drops-version-wrap>Version</label>
|
||||
<select id="drops-version" data-drops-version-wrap>
|
||||
<option>V1</option>
|
||||
<option>V2</option>
|
||||
<option>V3</option>
|
||||
<option>V4</option>
|
||||
</select>
|
||||
<div id="drops-peeps-controls">
|
||||
<label for="drops-version">Version</label>
|
||||
<select id="drops-version">
|
||||
<option value="v1">V1</option>
|
||||
<option value="v2">V2</option>
|
||||
<option value="v3">V3</option>
|
||||
<option value="bb">BB</option>
|
||||
</select>
|
||||
<p class="drops-field-note" id="drops-v2-note" hidden>V2 drop tables apply to PSO PC only.</p>
|
||||
<p class="drops-field-note" id="drops-hardcore-note" hidden>Hardcore uses the BB/V4 drop table.</p>
|
||||
|
||||
<label for="drops-episode" data-drops-episode-wrap hidden>Episode</label>
|
||||
<select id="drops-episode" data-drops-episode-wrap hidden>
|
||||
<option>Episode 1</option>
|
||||
<option>Episode 2</option>
|
||||
<option>Episode 4</option>
|
||||
</select>
|
||||
<div id="drops-rare-modifier-wrap" hidden>
|
||||
<label for="drops-rare-modifier">Rare bonus modifier</label>
|
||||
<select id="drops-rare-modifier">
|
||||
<option value="0">No modifier</option>
|
||||
<option value="0.1">Brutal Peeps +1 (+0.1%)</option>
|
||||
<option value="0.2">Brutal Peeps +2 (+0.2%)</option>
|
||||
<option value="0.5">Brutal Peeps +3 (+0.5%)</option>
|
||||
<option value="0.6">Brutal Peeps +4 (+0.6%)</option>
|
||||
<option value="0.8">Brutal Peeps +5 (+0.8%)</option>
|
||||
<option value="0.9">Brutal Peeps +6 (+0.9%)</option>
|
||||
<option value="1.0">Brutal Peeps +7 (+1.0%)</option>
|
||||
<option value="2.0">Brutal Peeps +8 (+2.0%)</option>
|
||||
<option value="3.0">Brutal Peeps +9 (+3.0%)</option>
|
||||
<option value="4.0">Brutal Peeps +10 (+4.0%)</option>
|
||||
<option value="5.0">Brutal Peeps +11 (+5.0%)</option>
|
||||
</select>
|
||||
<p class="drops-field-note">Applies to the Rate column for V2 PC and BB/V4.</p>
|
||||
</div>
|
||||
|
||||
<label for="drops-rare-mode">Mode</label>
|
||||
<select id="drops-rare-mode">
|
||||
<option value="">All modes</option>
|
||||
</select>
|
||||
|
||||
<label for="drops-episode">Episode</label>
|
||||
<select id="drops-episode">
|
||||
<option value="">All episodes</option>
|
||||
</select>
|
||||
|
||||
<label for="drops-difficulty">Difficulty</label>
|
||||
<select id="drops-difficulty">
|
||||
<option value="">All difficulties</option>
|
||||
</select>
|
||||
|
||||
<label for="drops-section">Section ID</label>
|
||||
<select id="drops-section">
|
||||
<option value="">All Section IDs</option>
|
||||
</select>
|
||||
|
||||
<label for="drops-search">Search</label>
|
||||
<input id="drops-search" type="search" placeholder="Enemy, box, item, code...">
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card placeholder-results-card">
|
||||
<div class="blank-data-box" id="drops-placeholder">Drop table placeholder</div>
|
||||
<div class="blank-data-box drops-box" id="drops-placeholder">Drop table placeholder</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -88,6 +127,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="placeholder-pages.js?v=basic-pages-fixed-1" defer></script>
|
||||
<script src="drop-tables.js?v=drops-peeps-table-viewer-20260613-12" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"label": "BB",
|
||||
"mode": "bestiary",
|
||||
"tables": [
|
||||
{
|
||||
"label": "BB",
|
||||
"path": "bb.json",
|
||||
"rows": 3216,
|
||||
"version": "bb"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"label": "Hardcore",
|
||||
"mode": "hardcore",
|
||||
"tables": [
|
||||
{
|
||||
"label": "BB",
|
||||
"path": "bb.json",
|
||||
"rows": 7200,
|
||||
"version": "bb"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"label": "Peeps",
|
||||
"mode": "peeps",
|
||||
"tables": [
|
||||
{
|
||||
"label": "V1",
|
||||
"path": "v1.json",
|
||||
"rows": 5328,
|
||||
"version": "v1"
|
||||
},
|
||||
{
|
||||
"label": "V2",
|
||||
"path": "v2.json",
|
||||
"rows": 7872,
|
||||
"version": "v2"
|
||||
},
|
||||
{
|
||||
"label": "V3",
|
||||
"path": "v3.json",
|
||||
"rows": 5636,
|
||||
"version": "v3"
|
||||
},
|
||||
{
|
||||
"label": "BB",
|
||||
"path": "bb.json",
|
||||
"rows": 7200,
|
||||
"version": "bb"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Guide - PSO Peeps</title>
|
||||
<meta name="description" content="PSO Peeps connection and gameplay guides.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=local-guide-20260614-7">
|
||||
<script src="app.js?v=saves-synced-20260609-2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
<header class="site-header site-header--accountline" aria-label="Site header">
|
||||
<a class="brand" href="index.html" aria-label="PSO Peeps home">
|
||||
<span class="brand-logo brand-logo--text" aria-hidden="true">P</span>
|
||||
<span class="brand-name">PSO PEEPS</span>
|
||||
</a>
|
||||
<div class="top-account-status"><span class="status-dot" aria-hidden="true"></span><a href="account-ready.html">Signed in as chuudoku</a></div>
|
||||
</header>
|
||||
|
||||
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork">
|
||||
<div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div>
|
||||
</section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="guide.html">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
</nav>
|
||||
|
||||
<main class="placeholder-layout guide-layout">
|
||||
<section class="card placeholder-control-card guide-control-card">
|
||||
<h1 class="section-title">Guide</h1>
|
||||
|
||||
<p class="guide-intro">
|
||||
Use this guide to connect to PSO Peeps, learn the normal Peeps setup, or review Hardcore Peeps rules and progression.
|
||||
Both ships are available below.
|
||||
</p>
|
||||
|
||||
<div class="guide-server-grid" aria-label="Server addresses">
|
||||
<div class="guide-server-card">
|
||||
<span>US Server</span>
|
||||
<strong>108.175.11.140</strong>
|
||||
</div>
|
||||
<div class="guide-server-card">
|
||||
<span>EU Server</span>
|
||||
<strong>65.21.79.231</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="placeholder-form guide-controls">
|
||||
<div class="guide-control">
|
||||
<label for="guide-level-0">Guide</label>
|
||||
<select id="guide-level-0"></select>
|
||||
</div>
|
||||
|
||||
<div class="guide-control" id="guide-level-1-wrap" hidden>
|
||||
<label for="guide-level-1">Section</label>
|
||||
<select id="guide-level-1"></select>
|
||||
</div>
|
||||
|
||||
<div class="guide-control" id="guide-level-2-wrap" hidden>
|
||||
<label for="guide-level-2">Platform</label>
|
||||
<select id="guide-level-2"></select>
|
||||
</div>
|
||||
|
||||
<div class="guide-control" id="guide-level-3-wrap" hidden>
|
||||
<label for="guide-level-3">System</label>
|
||||
<select id="guide-level-3"></select>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card placeholder-results-card">
|
||||
<div class="blank-data-box guide-box" id="guide-content">Guide content will load here.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer-bar">
|
||||
<div class="social-links" aria-label="Social links">
|
||||
<a href="#" aria-label="Discord">
|
||||
<img src="icons/discord.png" alt="" width="24" height="24">
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
<a href="#" aria-label="Mastodon">
|
||||
<img src="icons/mastodon.png" alt="" width="24" height="24">
|
||||
<span>Mastodon</span>
|
||||
</a>
|
||||
<a href="#" aria-label="Bluesky">
|
||||
<img src="icons/bluesky.png" alt="" width="24" height="24">
|
||||
<span>Bluesky</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer-legal" aria-label="Site license and software credits">
|
||||
<p>
|
||||
All content on this website is licensed under
|
||||
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
|
||||
</p>
|
||||
<p>
|
||||
PSO Peeps uses a modified version of
|
||||
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
|
||||
from fuzziqersoftware.
|
||||
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="guide.js?v=local-guide-20260614-7" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const SERVER_INFO = {
|
||||
us: "108.175.11.140",
|
||||
eu: "65.21.79.231",
|
||||
};
|
||||
|
||||
const GUIDE_TREE = {
|
||||
connection: {
|
||||
label: "Connection Guide",
|
||||
children: {
|
||||
overview: {
|
||||
label: "Overview",
|
||||
doc: "docs/guide/connection/overview.md",
|
||||
},
|
||||
dreamcast: {
|
||||
label: "Dreamcast",
|
||||
children: {
|
||||
hardware: {
|
||||
label: "Hardware",
|
||||
doc: "docs/guide/connection/dreamcast/hardware.md",
|
||||
},
|
||||
flycastDialup: {
|
||||
label: "Flycast (Dialup)",
|
||||
doc: "docs/guide/connection/dreamcast/flycast-dialup.md",
|
||||
},
|
||||
flycastBba: {
|
||||
label: "Flycast (BBA)",
|
||||
doc: "docs/guide/connection/dreamcast/flycast-bba.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
pc: {
|
||||
label: "PC",
|
||||
children: {
|
||||
pc: {
|
||||
label: "PC",
|
||||
children: {
|
||||
windows: {
|
||||
label: "Windows",
|
||||
doc: "docs/guide/connection/pc/pc-windows.md",
|
||||
},
|
||||
linux: {
|
||||
label: "Linux",
|
||||
doc: "docs/guide/connection/pc/pc-linux.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
blueBurst: {
|
||||
label: "Blue Burst",
|
||||
children: {
|
||||
windows: {
|
||||
label: "Windows",
|
||||
doc: "docs/guide/connection/pc/blue-burst-windows.md",
|
||||
},
|
||||
linux: {
|
||||
label: "Linux",
|
||||
doc: "docs/guide/connection/pc/blue-burst-linux.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
gamecube: {
|
||||
label: "GameCube",
|
||||
children: {
|
||||
hardwareBba: {
|
||||
label: "Hardware (BBA)",
|
||||
doc: "docs/guide/connection/gamecube/hardware-bba.md",
|
||||
},
|
||||
dolphin: {
|
||||
label: "Dolphin",
|
||||
doc: "docs/guide/connection/gamecube/dolphin.md",
|
||||
},
|
||||
nintendont: {
|
||||
label: "Nintendont",
|
||||
doc: "docs/guide/connection/gamecube/nintendont.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
xbox: {
|
||||
label: "Xbox",
|
||||
doc: "docs/guide/connection/xbox.md",
|
||||
},
|
||||
psp: {
|
||||
label: "Phantasy Star Portable",
|
||||
doc: "docs/guide/connection/phantasy-star-portable.md",
|
||||
},
|
||||
serverSideSaves: {
|
||||
label: "Server-side saves",
|
||||
doc: "docs/guide/connection/server-side-saves.md",
|
||||
},
|
||||
commonProblems: {
|
||||
label: "Common problems",
|
||||
doc: "docs/guide/connection/common-problems.md",
|
||||
},
|
||||
quickReference: {
|
||||
label: "Quick reference",
|
||||
doc: "docs/guide/connection/quick-reference.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
peeps: {
|
||||
label: "Peeps Guide",
|
||||
children: {
|
||||
gettingStarted: {
|
||||
label: "Getting Started",
|
||||
doc: "docs/guide/peeps/getting-started.md",
|
||||
},
|
||||
crossplayRooms: {
|
||||
label: "Crossplay Rooms",
|
||||
doc: "docs/guide/peeps/crossplay-rooms.md",
|
||||
},
|
||||
xpPeriods: {
|
||||
label: "XP Periods",
|
||||
doc: "docs/guide/peeps/xp-periods.md",
|
||||
},
|
||||
brutalPeeps: {
|
||||
label: "Brutal Peeps",
|
||||
doc: "docs/guide/peeps/brutal-peeps.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
hardcore: {
|
||||
label: "Hardcore Peeps Guide",
|
||||
children: {
|
||||
gettingStarted: {
|
||||
label: "Getting Started",
|
||||
doc: "docs/guide/hardcore/getting-started.md",
|
||||
},
|
||||
progression: {
|
||||
label: "Progression",
|
||||
doc: "docs/guide/hardcore/progression.md",
|
||||
},
|
||||
mesetaAndBankLimits: {
|
||||
label: "Meseta and Bank Limits",
|
||||
doc: "docs/guide/hardcore/meseta-and-bank-limits.md",
|
||||
},
|
||||
pointsSystem: {
|
||||
label: "Points System",
|
||||
doc: "docs/guide/hardcore/points-system.md",
|
||||
},
|
||||
brutalPeeps: {
|
||||
label: "Brutal Peeps",
|
||||
doc: "docs/guide/hardcore/brutal-peeps.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const qs = (sel) => document.querySelector(sel);
|
||||
const selects = [
|
||||
qs("#guide-level-0"),
|
||||
qs("#guide-level-1"),
|
||||
qs("#guide-level-2"),
|
||||
qs("#guide-level-3"),
|
||||
];
|
||||
const wrappers = [
|
||||
null,
|
||||
qs("#guide-level-1-wrap"),
|
||||
qs("#guide-level-2-wrap"),
|
||||
qs("#guide-level-3-wrap"),
|
||||
];
|
||||
const labels = [
|
||||
null,
|
||||
qs('label[for="guide-level-1"]'),
|
||||
qs('label[for="guide-level-2"]'),
|
||||
qs('label[for="guide-level-3"]'),
|
||||
];
|
||||
const box = qs("#guide-content");
|
||||
|
||||
let pendingPath = null;
|
||||
let loadingPath = "";
|
||||
const mdCache = new Map();
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function nodeChildren(node) {
|
||||
return node && node.children ? Object.entries(node.children) : [];
|
||||
}
|
||||
|
||||
function fillSelect(select, children, preferredValue) {
|
||||
if (!select) return "";
|
||||
|
||||
const entries = Object.entries(children || {});
|
||||
const previous = preferredValue || select.value;
|
||||
select.innerHTML = "";
|
||||
|
||||
for (const [key, child] of entries) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = key;
|
||||
opt.textContent = child.label;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
if (entries.some(([key]) => key === previous)) {
|
||||
select.value = previous;
|
||||
} else if (entries.length) {
|
||||
select.value = entries[0][0];
|
||||
}
|
||||
|
||||
return select.value;
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
if (!box) return;
|
||||
box.innerHTML = `<div class="guide-status ${kind ? `guide-status--${esc(kind)}` : ""}">${esc(message)}</div>`;
|
||||
}
|
||||
|
||||
function pathLabels(path) {
|
||||
let node = { children: GUIDE_TREE };
|
||||
const out = [];
|
||||
|
||||
for (const key of path) {
|
||||
const next = node.children?.[key];
|
||||
if (!next) break;
|
||||
out.push(next.label);
|
||||
node = next;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeHref(raw) {
|
||||
const href = String(raw || "").trim();
|
||||
if (/^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i.test(href)) return href;
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(href)) return href;
|
||||
return "#";
|
||||
}
|
||||
|
||||
function inlineMarkdown(raw) {
|
||||
const codeSpans = [];
|
||||
let text = String(raw ?? "").replace(/`([^`]+)`/g, (_, code) => {
|
||||
const token = `@@CODE${codeSpans.length}@@`;
|
||||
codeSpans.push(`<code>${esc(code)}</code>`);
|
||||
return token;
|
||||
});
|
||||
|
||||
let html = esc(text);
|
||||
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
|
||||
return `<a href="${esc(safeHref(href))}">${label}</a>`;
|
||||
});
|
||||
|
||||
html = html
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
|
||||
codeSpans.forEach((span, index) => {
|
||||
html = html.replaceAll(`@@CODE${index}@@`, span);
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown) {
|
||||
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
|
||||
const out = [];
|
||||
let paragraph = [];
|
||||
let list = [];
|
||||
let orderedList = [];
|
||||
let code = [];
|
||||
let inFence = false;
|
||||
const fence = String.fromCharCode(96, 96, 96);
|
||||
|
||||
function flushParagraph() {
|
||||
if (!paragraph.length) return;
|
||||
out.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
|
||||
function flushList() {
|
||||
if (!list.length) return;
|
||||
out.push(`<ul>${list.map((item) => `<li>${inlineMarkdown(item)}</li>`).join("")}</ul>`);
|
||||
list = [];
|
||||
}
|
||||
|
||||
function flushOrderedList() {
|
||||
if (!orderedList.length) return;
|
||||
out.push(`<ol>${orderedList.map((item) => `<li>${inlineMarkdown(item)}</li>`).join("")}</ol>`);
|
||||
orderedList = [];
|
||||
}
|
||||
|
||||
function flushCode() {
|
||||
if (!code.length) return;
|
||||
out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
|
||||
code = [];
|
||||
}
|
||||
|
||||
function flushTextBlocks() {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
flushOrderedList();
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith(fence)) {
|
||||
flushTextBlocks();
|
||||
if (inFence) {
|
||||
flushCode();
|
||||
inFence = false;
|
||||
} else {
|
||||
inFence = true;
|
||||
code = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
code.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ {4,}/.test(line)) {
|
||||
flushTextBlocks();
|
||||
code.push(line.replace(/^ {4}/, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code.length && trimmed === "") {
|
||||
flushCode();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code.length) {
|
||||
flushCode();
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
flushTextBlocks();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||
flushTextBlocks();
|
||||
out.push("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = trimmed.match(/^(#{1,5})\s+(.+)$/);
|
||||
if (heading) {
|
||||
flushTextBlocks();
|
||||
const level = Math.min(heading[1].length + 1, 6);
|
||||
out.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = line.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (bullet) {
|
||||
flushParagraph();
|
||||
flushOrderedList();
|
||||
list.push(bullet[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (ordered) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
orderedList.push(ordered[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
|
||||
flushTextBlocks();
|
||||
flushCode();
|
||||
|
||||
return `<div class="guide-doc">${out.join("\n")}</div>`;
|
||||
}
|
||||
|
||||
async function loadDoc(node, path) {
|
||||
if (!node.doc) {
|
||||
setStatus("Choose a guide page from the menus above.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pathKey = path.join("/");
|
||||
loadingPath = pathKey;
|
||||
setStatus(`Loading ${pathLabels(path).join(" › ")}...`);
|
||||
|
||||
try {
|
||||
let markdown = mdCache.get(node.doc);
|
||||
if (!markdown) {
|
||||
const response = await fetch(node.doc, { cache: "no-cache" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load ${node.doc} (${response.status})`);
|
||||
}
|
||||
markdown = await response.text();
|
||||
mdCache.set(node.doc, markdown);
|
||||
}
|
||||
|
||||
if (loadingPath !== pathKey) return;
|
||||
|
||||
const breadcrumb = pathLabels(path).join(" › ");
|
||||
box.innerHTML = `
|
||||
<div class="guide-breadcrumb">${esc(breadcrumb)}</div>
|
||||
${renderMarkdown(markdown)}
|
||||
`;
|
||||
} catch (err) {
|
||||
if (loadingPath !== pathKey) return;
|
||||
setStatus(err?.message || "Unable to load guide page.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function updateHash(path) {
|
||||
const next = `#${path.join("/")}`;
|
||||
if (window.location.hash !== next) {
|
||||
history.replaceState(null, "", next);
|
||||
}
|
||||
}
|
||||
|
||||
function readHashPath() {
|
||||
const raw = window.location.hash.replace(/^#/, "").trim();
|
||||
if (!raw) return null;
|
||||
return raw.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function syncGuide(changedLevel = -1) {
|
||||
if (!box || selects.some((select) => !select)) return;
|
||||
|
||||
if (changedLevel >= 0) {
|
||||
for (let i = changedLevel + 1; i < selects.length; i += 1) {
|
||||
selects[i].value = "";
|
||||
}
|
||||
}
|
||||
|
||||
let node = { children: GUIDE_TREE };
|
||||
const path = [];
|
||||
|
||||
for (let level = 0; level < selects.length; level += 1) {
|
||||
const select = selects[level];
|
||||
|
||||
if (!node.children) {
|
||||
for (let hideLevel = level; hideLevel < wrappers.length; hideLevel += 1) {
|
||||
if (wrappers[hideLevel]) wrappers[hideLevel].hidden = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const preferred = pendingPath?.[level] || select.value;
|
||||
const selected = fillSelect(select, node.children, preferred);
|
||||
|
||||
if (wrappers[level]) wrappers[level].hidden = false;
|
||||
|
||||
const current = node.children[selected];
|
||||
path.push(selected);
|
||||
node = current;
|
||||
|
||||
if (labels[level]) {
|
||||
labels[level].textContent =
|
||||
level === 1 && path[0] === "connection" ? "Platform" :
|
||||
level === 1 ? "Topic" :
|
||||
level === 2 && path[0] === "connection" && path[1] === "pc" ? "Client" :
|
||||
level === 2 && path[0] === "connection" ? "Setup" :
|
||||
level === 3 ? "System" :
|
||||
"Option";
|
||||
}
|
||||
}
|
||||
|
||||
pendingPath = null;
|
||||
|
||||
for (let level = 1; level < wrappers.length; level += 1) {
|
||||
const parent = path.slice(0, level).reduce((cur, key) => cur?.children?.[key], { children: GUIDE_TREE });
|
||||
if (!parent?.children && wrappers[level]) wrappers[level].hidden = true;
|
||||
}
|
||||
|
||||
updateHash(path);
|
||||
loadDoc(node, path);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
pendingPath = readHashPath();
|
||||
|
||||
selects.forEach((select, index) => {
|
||||
select?.addEventListener("change", () => syncGuide(index));
|
||||
});
|
||||
|
||||
syncGuide();
|
||||
});
|
||||
})();
|
||||
@@ -65,7 +65,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const top = rows.slice(0, 5);
|
||||
const top = rows.slice(0, 10);
|
||||
|
||||
if (!top.length) {
|
||||
list.innerHTML = `<li><span class="rank">1.</span><span>—</span></li>`;
|
||||
|
||||
+46
-38
@@ -8,8 +8,9 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=home-hardcore-preline-20260610-1">
|
||||
<link rel="stylesheet" href="style.css?v=home-status-two-boxes-2">
|
||||
<script src="app.js?v=account-status-label-20260609" defer></script>
|
||||
<script src="server-status.js?v=home-status-prometheus-2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
@@ -36,7 +37,7 @@
|
||||
</section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a>
|
||||
<a href="guide.html">Connection Guide</a>
|
||||
<a href="leaderboards.html">Leaderboards</a>
|
||||
<a href="drops.html">Drops</a>
|
||||
<a href="bestiary.html">Bestiary</a>
|
||||
@@ -52,30 +53,48 @@
|
||||
|
||||
<h1 id="server-status-heading" class="section-title">Server Status</h1>
|
||||
|
||||
<div class="status-list" role="list" aria-label="Current server player counts">
|
||||
<div class="status-row status-parent" role="listitem"><span>US Server</span><span></span></div>
|
||||
<div class="status-list status-list--regions" role="list" aria-label="Current server player counts" data-server-status="loading">
|
||||
<div class="status-region" role="group" aria-label="US Server player counts">
|
||||
<div class="status-region-header">
|
||||
<span>US Server</span><span>Loading…</span>
|
||||
</div>
|
||||
<div class="status-ship">
|
||||
<div class="status-ship-name">Alis</div>
|
||||
<div class="status-row"><span>V2</span><span>—</span></div>
|
||||
<div class="status-row"><span>V3</span><span>—</span></div>
|
||||
<div class="status-row"><span>BB</span><span>—</span></div>
|
||||
</div>
|
||||
<div class="status-ship">
|
||||
<div class="status-ship-name">Abion</div>
|
||||
<div class="status-row"><span>HC/BB</span><span>—</span></div>
|
||||
</div>
|
||||
<div class="status-ship">
|
||||
<div class="status-ship-name">AdHoc-US</div>
|
||||
<div class="status-row"><span>PSP1</span><span>—</span></div>
|
||||
<div class="status-row"><span>PSP2i</span><span>—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-row status-parent" role="listitem"><span>Alis</span><span>0 Players</span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>V2</span><span>0 Players</span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>V3</span><span>0 Players</span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>BB</span><span>0 Players</span></div>
|
||||
|
||||
<div class="status-row status-parent" role="listitem"><span>Abion</span><span></span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>HC</span><span>0 Players</span></div>
|
||||
|
||||
<div class="status-row status-parent" role="listitem"><span>EU Server</span><span></span></div>
|
||||
|
||||
<div class="status-row status-parent" role="listitem"><span>Palma</span><span>0 Players</span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>V2</span><span>0 Players</span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>V3</span><span>0 Players</span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>BB</span><span>0 Players</span></div>
|
||||
|
||||
<div class="status-row status-parent" role="listitem"><span>Aiedo</span><span></span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>HC</span><span>0 Players</span></div>
|
||||
|
||||
<div class="status-row status-parent" role="listitem"><span>PSP Ship</span><span></span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>PSP1</span><span>0 Players</span></div>
|
||||
<div class="status-row status-child" role="listitem"><span>PSP2i</span><span>0 Players</span></div>
|
||||
<div class="status-region" role="group" aria-label="EU Server player counts">
|
||||
<div class="status-region-header">
|
||||
<span>EU Server</span><span>Loading…</span>
|
||||
</div>
|
||||
<div class="status-ship">
|
||||
<div class="status-ship-name">Palma</div>
|
||||
<div class="status-row"><span>V2</span><span>—</span></div>
|
||||
<div class="status-row"><span>V3</span><span>—</span></div>
|
||||
<div class="status-row"><span>BB</span><span>—</span></div>
|
||||
</div>
|
||||
<div class="status-ship">
|
||||
<div class="status-ship-name">Aiedo</div>
|
||||
<div class="status-row"><span>HC/BB</span><span>—</span></div>
|
||||
</div>
|
||||
<div class="status-ship">
|
||||
<div class="status-ship-name">AdHoc-EU</div>
|
||||
<div class="status-row"><span>PSP1</span><span>—</span></div>
|
||||
<div class="status-row"><span>PSP2i</span><span>—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -90,22 +109,11 @@
|
||||
|
||||
<section class="card leaderboard-card" aria-labelledby="hardcore-heading">
|
||||
<h2 id="hardcore-heading" class="section-title">Hardcore Leaderboard</h2>
|
||||
<ol class="leaderboard-list leaderboard-list--home-hardcore" id="home-hardcore-leaderboard-body" aria-label="Top five hardcore players">
|
||||
<ol class="leaderboard-list leaderboard-list--home-hardcore" id="home-hardcore-leaderboard-body" aria-label="Top ten hardcore players">
|
||||
<li><span class="rank">1.</span><span>Loading...</span></li>
|
||||
</ol>
|
||||
<a class="small-link home-leaderboard-more" href="leaderboards.html">more</a>
|
||||
</section>
|
||||
|
||||
<section class="card leaderboard-card" aria-labelledby="crank-heading">
|
||||
<h2 id="crank-heading" class="section-title">C Rank Points</h2>
|
||||
<ol class="leaderboard-list" aria-label="Top five C Rank point totals">
|
||||
<li><span class="rank">1.</span><span>—</span></li>
|
||||
<li><span class="rank">2.</span><span>—</span></li>
|
||||
<li><span class="rank">3.</span><span>—</span></li>
|
||||
<li><span class="rank">4.</span><span>—</span></li>
|
||||
<li><span class="rank">5.</span><span>—</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
@@ -139,6 +147,6 @@
|
||||
</footer>
|
||||
</div>
|
||||
<script src="hero-cycle.js?v=force-cycle-3" defer></script>
|
||||
<script src="home-leaderboard.js?v=home-hardcore-preline-20260610-1" defer></script>
|
||||
<script src="home-leaderboard.js?v=home-hardcore-top10-1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=leaderboard-table-restore-20260610-1">
|
||||
<link rel="stylesheet" href="style.css?v=leaderboard-level-column-20260613-1">
|
||||
<script src="app.js?v=saves-synced-20260609-2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -24,7 +24,7 @@
|
||||
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
|
||||
|
||||
<nav class="nav-bar" aria-label="Primary navigation">
|
||||
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
<a href="guide.html">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
|
||||
</nav>
|
||||
|
||||
<main class="placeholder-layout">
|
||||
@@ -83,6 +83,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="placeholder-pages.js?v=hardcore-leaderboard-table-restore-20260610-1" defer></script>
|
||||
<script src="placeholder-pages.js?v=hardcore-leaderboard-level-column-20260613-1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
{ key: "rank", label: "Rank", numeric: true },
|
||||
{ key: "name", label: "Player Name" },
|
||||
{ key: "points", label: "Points", numeric: true },
|
||||
{ key: "level", label: "Level", numeric: true },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "class", label: "Class" },
|
||||
{ key: "secid", label: "SecID" },
|
||||
@@ -61,6 +62,7 @@
|
||||
originalRank: index + 1,
|
||||
name: row.PlayerName || row.CharacterName || row.character_name || "",
|
||||
points: Number(row.Points ?? row.TotalPoints ?? 0),
|
||||
level: Number(row.Level ?? row.level ?? 0),
|
||||
class: row.Class || row.character_class || "",
|
||||
secid: row.SecID || row.section_id || "",
|
||||
kills: Number(row.Kills ?? row.TotalKills ?? row.total_enemies_killed ?? 0),
|
||||
@@ -168,6 +170,7 @@
|
||||
<td data-label="Rank">${rank}</td>
|
||||
<td data-label="Player Name">${escapeHtml(row.name)}</td>
|
||||
<td data-label="Points">${fmtNumber(row.points)}</td>
|
||||
<td data-label="Level">${fmtNumber(row.level)}</td>
|
||||
<td data-label="Status">${escapeHtml(row.status)}</td>
|
||||
<td data-label="Class">${escapeHtml(row.class || "—")}</td>
|
||||
<td data-label="SecID">${escapeHtml(row.secid || "—")}</td>
|
||||
@@ -180,7 +183,7 @@
|
||||
<div class="leaderboard-table-wrap">
|
||||
<table class="leaderboard-table">
|
||||
<thead><tr>${head}</tr></thead>
|
||||
<tbody>${body || `<tr><td colspan="8">No Hardcore leaderboard rows yet.</td></tr>`}</tbody>
|
||||
<tbody>${body || `<tr><td colspan="9">No Hardcore leaderboard rows yet.</td></tr>`}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="leaderboard-pager">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
(() => {
|
||||
const list = document.querySelector(".status-list");
|
||||
if (!list) return;
|
||||
|
||||
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
|
||||
const players = (value) => {
|
||||
const n = Number(value || 0);
|
||||
return `${n.toLocaleString()} ${n === 1 ? "Player" : "Players"}`;
|
||||
};
|
||||
|
||||
const render = (data) => {
|
||||
const servers = Array.isArray(data?.servers) ? data.servers : [];
|
||||
if (!servers.length) return;
|
||||
|
||||
list.classList.add("status-list--regions");
|
||||
list.innerHTML = servers.map((server) => `
|
||||
<div class="status-region" role="group" aria-label="${esc(server.label)} player counts">
|
||||
<div class="status-row status-parent status-server" role="listitem">
|
||||
<span>${esc(server.label)}</span><span>${players(server.players)}</span>
|
||||
</div>
|
||||
${(server.ships || []).map((ship) => `
|
||||
<div class="status-row status-parent status-ship" role="listitem">
|
||||
<span>${esc(ship.label)}</span><span></span>
|
||||
</div>
|
||||
${(ship.rows || []).map((row) => `
|
||||
<div class="status-row status-child" role="listitem">
|
||||
<span>${esc(row.label)}</span><span>${players(row.players)}</span>
|
||||
</div>
|
||||
`).join("")}
|
||||
`).join("")}
|
||||
</div>
|
||||
`).join("");
|
||||
};
|
||||
|
||||
const loadStatus = (url) => fetch(url, { cache: "no-store" })
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`status ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
|
||||
loadStatus(`/server-status.json?ts=${Date.now()}`)
|
||||
.catch(() => loadStatus("/api/server-status"))
|
||||
.then((data) => data && render(data))
|
||||
.catch(() => {});
|
||||
})();
|
||||
+364
-845
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user