encounter journal base logic

This commit is contained in:
2026-06-06 19:51:26 +04:00
parent 5dcb8be3aa
commit 02d382c0f8
16 changed files with 236610 additions and 7 deletions
+9 -1
View File
@@ -12,7 +12,15 @@
"Bash(awk '/ac-worldserver:/{flag=1} flag{print; if\\(/depends_on:/\\){flag=0}}' /home/sindo/moonwell-core/docker-compose.yml)",
"Bash(grep -n \"LoadAdditionalFile\\\\|\\\\.conf.dist\\\\|conf$\" /home/sindo/moonwell-core/src/common/Configuration/Config.cpp)",
"Bash(grep -n \"^$\\\\|ENCOUNTER\" /home/sindo/moonwell-core/conf/dist/env.ac)",
"Bash(grep -v \"^information_schema\\\\|^mysql$\\\\|^performance_schema\\\\|^sys$\")"
"Bash(grep -v \"^information_schema\\\\|^mysql$\\\\|^performance_schema\\\\|^sys$\")",
"Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/build_item_cache.py -j /home/sindo/moonwell-core/env/dist/logs/EncounterJournalData.json -d /home/sindo/moonwell-core/Generated_ItemsCache1.lua /home/sindo/moonwell-core/Generated_ItemsCache2.lua /home/sindo/moonwell-core/Generated_ItemsCache3.lua -o /tmp/EncounterJournalItemCache.lua)",
"Bash(awk 'NR>1 && !/^\\\\s*\\\\[[0-9]+\\\\]\\\\s*=\\\\s*\\\\{.*\\\\},?\\\\s*$/ && !/^\\\\}$/' /home/sindo/moonwell-core/Generated_ItemsCache3.lua)",
"Bash(awk '/^\\\\s*\\\\[[0-9]+\\\\]\\\\s*=\\\\s*\\\\{/' /home/sindo/moonwell-core/Generated_ItemsCache3.lua)",
"Bash(grep -n \"^[A-Z_]* = {$\\\\|^[A-Z_]* = {\" /home/sindo/moonwell-core/Generated_EncounterJournal.lua)",
"Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/import_donor_journal.py -i /home/sindo/moonwell-core/Generated_EncounterJournal.lua -o /tmp/donor_import.sql)",
"Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/import_donor_journal.py -i /home/sindo/moonwell-core/Generated_EncounterJournal.lua -o /tmp/donor_import_filtered.sql --known-maps /tmp/known_maps.txt --known-creatures /tmp/known_creatures.txt --known-items /tmp/known_items.txt)",
"Bash(sudo grep -c \"Encounter Journal loaded\\\\|mod-encounter-journal\" /var/lib/docker/containers/8a3d7a23f170597b5a8eae754ef3259ac6f2af352d78958707ef5cf3010133e3/8a3d7a23f170597b5a8eae754ef3259ac6f2af352d78958707ef5cf3010133e3-json.log)",
"Bash(xargs -I {} stat -c '%y %n' {})"
]
}
}
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
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -85,11 +85,15 @@ console.
| Command | What it does |
| ---------------- | ---------------------------------------------------------------------------- |
| `.ej info` | Print row counts of the in-memory cache. |
| `.ej reload` | Re-read all `custom_ej_*` tables. |
| `.ej validate` | Run integrity checks (see "Validation" below). |
| `.ej importloot` | Auto-populate `custom_ej_loot` from `creature_loot_template` (skips dupes). |
| `.ej export` | Write `EncounterJournalData.json` (and `.lua`) to the configured path. |
| Command | What it does |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| `.ej info` | Print row counts of the in-memory cache. |
| `.ej reload` | Re-read all `custom_ej_*` tables. |
| `.ej validate` | Run integrity checks (see "Validation" below). |
| `.ej importskeleton` | Generate instance/encounter/creature/section rows from `instance_template` + `instance_encounters`. |
| `.ej importloot` | Auto-populate `custom_ej_loot` from `creature_loot_template` (skips dupes). |
| `.ej importall` | Run `importskeleton` then `importloot` back-to-back. |
| `.ej export` | Write `EncounterJournalData.json` (and `.lua`) to the configured path. |
## Configuration
@@ -1199,7 +1199,9 @@ namespace MoonWell::EncounterJournal
CreatureTemplate const* ct = sObjectMgr->GetCreatureTemplate(creature);
std::string crName = (ct && !ct->Name.empty()) ? ct->Name : cmt;
uint32 displayId = ct ? ct->Modelid1 : 0;
uint32 displayId = 0;
if (ct && !ct->Models.empty())
displayId = ct->Models[0].CreatureDisplayID;
std::string crEsc;
crEsc.reserve(crName.size());
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Build a trimmed item cache for the MoonWellClient EncounterJournal addon.
Reads one or more donor cache files (Sirus-style Generated_ItemsCache*.lua,
each holding rows like `[id] = { "name_en", "name_ru", quality, ..., "icon",
... }`), intersects them with the item ids referenced by
EncounterJournalData.json (the file produced by .ej export) and emits a
Lua file containing only the matching rows.
Output schema (single global, Lua):
MoonWellEncounterJournalItemCache = {
[<item_id>] = { name_en, name_ru, quality, icon },
...
}
Multiple donor files are merged left-to-right; later files override earlier
ones for duplicate ids. Items missing from every donor are reported on
stderr and skipped (the client falls back to GetItemInfo for those).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, Iterable, Set, Tuple
# Row layout per Sirus convention:
# [id] = { "name_en", "name_ru", quality, classId, subClass,
# inventoryType, slot, stackable, idx, "icon", spellId },
ROW_RE = re.compile(
r"""^\s*\[(?P<id>\d+)\]\s*=\s*\{\s*
"(?P<en>(?:[^"\\]|\\.)*)"\s*,\s*
"(?P<ru>(?:[^"\\]|\\.)*)"\s*,\s*
(?P<quality>-?\d+)\s*,\s*
-?\d+\s*,\s* # field 4
-?\d+\s*,\s* # field 5
-?\d+\s*,\s* # field 6
-?\d+\s*,\s* # field 7
-?\d+\s*,\s* # field 8
-?\d+\s*,\s* # field 9
"(?P<icon>(?:[^"\\]|\\.)*)"\s*,\s*
-?\d+\s*
\}\s*,?\s*$""",
re.VERBOSE,
)
def parse_donor(path: Path) -> Dict[int, Tuple[str, str, int, str]]:
"""Returns {id: (name_en, name_ru, quality, icon)} from one donor file."""
out: Dict[int, Tuple[str, str, int, str]] = {}
bad = 0
with path.open("r", encoding="utf-8") as f:
for line in f:
m = ROW_RE.match(line)
if not m:
stripped = line.strip()
if stripped and not stripped.startswith(
("--", "Items", "ItemsCache", "}", "{")
):
bad += 1
continue
out[int(m.group("id"))] = (
m.group("en"),
m.group("ru"),
int(m.group("quality")),
m.group("icon"),
)
if bad:
print(f"[{path.name}] skipped {bad} unparseable lines", file=sys.stderr)
return out
def collect_needed_ids(journal_json: Path) -> Set[int]:
"""Returns the set of item ids referenced in EncounterJournalData.json."""
with journal_json.open("r", encoding="utf-8") as f:
data = json.load(f)
needed: Set[int] = set()
for rows in data.get("items", {}).values():
for row in rows:
# items[encounterID] row layout per docs/encounter_journal_format.md:
# [item_id, encounter_id, difficulty_mask, faction_mask, flags,
# id, class_mask]
if row:
needed.add(int(row[0]))
return needed
def lua_str(s: str) -> str:
s = s.replace("\\", "\\\\").replace("'", "\\'")
s = s.replace("\n", "\\n").replace("\r", "\\r")
return f"'{s}'"
def write_lua(out_path: Path, global_name: str,
cache: Dict[int, Tuple[str, str, int, str]]) -> None:
with out_path.open("w", encoding="utf-8") as f:
f.write("-- Generated by mod-encounter-journal/tools/"
"build_item_cache.py.\n")
f.write("-- Trimmed donor item cache for items referenced by "
"EncounterJournalData.\n\n")
f.write(f"{global_name} = {{\n")
for item_id in sorted(cache.keys()):
en, ru, quality, icon = cache[item_id]
f.write(
f" [{item_id}] = {{ {lua_str(en)}, {lua_str(ru)}, "
f"{quality}, {lua_str(icon)} }},\n"
)
f.write("}\n")
def merge_donors(donor_paths: Iterable[Path]
) -> Dict[int, Tuple[str, str, int, str]]:
merged: Dict[int, Tuple[str, str, int, str]] = {}
for p in donor_paths:
rows = parse_donor(p)
print(f"[{p.name}] parsed {len(rows)} rows", file=sys.stderr)
merged.update(rows)
return merged
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--journal", "-j", required=True, type=Path,
help="EncounterJournalData.json (from .ej export)")
parser.add_argument("--donor", "-d", required=True, type=Path, nargs="+",
help="One or more Generated_ItemsCache*.lua files")
parser.add_argument("--output", "-o", required=True, type=Path,
help="Where to write EncounterJournalItemCache.lua")
parser.add_argument("--global", dest="global_name",
default="MoonWellEncounterJournalItemCache",
help="Lua global name (default: %(default)s)")
args = parser.parse_args()
needed = collect_needed_ids(args.journal)
print(f"EJ references {len(needed)} unique item ids", file=sys.stderr)
donors = merge_donors(args.donor)
print(f"donor cache holds {len(donors)} unique item ids", file=sys.stderr)
trimmed = {i: donors[i] for i in needed if i in donors}
missing = sorted(needed - donors.keys())
write_lua(args.output, args.global_name, trimmed)
print(
f"wrote {len(trimmed)} rows to {args.output} "
f"(missing in donor: {len(missing)})",
file=sys.stderr,
)
if missing:
sample = ", ".join(str(i) for i in missing[:15])
more = "" if len(missing) <= 15 else f" ... and {len(missing) - 15} more"
print(f"missing item ids (client will fall back to GetItemInfo): "
f"{sample}{more}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""
Import a Sirus-style Generated_EncounterJournal.lua dump into the
mod-encounter-journal custom_ej_* tables.
The dump holds seven top-level Lua tables that map 1:1 to our schema:
JOURNALINSTANCE -> custom_ej_instance
JOURNALENCOUNTER -> custom_ej_encounter
JOURNALENCOUNTERCREATURE -> custom_ej_creature
JOURNALENCOUNTERSECTION -> custom_ej_section
JOURNALENCOUNTERITEM -> custom_ej_loot
JOURNALTIER -> custom_ej_tier
JOURNALTIERXINSTANCE -> custom_ej_tier_instance
The script emits a single .sql file (or writes to stdout) that uses
REPLACE INTO so re-running is safe. Pipe it into your MySQL container:
python3 import_donor_journal.py -i Generated_EncounterJournal.lua \
-o donor_import.sql
docker exec -i ac-database mysql -uroot -p$PASS acore_world \
< donor_import.sql
Each row from the dump goes through a small tokenizer that understands
quoted strings ("..."), long-bracket strings ([[...]]), numbers, and
nested braces. The dump only uses single-line strings, so no multi-line
handling is needed.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Any, Iterable, List, Optional, TextIO, Tuple
# ---------------------------------------------------------------------------
# Tokeniser / value parser
# ---------------------------------------------------------------------------
class LuaParseError(RuntimeError):
pass
def _skip_ws(src: str, pos: int) -> int:
while pos < len(src) and src[pos] in " \t\r\n,":
pos += 1
return pos
def _parse_quoted_string(src: str, pos: int) -> Tuple[str, int]:
assert src[pos] == '"'
pos += 1
out: List[str] = []
while pos < len(src):
c = src[pos]
if c == "\\" and pos + 1 < len(src):
nxt = src[pos + 1]
# Lua escapes: \", \', \\, \n, \r, \t — keep verbatim semantics.
out.append({
'"': '"', "'": "'", "\\": "\\",
"n": "\n", "r": "\r", "t": "\t",
}.get(nxt, nxt))
pos += 2
continue
if c == '"':
return "".join(out), pos + 1
out.append(c)
pos += 1
raise LuaParseError("unterminated quoted string")
def _parse_long_bracket_string(src: str, pos: int) -> Tuple[str, int]:
assert src.startswith("[[", pos)
pos += 2
end = src.find("]]", pos)
if end < 0:
raise LuaParseError("unterminated long-bracket string")
return src[pos:end], end + 2
_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
def _parse_number(src: str, pos: int) -> Tuple[float, int]:
m = _NUM_RE.match(src, pos)
if not m:
raise LuaParseError(f"expected number at {pos}: {src[pos:pos+20]!r}")
txt = m.group(0)
val: Any = float(txt) if ("." in txt or "e" in txt or "E" in txt) else int(txt)
return val, m.end()
def _parse_table(src: str, pos: int) -> Tuple[list, int]:
"""Parses `{ v1, v2, ... }` and returns the list of values."""
if src[pos] != "{":
raise LuaParseError(f"expected '{{' at {pos}")
pos += 1
out: list = []
while True:
pos = _skip_ws(src, pos)
if pos >= len(src):
raise LuaParseError("unterminated table literal")
if src[pos] == "}":
return out, pos + 1
value, pos = _parse_value(src, pos)
out.append(value)
def _parse_value(src: str, pos: int) -> Tuple[Any, int]:
pos = _skip_ws(src, pos)
c = src[pos]
if c == '"':
return _parse_quoted_string(src, pos)
if src.startswith("[[", pos):
return _parse_long_bracket_string(src, pos)
if c == "{":
return _parse_table(src, pos)
return _parse_number(src, pos)
# ---------------------------------------------------------------------------
# Top-level table extractor
# ---------------------------------------------------------------------------
def _find_top_level_tables(text: str) -> dict:
"""
Returns {table_name: dict_or_list}. Iterates the source once, splitting
at top-level `NAME = {` declarations and parsing the brace-balanced body.
"""
tables: dict = {}
pos = 0
pat = re.compile(r"^([A-Z_]+)\s*=\s*\{", re.MULTILINE)
while True:
m = pat.search(text, pos)
if not m:
break
name = m.group(1)
# Find brace-balanced end.
body_start = m.end() - 1 # position of '{'
depth = 0
i = body_start
in_str = None # '"' or '[' for long bracket
while i < len(text):
ch = text[i]
if in_str == '"':
if ch == "\\":
i += 2
continue
if ch == '"':
in_str = None
i += 1
continue
if in_str == "[":
if text.startswith("]]", i):
in_str = None
i += 2
continue
i += 1
continue
if ch == '"':
in_str = '"'
i += 1
continue
if text.startswith("[[", i):
in_str = "["
i += 2
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
body = text[body_start:i + 1]
tables[name] = _parse_top_level_body(name, body)
pos = i + 1
break
i += 1
else:
raise LuaParseError(f"unterminated table {name}")
return tables
def _parse_top_level_body(name: str, body: str) -> Any:
"""
Parses the contents of one top-level table, which can have either
keyed entries `[id] = { ... }` or sequential entries `{ ... }`.
Returns dict for keyed entries, list for sequential.
"""
assert body.startswith("{") and body.endswith("}")
inner = body[1:-1]
i = 0
keyed: dict = {}
seq: list = []
while True:
i = _skip_ws(inner, i)
if i >= len(inner):
break
if inner[i] == "[":
# `[key] = value`
j = inner.index("]", i)
key_val, _ = _parse_value(inner, i + 1)
i = j + 1
i = _skip_ws(inner, i)
if inner[i] != "=":
raise LuaParseError(f"{name}: expected '=' at {i}")
i = _skip_ws(inner, i + 1)
value, i = _parse_value(inner, i)
keyed[key_val] = value
else:
value, i = _parse_value(inner, i)
seq.append(value)
if keyed and seq:
raise LuaParseError(f"{name}: mix of keyed/sequential entries")
return keyed if keyed else seq
# ---------------------------------------------------------------------------
# SQL emission
# ---------------------------------------------------------------------------
def _sql_str(s: Any) -> str:
s = str(s)
return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
def _to_int(v: Any, default: int = 0) -> int:
if v is None:
return default
if isinstance(v, bool):
return int(v)
return int(v)
def _to_uint(v: Any) -> int:
"""Sirus uses -1 as 'all difficulties'; our schema is UNSIGNED → store 0."""
n = _to_int(v)
return n if n >= 0 else 0
def _to_signed(v: Any) -> int:
return _to_int(v)
def _to_float(v: Any) -> float:
if v is None:
return 0.0
return float(v)
def _load_id_set(path: Optional[Path]) -> Optional[set]:
"""Reads one id per line, returns a set of ints. None when path is None."""
if path is None:
return None
out: set = set()
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
try:
out.add(int(line))
except ValueError:
pass
return out
def emit(tables: dict, out: TextIO,
known_maps: Optional[set] = None,
known_creatures: Optional[set] = None,
known_items: Optional[set] = None,
dropped: Optional[dict] = None) -> dict:
"""
Writes REPLACE INTO statements for every donor table. Returns per-table
insert counts for the run summary.
When @known_maps/@known_creatures/@known_items are provided, rows that
reference an id missing from the corresponding set are skipped:
- instance with map_id not in known_maps is dropped (and all of its
encounters/creatures/sections/loot/tier_instance go with it);
- creature whose creature_id != 0 and not in known_creatures dropped;
- loot whose item_id not in known_items dropped;
- tier with no surviving tier_instance entries dropped.
"""
counts = {
"tier": 0, "instance": 0, "tier_instance": 0,
"encounter": 0, "creature": 0, "section": 0, "loot": 0,
}
if dropped is None:
dropped = {}
for k in ("instance", "creature", "loot", "tier"):
dropped.setdefault(k, 0)
out.write("-- Generated by mod-encounter-journal/tools/"
"import_donor_journal.py.\n")
out.write("-- Source: Sirus-style Generated_EncounterJournal.lua dump.\n")
out.write("-- Safe to re-run: every INSERT uses REPLACE semantics on PK.\n")
out.write("SET NAMES utf8mb4;\n")
out.write("START TRANSACTION;\n\n")
# First pass: figure out which instance_ids survive the map filter so we
# can transitively drop their encounters/creatures/sections/loot.
surviving_instances: set = set()
for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
if len(row) < 12:
row = row + [0] * (12 - len(row))
map_id = _to_int(row[6])
if known_maps is not None and map_id not in known_maps:
dropped["instance"] += 1
continue
surviving_instances.add(_to_int(inst_id))
# encounter_id -> instance_id (used by creature/section/loot filters)
encounter_to_instance: dict = {}
for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
for row in rows:
if len(row) < 12:
continue
encounter_to_instance[_to_int(row[0])] = _to_int(inst_id)
def _enc_survives(enc_id: int) -> bool:
inst = encounter_to_instance.get(enc_id)
return inst is not None and inst in surviving_instances
# ---- tiers ----
# Build the set of tiers that retain at least one surviving instance
# via JOURNALTIERXINSTANCE so we don't write orphan tiers.
surviving_tiers: set = set()
for inst_id, tirow in tables.get("JOURNALTIERXINSTANCE", {}).items():
if _to_int(inst_id) in surviving_instances and len(tirow) >= 1:
surviving_tiers.add(_to_int(tirow[0]))
for row in tables.get("JOURNALTIER", []):
# {id, name, flags}
tid, name, flags = row[0], row[1], row[2] if len(row) > 2 else 0
if surviving_tiers and _to_int(tid) not in surviving_tiers:
dropped["tier"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_tier` "
"(`id`, `name`, `flags`, `sort_order`) VALUES "
f"({_to_int(tid)}, {_sql_str(name)}, {_to_int(flags)}, "
f"{_to_int(tid)});\n"
)
counts["tier"] += 1
out.write("\n")
# ---- instances ----
for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
if len(row) < 12:
row = row + [0] * (12 - len(row))
(name, desc, btn, sbtn, bg, lbg, map_id, area, sort_o,
flags, src_id, wmaa) = row[:12]
out.write(
"REPLACE INTO `custom_ej_instance` "
"(`id`, `name`, `description`, `button_icon`, "
"`small_button_icon`, `background`, `lore_background`, "
"`map_id`, `area_id`, `world_map_area_id`, `flags`, `sort_order`) "
"VALUES ("
f"{_to_int(inst_id)}, {_sql_str(name)}, {_sql_str(desc)}, "
f"{_sql_str(btn)}, {_sql_str(sbtn)}, {_sql_str(bg)}, "
f"{_sql_str(lbg)}, {_to_int(map_id)}, {_to_int(area)}, "
f"{_to_int(wmaa)}, {_to_int(flags)}, {_to_int(sort_o)}"
");\n"
)
counts["instance"] += 1
out.write("\n")
# ---- tier_instance ----
# JOURNALTIERXINSTANCE[instance_id] = {tier_id, sort_order}
for inst_id, row in tables.get("JOURNALTIERXINSTANCE", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
tier_id = row[0]
sort_o = row[1] if len(row) > 1 else 0
out.write(
"REPLACE INTO `custom_ej_tier_instance` "
"(`tier_id`, `instance_id`, `sort_order`) VALUES "
f"({_to_int(tier_id)}, {_to_int(inst_id)}, {_to_int(sort_o)});\n"
)
counts["tier_instance"] += 1
out.write("\n")
# ---- encounters ----
# JOURNALENCOUNTER[instance_id] = list of rows
# {id, name, desc, map_x, map_y, floor, world_map_area, first_section,
# instance_id, difficulty_mask, flags, sort_order}
for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
for row in rows:
if len(row) < 12:
row = row + [0] * (12 - len(row))
(eid, name, desc, mx, my, floor, wmaa, first_sec, src_inst,
diff, flags, sort_o) = row[:12]
out.write(
"REPLACE INTO `custom_ej_encounter` "
"(`id`, `instance_id`, `name`, `description`, "
"`map_position_x`, `map_position_y`, `floor_index`, "
"`world_map_area_id`, `first_section_id`, `difficulty_mask`, "
"`flags`, `sort_order`) VALUES ("
f"{_to_int(eid)}, {_to_int(inst_id)}, {_sql_str(name)}, "
f"{_sql_str(desc)}, {_to_float(mx):.6f}, {_to_float(my):.6f}, "
f"{_to_uint(floor)}, {_to_uint(wmaa)}, {_to_int(first_sec)}, "
f"{_to_uint(diff)}, {_to_uint(flags)}, {_to_int(sort_o)}"
");\n"
)
counts["encounter"] += 1
out.write("\n")
# ---- creatures ----
# JOURNALENCOUNTERCREATURE[encounter_id] = list of rows
# {name, desc, display_id, icon, encounter_id, sort, id, creature_id,
# difficulty_mask}
for enc_id, rows in tables.get("JOURNALENCOUNTERCREATURE", {}).items():
if not _enc_survives(_to_int(enc_id)):
continue
for row in rows:
if len(row) < 9:
row = row + [0] * (9 - len(row))
(name, desc, display_id, icon, src_enc, sort_o, cid, cr_id,
diff) = row[:9]
cr_id_i = _to_int(cr_id)
if (known_creatures is not None and cr_id_i != 0
and cr_id_i not in known_creatures):
dropped["creature"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_creature` "
"(`id`, `encounter_id`, `creature_id`, `name`, `description`, "
"`creature_display_id`, `icon`, `difficulty_mask`, "
"`sort_order`) VALUES ("
f"{_to_int(cid)}, {_to_int(enc_id)}, {_to_int(cr_id)}, "
f"{_sql_str(name)}, {_sql_str(desc)}, {_to_uint(display_id)}, "
f"{_sql_str(icon)}, {_to_uint(diff)}, {_to_int(sort_o)}"
");\n"
)
counts["creature"] += 1
out.write("\n")
# ---- sections ----
# JOURNALENCOUNTERSECTION[section_id] = single row
# {id, name, desc, display_id, desc_spell, icon_spell, encounter_id,
# next, sub, parent, flags, icon_flags, sort, type, difficulty,
# creature_id}
for sid, row in tables.get("JOURNALENCOUNTERSECTION", {}).items():
if len(row) < 16:
row = row + [0] * (16 - len(row))
(src_sid, name, desc, display_id, desc_spell, icon_spell, enc_id,
next_sid, sub_sid, parent_sid, flags, icon_flags, sort_o, stype,
diff, cr_id) = row[:16]
if not _enc_survives(_to_int(enc_id)):
continue
out.write(
"REPLACE INTO `custom_ej_section` "
"(`id`, `encounter_id`, `parent_section_id`, `next_section_id`, "
"`sub_section_id`, `name`, `description`, `creature_display_id`, "
"`description_spell_id`, `icon_spell_id`, `icon`, `flags`, "
"`icon_flags`, `sort_order`, `type`, `difficulty_mask`, "
"`creature_id`) VALUES ("
f"{_to_int(sid)}, {_to_int(enc_id)}, {_to_int(parent_sid)}, "
f"{_to_int(next_sid)}, {_to_int(sub_sid)}, {_sql_str(name)}, "
f"{_sql_str(desc)}, {_to_uint(display_id)}, "
f"{_to_uint(desc_spell)}, {_to_uint(icon_spell)}, NULL, "
f"{_to_uint(flags)}, {_to_uint(icon_flags)}, {_to_int(sort_o)}, "
f"{_to_uint(stype)}, {_to_uint(diff)}, {_to_uint(cr_id)}"
");\n"
)
counts["section"] += 1
out.write("\n")
# ---- loot ----
# JOURNALENCOUNTERITEM[encounter_id] = list of rows
# {item_id, encounter_id, difficulty_mask, faction_mask, flags, id,
# class_mask}
for enc_id, rows in tables.get("JOURNALENCOUNTERITEM", {}).items():
if not _enc_survives(_to_int(enc_id)):
continue
for row in rows:
if len(row) < 7:
row = row + [0] * (7 - len(row))
(item_id, src_enc, diff, faction, flags, lid, class_mask) = row[:7]
item_i = _to_int(item_id)
if known_items is not None and item_i not in known_items:
dropped["loot"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_loot` "
"(`id`, `encounter_id`, `item_id`, `difficulty_mask`, "
"`faction_mask`, `class_mask`, `flags`, `sort_order`) "
"VALUES ("
f"{_to_int(lid)}, {_to_int(enc_id)}, {_to_int(item_id)}, "
f"{_to_uint(diff)}, {_to_signed(faction)}, "
f"{_to_signed(class_mask)}, {_to_uint(flags)}, "
f"{_to_int(lid)}"
");\n"
)
counts["loot"] += 1
out.write("\n")
out.write("COMMIT;\n")
return counts
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--input", "-i", required=True, type=Path,
help="Generated_EncounterJournal.lua")
p.add_argument("--output", "-o", type=Path,
help="Where to write the .sql file (default: stdout)")
p.add_argument("--known-maps", type=Path,
help="File with allowed map ids (one per line). Donor "
"instances whose map_id is not listed are dropped, "
"together with their encounters/creatures/sections/"
"loot.")
p.add_argument("--known-creatures", type=Path,
help="File with allowed creature_template entries. Donor "
"creature cards with creature_id not in the list are "
"dropped.")
p.add_argument("--known-items", type=Path,
help="File with allowed item_template entries. Donor loot "
"rows referencing missing items are dropped.")
args = p.parse_args()
text = args.input.read_text(encoding="utf-8")
tables = _find_top_level_tables(text)
print("parsed tables:", file=sys.stderr)
for name, body in tables.items():
size = len(body) if isinstance(body, (dict, list)) else "?"
print(f" {name:30s} {size}", file=sys.stderr)
known_maps = _load_id_set(args.known_maps)
known_creatures = _load_id_set(args.known_creatures)
known_items = _load_id_set(args.known_items)
if known_maps is not None:
print(f"map whitelist: {len(known_maps)} ids", file=sys.stderr)
if known_creatures is not None:
print(f"creature whitelist: {len(known_creatures)} ids",
file=sys.stderr)
if known_items is not None:
print(f"item whitelist: {len(known_items)} ids", file=sys.stderr)
dropped: dict = {}
if args.output:
with args.output.open("w", encoding="utf-8") as f:
counts = emit(tables, f, known_maps, known_creatures,
known_items, dropped)
else:
counts = emit(tables, sys.stdout, known_maps, known_creatures,
known_items, dropped)
print("emitted rows:", file=sys.stderr)
for k, v in counts.items():
print(f" {k:14s} {v}", file=sys.stderr)
if any(dropped.values()):
print("dropped (custom Sirus entries):", file=sys.stderr)
for k, v in dropped.items():
if v:
print(f" {k:14s} {v}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -336,3 +336,40 @@ IndividualProgression.ExcludeAccounts = 1
# Default: "^RNDBOT.*"
#
IndividualProgression.ExcludedAccountsRegex = "^RNDBOT.*"
########################################
# Player Guide (Путеводитель)
########################################
# IndividualProgression.PlayerGuide.Enable
#
# Description: Enable server-side Player Guide payload generation
# and addon-channel delivery. When disabled the
# module does not push any data to clients and
# ignores client commands.
#
# Default: 1 - Enabled
# 0 - Disabled
#
IndividualProgression.PlayerGuide.Enable = 1
# IndividualProgression.PlayerGuide.ChunkSize
#
# Description: Maximum size, in bytes, of a single addon-channel
# chunk used to deliver guide payloads. Lower values
# produce more chunks; higher values risk hitting
# the 255-byte client limit. Clamped to [64, 240].
#
# Default: 230
#
IndividualProgression.PlayerGuide.ChunkSize = 230
# IndividualProgression.PlayerGuide.LogPayloads
#
# Description: Emit a debug log line for each payload pushed to
# a client. Useful when developing the client UI.
#
# Default: 0 - Disabled
# 1 - Enabled
#
IndividualProgression.PlayerGuide.LogPayloads = 0
@@ -42,6 +42,7 @@ void AddSC_ipp_spell_scripts();
void AddSC_individualProgression_commandscript();
void AddSC_mod_individual_progression_awareness();
void AddSC_mod_individual_progression_player();
void AddSC_mod_individual_progression_player_guide();
void AddSC_npc_archmage_timear();
void AddSC_karazhan_70();
void AddSC_the_eye_70();
@@ -88,6 +89,7 @@ void Addmod_individual_progressionScripts()
AddSC_individualProgression_commandscript();
AddSC_mod_individual_progression_awareness();
AddSC_mod_individual_progression_player();
AddSC_mod_individual_progression_player_guide();
AddSC_npc_archmage_timear();
AddSC_karazhan_70();
AddSC_the_eye_70();
@@ -0,0 +1,584 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideMgr.h"
#include "../IndividualProgression.h"
#include "Config.h"
#include "Log.h"
#include "Player.h"
#include "QuestDef.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <sstream>
#include <unordered_set>
namespace MoonWell::PlayerGuide
{
namespace
{
std::string EscapeJson(std::string const& in)
{
std::string out;
out.reserve(in.size() + 2);
for (unsigned char c : in)
{
switch (c)
{
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (c < 0x20)
{
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
}
else
out += static_cast<char>(c);
break;
}
}
return out;
}
// Writes "key":number when value > 0.
void WriteOptUInt(std::ostringstream& s, char const* key, uint32 v,
bool& first)
{
if (!v)
return;
if (!first) s << ',';
s << '"' << key << "\":" << v;
first = false;
}
void WriteString(std::ostringstream& s, char const* key,
std::string const& v, bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":\"" << EscapeJson(v) << '"';
first = false;
}
void WriteUInt(std::ostringstream& s, char const* key, uint32 v,
bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":" << v;
first = false;
}
void WriteBool(std::ostringstream& s, char const* key, bool v,
bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":" << (v ? "true" : "false");
first = false;
}
// Maps an IPP ProgressionState into a target stage (1..N).
// The stage ids are kept stable so the client can key
// localisations off of them.
struct StageInfo
{
uint32 id;
char const* name;
char const* description;
};
StageInfo const STAGES[] =
{
{ 1, "Начало пути",
"Завершите низкоуровневое содержимое родного континента." },
{ 2, "Огненные Недра",
"Победите Рагнароса в Огненных Недрах." },
{ 3, "Логово Ониксии",
"Победите Ониксию." },
{ 4, "Логово Крыла Тьмы",
"Победите Нефариана в Логове Крыла Тьмы." },
{ 5, "Подготовка к АQ",
"Завершите подготовку к открытию ворот Ан'Киража." },
{ 6, "Военный поход АQ",
"Окажите помощь военным усилиям в Силитусе." },
{ 7, "Храм Ан'Киража",
"Победите Ц'Туна в Храме Ан'Киража." },
{ 8, "Наксрамас (40)",
"Победите КелТузада в Наксрамасе." },
{ 9, "Подготовка к Запределью",
"Откройте Тёмный портал и подготовьтесь к TBC." },
{ 10, "Каражан и Гру'ул",
"Завершите первый ярус рейдов TBC." },
{ 11, "Пещера Змеиных Недр / Око Бури",
"Завершите рейды второго яруса TBC." },
{ 12, "Вершина Хиджала и Чёрный храм",
"Победите Иллидана в Чёрном храме." },
{ 13, "Зул'Аман",
"Получите доступ к Зул'Аману и завершите его." },
{ 14, "Плато Солнечного Колодца",
"Победите Кил'джедена на Плато Солнечного Колодца." },
{ 15, "Наксрамас (Нордскол)",
"Завершите рейды первого яруса WotLK." },
{ 16, "Ульдуар",
"Победите Йогг-Сарона в Ульдуаре." },
{ 17, "Испытание крестоносца",
"Завершите Испытание крестоносца." },
{ 18, "Ледяная Корона",
"Победите Короля-лича." },
{ 19, "Рубиновое святилище",
"Победите Халиона в Рубиновом святилище." }
};
StageInfo const* FindStage(uint32 id)
{
for (StageInfo const& s : STAGES)
if (s.id == id)
return &s;
return nullptr;
}
// Tiered dungeon recommendations matched to the player level/stage.
struct DungeonRec
{
uint32 stage; // recommended starting from this stage
uint32 maxStage; // hidden after this stage (inclusive)
uint8 minLevel;
uint8 maxLevel;
uint32 mapId;
char const* title;
};
DungeonRec const DUNGEONS[] =
{
{ 1, 1, 15, 21, 36, "Мёртвые копи" },
{ 1, 1, 17, 24, 33, "Курганы Иглошкурых" },
{ 1, 2, 22, 30, 47, "Шахта Каражан" },
{ 1, 2, 24, 32, 70, "Ульдаман" },
{ 1, 3, 27, 38, 109, "Затонувший храм" },
{ 1, 3, 30, 42, 209, "Зул'Фаррак" },
{ 1, 4, 46, 54, 229, "Верхняя часть Чёрной горы" },
{ 1, 4, 55, 60, 230, "Глубины Чёрной горы" },
{ 1, 4, 58, 60, 533, "Наксрамас" },
{ 9, 12, 58, 70, 540, "Кузня Крови" },
{ 9, 12, 60, 70, 542, "Цитадель Адского пламени" },
{ 9, 12, 65, 70, 553, "Ботаника" },
{ 9, 12, 67, 70, 555, "Гробница Ткача Смерти" },
{14, 19, 68, 80, 574, "Утгард" },
{14, 19, 70, 80, 600, "Драк'Тарон" },
{14, 19, 72, 80, 619, "Ан'Кахет: Старое Королевство" },
{14, 19, 74, 80, 595, "Чертоги Камней" },
{14, 19, 76, 80, 658, "Очищение Стратхольма" }
};
}
Mgr* Mgr::instance()
{
static Mgr inst;
return &inst;
}
void Mgr::LoadConfig()
{
_enabled = sConfigMgr->GetOption<bool>(
"IndividualProgression.PlayerGuide.Enable", true);
_logPayloads = sConfigMgr->GetOption<bool>(
"IndividualProgression.PlayerGuide.LogPayloads", false);
_chunkSize = sConfigMgr->GetOption<uint32>(
"IndividualProgression.PlayerGuide.ChunkSize", 230);
// The addon channel hard-caps a single message body to 255
// characters. We leave room for the framing header.
if (_chunkSize < 64) _chunkSize = 64;
if (_chunkSize > 240) _chunkSize = 240;
}
std::string Mgr::StageName(uint32 stage) const
{
StageInfo const* s = FindStage(stage);
return s ? s->name : "Этап";
}
std::string Mgr::StageDescription(uint32 stage) const
{
StageInfo const* s = FindStage(stage);
return s ? s->description : "";
}
void Mgr::AppendLevelObjective(Payload& out, Player* player,
uint32 requiredLevel, uint32 order) const
{
Objective o;
o.id = order;
o.type = "level";
char buf[64];
std::snprintf(buf, sizeof(buf), "Достигнуть %u уровня",
requiredLevel);
o.title = buf;
o.progress = player->GetLevel();
o.required = requiredLevel;
o.completed = o.progress >= o.required;
o.targetId = requiredLevel;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendQuestObjective(Payload& out, Player* player,
uint32 questId,
std::string const& title,
uint32 order) const
{
QuestStatus st = player->GetQuestStatus(questId);
Objective o;
o.id = order;
o.type = "quest";
o.title = title;
o.required = 1;
o.progress = (st == QUEST_STATUS_REWARDED ||
st == QUEST_STATUS_COMPLETE) ? 1 : 0;
o.completed = (st == QUEST_STATUS_REWARDED);
o.targetId = questId;
o.questId = questId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendDungeonObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const
{
Objective o;
o.id = order;
o.type = "dungeon";
o.title = title;
o.required = 1;
o.targetId = mapId;
o.instanceId = mapId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendRaidObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const
{
Objective o;
o.id = order;
o.type = "raid";
o.title = title;
o.required = 1;
o.targetId = mapId;
o.instanceId = mapId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendRecommendations(Payload& out, Player* player) const
{
uint8 level = player->GetLevel();
uint32 stage = out.stageId;
std::unordered_set<uint32> seen;
uint32 recId = 1;
for (DungeonRec const& d : DUNGEONS)
{
if (stage < d.stage || stage > d.maxStage)
continue;
if (level + 2 < d.minLevel || level > d.maxLevel + 1)
continue;
if (!seen.insert(d.mapId).second)
continue;
Recommendation r;
r.id = recId++;
r.type = "dungeon";
r.title = d.title;
r.instanceId = d.mapId;
r.priority = 100 -
static_cast<uint32>(std::abs(int32(level) -
int32((d.minLevel + d.maxLevel) / 2)));
r.description = "Подходит для вашего текущего этапа.";
out.recommendations.push_back(std::move(r));
if (out.recommendations.size() >= 6)
break;
}
}
static uint32 ProgressFor(uint32 stage, uint8 level)
{
// Rough %: scaled across the three expansion bands.
uint32 p = 0;
if (stage < PROGRESSION_PRE_TBC)
p = std::min<uint32>(60u, (level * 60u) / 60u);
else if (stage < PROGRESSION_WOTLK_TIER_1)
p = 60u + std::min<uint32>(20u,
((level > 60 ? level - 60 : 0) * 20u) / 10u);
else
p = 80u + std::min<uint32>(20u,
((level > 70 ? level - 70 : 0) * 20u) / 10u);
// Add a small bump per cleared major raid stage above 1.
if (stage > 1)
p = std::min<uint32>(100u, p + (stage - 1) * 2u);
return p;
}
Payload Mgr::BuildFor(Player* player) const
{
Payload out;
out.version = PAYLOAD_VERSION;
out.characterGuid = player->GetGUID().GetRawValue();
// ProgressionState as set by IPP. Default is 0 (start of vanilla).
uint32 ipStage = player->GetPlayerSetting(
"mod-individual-progression", SETTING_PROGRESSION_STATE).value;
// Player's *next* stage is what the guide should be helping with.
// STAGES use 1-based ids where stage id = ProgressionState+1.
out.stageId = std::min<uint32>(ipStage + 1, 19);
out.stageName = StageName(out.stageId);
out.stageDescription = StageDescription(out.stageId);
out.progress = ProgressFor(ipStage, player->GetLevel());
uint32 order = 1;
switch (ipStage)
{
case PROGRESSION_START:
AppendLevelObjective(out, player, 60, order++);
AppendRaidObjective(out, 409,
"Победить Рагнароса в Огненных Недрах", order++);
break;
case PROGRESSION_MOLTEN_CORE:
AppendLevelObjective(out, player, 60, order++);
AppendRaidObjective(out, 249,
"Победить Ониксию", order++);
AppendRaidObjective(out, 469,
"Победить Нефариана в Логове Крыла Тьмы", order++);
break;
case PROGRESSION_ONYXIA:
AppendRaidObjective(out, 469,
"Победить Нефариана в Логове Крыла Тьмы", order++);
AppendRaidObjective(out, 309,
"Зул'Гуруб (опционально)", order++);
break;
case PROGRESSION_BLACKWING_LAIR:
AppendQuestObjective(out, player, MIGHT_OF_KALIMDOR,
"Сдать ресурсы для военного похода", order++);
AppendQuestObjective(out, player, BANG_A_GONG,
"Открыть ворота Ан'Киража", order++);
break;
case PROGRESSION_PRE_AQ:
AppendQuestObjective(out, player, CHAOS_AND_DESTRUCTION,
"Завершить полевые задания в Силитусе", order++);
AppendRaidObjective(out, 509,
"Руины Ан'Киража", order++);
break;
case PROGRESSION_AQ_WAR:
AppendRaidObjective(out, 531,
"Победить Ц'Туна в Храме Ан'Киража", order++);
break;
case PROGRESSION_AQ:
AppendRaidObjective(out, 533,
"Победить КелТузада в Наксрамасе", order++);
break;
case PROGRESSION_NAXX40:
AppendQuestObjective(out, player, INTO_THE_BREACH,
"Открыть Тёмный портал", order++);
AppendLevelObjective(out, player, 70, order++);
break;
case PROGRESSION_PRE_TBC:
AppendLevelObjective(out, player, 70, order++);
AppendRaidObjective(out, 532,
"Каражан", order++);
AppendRaidObjective(out, 565,
"Логово Груула", order++);
AppendRaidObjective(out, 544,
"Логово Магтеридона", order++);
break;
case PROGRESSION_TBC_TIER_1:
AppendRaidObjective(out, 548,
"Пещера Змеиных Недр", order++);
AppendRaidObjective(out, 550,
"Око Бури — Крепость Бурь", order++);
break;
case PROGRESSION_TBC_TIER_2:
AppendRaidObjective(out, 534,
"Битва за гору Хиджал", order++);
AppendRaidObjective(out, 564,
"Чёрный храм", order++);
break;
case PROGRESSION_TBC_TIER_3:
AppendRaidObjective(out, 568,
"Зул'Аман", order++);
break;
case PROGRESSION_TBC_TIER_4:
AppendRaidObjective(out, 580,
"Плато Солнечного Колодца", order++);
break;
case PROGRESSION_TBC_TIER_5:
AppendLevelObjective(out, player, 80, order++);
AppendRaidObjective(out, 533,
"Наксрамас (Нордскол)", order++);
AppendRaidObjective(out, 616,
"Око Вечности", order++);
AppendRaidObjective(out, 615,
"Камера Сокровищ Обсидиановой драконьей стаи",
order++);
break;
case PROGRESSION_WOTLK_TIER_1:
AppendRaidObjective(out, 603,
"Победить Йогг-Сарона в Ульдуаре", order++);
break;
case PROGRESSION_WOTLK_TIER_2:
AppendRaidObjective(out, 649,
"Испытание крестоносца", order++);
break;
case PROGRESSION_WOTLK_TIER_3:
AppendRaidObjective(out, 631,
"Цитадель Ледяной Короны", order++);
break;
case PROGRESSION_WOTLK_TIER_4:
AppendRaidObjective(out, 724,
"Рубиновое святилище", order++);
break;
default:
break;
}
AppendRecommendations(out, player);
return out;
}
std::string Mgr::SerializeJson(Objective const& o) const
{
std::ostringstream s;
s << '{';
bool first = true;
WriteUInt(s, "id", o.id, first);
WriteString(s, "type", o.type, first);
WriteString(s, "title", o.title, first);
if (!o.description.empty())
WriteString(s, "description", o.description, first);
WriteBool(s, "completed", o.completed, first);
WriteUInt(s, "progress", o.progress, first);
WriteUInt(s, "required", o.required, first);
WriteUInt(s, "targetID", o.targetId, first);
WriteUInt(s, "order", o.order, first);
WriteOptUInt(s, "questID", o.questId, first);
WriteOptUInt(s, "questChainID", o.questChainId, first);
WriteOptUInt(s, "instanceID", o.instanceId, first);
WriteOptUInt(s, "encounterID", o.encounterId, first);
WriteOptUInt(s, "achievementID", o.achievementId, first);
WriteOptUInt(s, "factionID", o.factionId, first);
WriteOptUInt(s, "professionID", o.professionId, first);
WriteOptUInt(s, "itemID", o.itemId, first);
WriteOptUInt(s, "currencyID", o.currencyId, first);
s << '}';
return s.str();
}
std::string Mgr::SerializeJson(Payload const& p) const
{
std::ostringstream s;
s << '{';
s << "\"version\":" << p.version
<< ",\"characterGuid\":" << p.characterGuid
<< ",\"stageID\":" << p.stageId
<< ",\"stageName\":\"" << EscapeJson(p.stageName) << '"'
<< ",\"stageDescription\":\""
<< EscapeJson(p.stageDescription) << '"'
<< ",\"progress\":" << std::min<uint32>(p.progress, 100u);
// Objectives: collapse duplicate ids and clamp progress.
s << ",\"objectives\":[";
std::set<uint32> seenIds;
bool firstObj = true;
for (Objective const& src : p.objectives)
{
if (!seenIds.insert(src.id).second)
continue;
Objective o = src;
if (o.required && o.progress > o.required)
o.progress = o.required;
if (!firstObj) s << ',';
s << SerializeJson(o);
firstObj = false;
}
s << "]";
// Recommendations.
s << ",\"recommendations\":[";
bool firstRec = true;
for (Recommendation const& r : p.recommendations)
{
if (!firstRec) s << ',';
s << '{';
bool f = true;
WriteUInt(s, "id", r.id, f);
WriteString(s, "type", r.type, f);
WriteString(s, "title", r.title, f);
if (!r.description.empty())
WriteString(s, "description", r.description, f);
WriteOptUInt(s, "instanceID", r.instanceId, f);
WriteOptUInt(s, "encounterID", r.encounterId, f);
WriteOptUInt(s, "questID", r.questId, f);
WriteOptUInt(s, "itemID", r.itemId, f);
WriteUInt(s, "priority", r.priority, f);
s << '}';
firstRec = false;
}
s << "]";
// Rewards.
s << ",\"rewards\":[";
bool firstRew = true;
for (Reward const& r : p.rewards)
{
if (!firstRew) s << ',';
s << '{';
bool f = true;
WriteString(s, "type", r.type, f);
WriteOptUInt(s, "itemID", r.itemId, f);
WriteOptUInt(s, "currencyID", r.currencyId, f);
WriteUInt(s, "count", std::max<uint32>(r.count, 1), f);
WriteOptUInt(s, "amount", r.amount, f);
s << '}';
firstRew = false;
}
s << "]";
s << '}';
return s.str();
}
}
@@ -0,0 +1,138 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#ifndef MOONWELL_PLAYER_GUIDE_MGR_H
#define MOONWELL_PLAYER_GUIDE_MGR_H
#include "Define.h"
#include <cstdint>
#include <string>
#include <vector>
class Player;
namespace MoonWell::PlayerGuide
{
enum : uint32 { PAYLOAD_VERSION = 1 };
struct Objective
{
uint32 id = 0;
std::string type; // level | quest | quest_chain | dungeon |
// raid | boss | achievement |
// reputation | profession | item |
// currency | custom
std::string title;
std::string description;
bool completed = false;
uint32 progress = 0;
uint32 required = 0;
uint32 targetId = 0;
uint32 order = 0;
// Optional type-specific helpers. Zero means "not set" and
// serialiser omits it.
uint32 questId = 0;
uint32 questChainId = 0;
uint32 instanceId = 0;
uint32 encounterId = 0;
uint32 achievementId = 0;
uint32 factionId = 0;
uint32 professionId = 0;
uint32 itemId = 0;
uint32 currencyId = 0;
};
struct Recommendation
{
uint32 id = 0;
std::string type;
std::string title;
std::string description;
uint32 instanceId = 0;
uint32 encounterId = 0;
uint32 questId = 0;
uint32 itemId = 0;
uint32 priority = 0;
};
struct Reward
{
std::string type;
uint32 itemId = 0;
uint32 count = 1;
uint32 currencyId = 0;
uint32 amount = 0;
};
struct Payload
{
uint32 version = PAYLOAD_VERSION;
uint64 characterGuid = 0;
uint32 stageId = 0;
std::string stageName;
std::string stageDescription;
uint32 progress = 0; // 0..100
std::vector<Objective> objectives;
std::vector<Recommendation> recommendations;
std::vector<Reward> rewards;
};
class Mgr
{
public:
static Mgr* instance();
// Reads configuration. Safe to call multiple times.
void LoadConfig();
// Builds the per-character guide payload from IPP state.
// The function is read-only with respect to the player.
Payload BuildFor(Player* player) const;
// Serialises a payload to a single JSON string.
// Validates uniqueness of objective ids and clamps progress.
std::string SerializeJson(Payload const& payload) const;
std::string SerializeJson(Objective const& objective) const;
bool IsEnabled() const { return _enabled; }
bool LogPayloads() const { return _logPayloads; }
uint32 ChunkSize() const { return _chunkSize; }
private:
Mgr() = default;
// Stage helpers based on ProgressionState.
std::string StageName(uint32 stage) const;
std::string StageDescription(uint32 stage) const;
// Filling helpers.
void AppendLevelObjective(Payload& out, Player* player,
uint32 requiredLevel, uint32 order) const;
void AppendQuestObjective(Payload& out, Player* player,
uint32 questId, std::string const& title,
uint32 order) const;
void AppendDungeonObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const;
void AppendRaidObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const;
void AppendRecommendations(Payload& out, Player* player) const;
bool _enabled = true;
bool _logPayloads = false;
uint32 _chunkSize = 230; // per-frame body capacity (addon channel)
};
}
#define sPlayerGuideMgr ::MoonWell::PlayerGuide::Mgr::instance()
#endif // MOONWELL_PLAYER_GUIDE_MGR_H
@@ -0,0 +1,183 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideMgr.h"
#include "PlayerGuideTransport.h"
#include "../IndividualProgression.h"
#include "Chat.h"
#include "Config.h"
#include "Creature.h"
#include "Log.h"
#include "Player.h"
#include "QuestDef.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include <cstdlib>
#include <string>
namespace MoonWell::PlayerGuide
{
namespace
{
void Push(Player* player)
{
if (!sPlayerGuideMgr->IsEnabled())
return;
if (!player || !player->GetSession() || player->IsBeingTeleported())
return;
Payload p = sPlayerGuideMgr->BuildFor(player);
std::string json = sPlayerGuideMgr->SerializeJson(p);
if (sPlayerGuideMgr->LogPayloads())
LOG_DEBUG("module", "[PlayerGuide] push {} bytes -> {}",
json.size(), player->GetName());
Transport::SendSetData(player, json);
}
// Splits "MWPG\t<cmd>\t<arg>" -> (cmd, arg). Returns false if
// the message does not match the expected addon prefix.
bool ParseClientMessage(std::string const& in, std::string& cmd,
std::string& arg)
{
// Expected prefix: "MWPG\t".
if (in.size() < 6)
return false;
if (in.compare(0, 4, ADDON_PREFIX) != 0)
return false;
if (in[4] != '\t')
return false;
std::size_t end = in.find('\t', 5);
if (end == std::string::npos)
{
cmd.assign(in, 5, in.size() - 5);
arg.clear();
return true;
}
cmd.assign(in, 5, end - 5);
arg.assign(in, end + 1, in.size() - (end + 1));
return true;
}
}
class PlayerGuide_PlayerScript : public PlayerScript
{
public:
PlayerGuide_PlayerScript()
: PlayerScript("PlayerGuide_PlayerScript",
{
PLAYERHOOK_ON_LOGIN,
PLAYERHOOK_ON_LEVEL_CHANGED,
PLAYERHOOK_ON_PLAYER_COMPLETE_QUEST,
PLAYERHOOK_ON_CREATURE_KILL,
PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE
}) { }
void OnPlayerLogin(Player* player) override
{
Push(player);
}
void OnPlayerLevelChanged(Player* player, uint8 /*oldLevel*/) override
{
Push(player);
}
void OnPlayerCompleteQuest(Player* player,
Quest const* /*quest*/) override
{
Push(player);
}
void OnPlayerCreatureKill(Player* killer, Creature* killed) override
{
if (!killer || !killed)
return;
if (killed->GetCreatureTemplate()->rank <= CREATURE_ELITE_NORMAL)
return;
Push(killer);
}
void OnPlayerBeforeSendChatMessage(Player* player, uint32& type,
uint32& lang, std::string& msg) override
{
if (!sPlayerGuideMgr->IsEnabled())
return;
if (type != CHAT_MSG_WHISPER || lang != LANG_ADDON)
return;
if (sPlayerGuideMgr->LogPayloads())
LOG_DEBUG("module",
"[PlayerGuide] raw addon <- {} ({} bytes): '{}'",
player->GetName(), msg.size(), msg);
std::string cmd, arg;
if (!ParseClientMessage(msg, cmd, arg))
return;
LOG_INFO("module",
"[PlayerGuide] <- {} cmd='{}' arg='{}' ({} bytes)",
player->GetName(), cmd, arg, msg.size());
if (cmd == ClientCmd::REQUEST_REFRESH)
{
Push(player);
}
else if (cmd == ClientCmd::REQUEST_TRACK_OBJECTIVE)
{
// Tracking is purely client-side state for now; we just
// re-send the payload so the client can update its
// tracked id atomically.
Push(player);
}
else if (cmd == ClientCmd::REQUEST_OPEN_TARGET)
{
// Reserved for future: server-driven open-target action
// (e.g. queue LFG). For MVP a refresh is enough.
Push(player);
}
else
{
LOG_WARN("module",
"[PlayerGuide] unknown cmd='{}' from {}",
cmd, player->GetName());
return;
}
// Swallow the message so it does not get relayed as a real
// whisper. Clearing the body is enough — ChatHandler
// returns early on empty addon messages.
msg.clear();
}
};
class PlayerGuide_WorldScript : public WorldScript
{
public:
PlayerGuide_WorldScript()
: WorldScript("PlayerGuide_WorldScript",
{ WORLDHOOK_ON_AFTER_CONFIG_LOAD }) { }
void OnAfterConfigLoad(bool /*reload*/) override
{
sPlayerGuideMgr->LoadConfig();
LOG_INFO("server.loading",
">> PlayerGuide module: {}.",
sPlayerGuideMgr->IsEnabled() ? "enabled" : "disabled");
}
};
}
void AddSC_mod_individual_progression_player_guide()
{
new MoonWell::PlayerGuide::PlayerGuide_PlayerScript();
new MoonWell::PlayerGuide::PlayerGuide_WorldScript();
}
@@ -0,0 +1,96 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideTransport.h"
#include "PlayerGuideMgr.h"
#include "Chat.h"
#include "Player.h"
#include "WorldPacket.h"
#include "WorldSession.h"
#include <algorithm>
#include <cstdio>
#include <vector>
namespace MoonWell::PlayerGuide
{
namespace
{
// The client-visible chat body must stay strictly below the
// 255-byte addon limit. We reserve room for the framing header.
// The mgr clamps its configured chunk size into a safe range.
constexpr std::size_t HARD_LIMIT = 250;
void SendOneChunk(Player* player, std::string_view command,
uint32 seq, uint32 total, std::string_view body)
{
std::string msg;
msg.reserve(ADDON_PREFIX[0] ? body.size() + 32 : body.size());
msg.append(ADDON_PREFIX);
msg.push_back('\t');
msg.append(command.data(), command.size());
msg.push_back('\t');
char buf[24];
std::snprintf(buf, sizeof(buf), "%u\t%u\t", seq, total);
msg.append(buf);
msg.append(body.data(), body.size());
if (msg.size() > HARD_LIMIT)
msg.resize(HARD_LIMIT);
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER,
LANG_ADDON, player, player, msg);
player->GetSession()->SendPacket(&data);
}
}
void Transport::Send(Player* player, std::string_view command,
std::string_view body)
{
if (!player || !player->GetSession())
return;
// How much body fits into a chunk after framing.
// Framing = prefix(4) + '\t' + cmd(N) + '\t' + "65535\t65535\t".
// Use Mgr's configured chunk size when sensible.
std::size_t framing = 4 + 1 + command.size() + 1 + 16;
std::size_t budget = sPlayerGuideMgr->ChunkSize() > framing
? (sPlayerGuideMgr->ChunkSize() - framing)
: 64;
if (body.size() <= budget)
{
SendOneChunk(player, command, 1, 1, body);
return;
}
// Multi-chunk: split blindly by byte budget. JSON tolerates
// concatenation on the client.
uint32 total = static_cast<uint32>(
(body.size() + budget - 1) / budget);
uint32 seq = 1;
for (std::size_t off = 0; off < body.size(); off += budget, ++seq)
{
std::size_t take = std::min(budget, body.size() - off);
SendOneChunk(player, command, seq, total,
std::string_view(body.data() + off, take));
}
}
void Transport::SendSetData(Player* player, std::string const& json)
{
Send(player, ServerCmd::SET_DATA, json);
}
void Transport::SendNotify(Player* player, std::string const& text)
{
Send(player, ServerCmd::NOTIFY, text);
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#ifndef MOONWELL_PLAYER_GUIDE_TRANSPORT_H
#define MOONWELL_PLAYER_GUIDE_TRANSPORT_H
#include "Define.h"
#include <string>
#include <string_view>
class Player;
namespace MoonWell::PlayerGuide
{
// Addon channel prefix used on both ends. The client AIO bridge
// routes messages with this prefix to the MoonWellPlayerGuide
// handler.
constexpr char const* ADDON_PREFIX = "MWPG";
// Commands sent server -> client.
namespace ServerCmd
{
constexpr char const* SET_DATA = "SetData";
constexpr char const* SET_STAGE = "SetStage";
constexpr char const* UPDATE_OBJECTIVE = "UpdateObjective";
constexpr char const* NOTIFY = "Notify";
}
// Commands sent client -> server.
namespace ClientCmd
{
constexpr char const* REQUEST_REFRESH = "RequestRefresh";
constexpr char const* REQUEST_TRACK_OBJECTIVE = "RequestTrackObjective";
constexpr char const* REQUEST_OPEN_TARGET = "RequestOpenTarget";
}
class Transport
{
public:
// Sends a single logical message split into addon-channel
// chunks. Wire format per chunk:
// "MWPG\t<cmd>\t<seq>\t<total>\t<body>"
// Single-chunk messages still use seq=1 total=1.
static void Send(Player* player, std::string_view command,
std::string_view body);
// Convenience wrappers.
static void SendSetData(Player* player, std::string const& json);
static void SendNotify(Player* player, std::string const& text);
};
}
#endif // MOONWELL_PLAYER_GUIDE_TRANSPORT_H