encounter journal base logic
This commit is contained in:
@@ -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(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 \"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 -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
+119789
File diff suppressed because it is too large
Load Diff
@@ -85,10 +85,14 @@ console.
|
|||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
| ---------------- | ---------------------------------------------------------------------------- |
|
| ---------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Command | What it does |
|
||||||
|
| -------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| `.ej info` | Print row counts of the in-memory cache. |
|
| `.ej info` | Print row counts of the in-memory cache. |
|
||||||
| `.ej reload` | Re-read all `custom_ej_*` tables. |
|
| `.ej reload` | Re-read all `custom_ej_*` tables. |
|
||||||
| `.ej validate` | Run integrity checks (see "Validation" below). |
|
| `.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 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. |
|
| `.ej export` | Write `EncounterJournalData.json` (and `.lua`) to the configured path. |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -1199,7 +1199,9 @@ namespace MoonWell::EncounterJournal
|
|||||||
|
|
||||||
CreatureTemplate const* ct = sObjectMgr->GetCreatureTemplate(creature);
|
CreatureTemplate const* ct = sObjectMgr->GetCreatureTemplate(creature);
|
||||||
std::string crName = (ct && !ct->Name.empty()) ? ct->Name : cmt;
|
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;
|
std::string crEsc;
|
||||||
crEsc.reserve(crName.size());
|
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.*"
|
# Default: "^RNDBOT.*"
|
||||||
#
|
#
|
||||||
IndividualProgression.ExcludedAccountsRegex = "^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_individualProgression_commandscript();
|
||||||
void AddSC_mod_individual_progression_awareness();
|
void AddSC_mod_individual_progression_awareness();
|
||||||
void AddSC_mod_individual_progression_player();
|
void AddSC_mod_individual_progression_player();
|
||||||
|
void AddSC_mod_individual_progression_player_guide();
|
||||||
void AddSC_npc_archmage_timear();
|
void AddSC_npc_archmage_timear();
|
||||||
void AddSC_karazhan_70();
|
void AddSC_karazhan_70();
|
||||||
void AddSC_the_eye_70();
|
void AddSC_the_eye_70();
|
||||||
@@ -88,6 +89,7 @@ void Addmod_individual_progressionScripts()
|
|||||||
AddSC_individualProgression_commandscript();
|
AddSC_individualProgression_commandscript();
|
||||||
AddSC_mod_individual_progression_awareness();
|
AddSC_mod_individual_progression_awareness();
|
||||||
AddSC_mod_individual_progression_player();
|
AddSC_mod_individual_progression_player();
|
||||||
|
AddSC_mod_individual_progression_player_guide();
|
||||||
AddSC_npc_archmage_timear();
|
AddSC_npc_archmage_timear();
|
||||||
AddSC_karazhan_70();
|
AddSC_karazhan_70();
|
||||||
AddSC_the_eye_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
|
||||||
Reference in New Issue
Block a user