feat: pzmm conflict detection + content-type categorization

- mod_files manifest table populated at parse time
- POST /api/conflicts endpoint
- mod_types fingerprinting feeds derive_category
- DD filelist regex broadened to cover conflict-eligible exts
- media/maps/<*>/* excluded from manifest (per-mod namespaced,
  no conflict value, can be tens of MB per mod)

Plan: docs/plans/2026-05-04-pzmm-conflict-and-typing.md
This commit is contained in:
2026-05-04 15:22:35 +00:00
parent a15d35214e
commit b73325882e
9 changed files with 936 additions and 18 deletions

View File

@@ -130,6 +130,11 @@ class ModInfo:
# signal for build / multiplayer / category detection. Distinct from
# `tags` which is mod.info-side (freeform).
workshop_tags: List[str] = field(default_factory=list)
# pzmm-style content fingerprint (Maps, Vehicles, Weapons, Traits, …)
# populated by worker.build_manifest_and_types at parse time. Empty when
# files_manifest_built=false (older cached rows); derive_category falls
# through to the existing cascade in that case.
mod_types: List[str] = field(default_factory=list)
warnings: Dict[str, List[str]] = field(default_factory=dict)
@@ -389,30 +394,77 @@ def _name_has(name: str, hints: List[str]) -> bool:
return any(h in n for h in hints)
# pzmm content-type → sortof CATEGORY_ORDER mapping. "skip" entries fall
# through to the existing derive_category cascade. Items/Animations/Lua/Unknown
# are too generic; Maps/Sounds/Patch/Vehicles/Clothing duplicate signals already
# captured by the cascade but stay here as fallbacks for poorly-tagged mods.
_TYPE_TO_CAT: Dict[str, str] = {
"Maps": "map",
"Vehicles": "vehicle",
"Weapons": "weapon",
"Clothing": "wearable",
"Traits": "code",
"Professions": "profession",
"Recipes": "crafting",
"Tiles": "tile",
"Textures": "texture",
"Sounds": "sound",
"UI": "ui",
"Translations": "translation",
"Patch": "patch",
"Dependency": "tweaks",
"Framework": "tweaks",
}
def _types_to_category(mod_types: List[str], name: str) -> Optional[str]:
"""First mod_type that maps to a sortof CATEGORY_ORDER bucket wins.
Returns None if mod_types contains only skip-types (Items/Animations/Lua/
Unknown), so the caller can fall through to the existing cascade."""
for t in mod_types:
cat = _TYPE_TO_CAT.get(t)
if cat:
# vehicle_spawn refinement matches the downstream ws_tag check.
if cat == "vehicle" and name and "spawn zone" in name.lower():
return "vehicle_spawn"
return cat
return None
def derive_category(mod: ModInfo) -> str:
"""Best-effort category from mod.info + workshop_meta.tags + name.
Detection order (most specific → least):
1. mod.info `category=` if explicit and recognized.
2. patch / fix name regex (Spec G-patch).
3. library/framework name regex (extends FRAMEWORK_KEYS).
4. mod.maps non-empty → map.
5. moodle / profession / movement / specific gameplay axes by name.
6. Workshop tags (canonical Steam controlled vocab): Audio + 'music'
2. pzmm-style mod_types fingerprint (when files_manifest_built=true).
3. patch / fix name regex (Spec G-patch).
4. library/framework name regex (extends FRAMEWORK_KEYS).
5. mod.maps non-empty → map.
6. moodle / profession / movement / specific gameplay axes by name.
7. Workshop tags (canonical Steam controlled vocab): Audio + 'music'
music; Audio → sound; Weapons → weapon; Vehicles → vehicle;
Clothing/Armor + 'armor' → armor, else wearable; Building →
building; Farming → farming; Food → food; Skills → profession
(or moodle); Interface → ui; Textures → texture;
Language/Translation → translation; QOL → qol; Multiplayer alone
→ multiplayer.
7. mod.info tags (freeform fallback).
8. FRAMEWORK_KEYS substring match → tweaks.
9. Default → other.
8. mod.info tags (freeform fallback).
9. FRAMEWORK_KEYS substring match → tweaks.
10. Default → other.
"""
if mod.category in CATEGORY_ORDER and mod.category != "undefined":
return mod.category
name = mod.name or ""
# pzmm-style content fingerprint takes precedence over name regex when
# available. Empty mod_types means files_manifest_built=false (older
# cached row); fall through to existing cascade.
if mod.mod_types:
cat = _types_to_category(mod.mod_types, name)
if cat:
return cat
if name and _PATCH_NAME_RE.search(name):
return "patch"
if _name_has(name, _LIB_NAME_HINTS) or (name and _LIB_NAME_RE.search(name)):