Skip to content

Mod relations: dependencies and incompatibilities between mods - #117

Open
Pixnop wants to merge 2 commits into
anegostudios:stagingfrom
Pixnop:master
Open

Mod relations: dependencies and incompatibilities between mods#117
Pixnop wants to merge 2 commits into
anegostudios:stagingfrom
Pixnop:master

Conversation

@Pixnop

@Pixnop Pixnop commented May 11, 2026

Copy link
Copy Markdown

Why

The motivating use case is simpler modpack creation. Today, building a modpack means manually tracking which mods need which other mods, which versions are compatible, which mods conflict, and rebuilding that whole web by hand whenever a member mod is updated. With a first-class relations layer, a modpack mod can declare its members through required relations and the moddb knows the install graph - launchers can then consume it through the install-information API instead of each tool reinventing dependency tracking.

The system is general (any mod can declare any kind of relation), but modpacks are the headline use case it unlocks. Refs #54.

Summary

Adds a first-class mod-relations system, scoped per release, covering four relation kinds:

  • required - target mod must be installed (auto-derived from modinfo.json, also overridable manually)
  • optional - recommended but not blocking (manual only)
  • incompatible - must not be installed together (manual only)
  • tested_with - explicit confirmation that the two mods are known to work together (manual only)

Surfaces relations on the public mod page (4 sections in the infobox), in a dedicated editor section on the edit-release page, and via a backward-compatible extension of the install-information API that exposes transitive resolution + warnings.

Closes #55 (Dependency graph - provides the underlying data that issue noted was missing).
Refs #13 (the original Mod dependencies suggestion, closed) and #54 (Modpacks).

Why per-release

Relations are pinned to a specific releaseId (not to a mod), so a mod page that hosts multiple identifiers (e.g. mymod and mymodlinux) declares relations independently per release. On a new release upload, manual relations carry forward from the previous release of the same identifier as a template (much like the existing modid/version autofill), and auto-detected required relations are independently re-derived from the new release's own rawDependencies.

Note on the existing show-dependencies branch

The upstream show-dependencies branch (last touched Nov 2025, 69 commits behind master, no PR) prototypes a separate show-dependencies.php page with a tree-view solver. This PR takes a different shape:

  • Inline in the existing show-mod infobox rather than a dedicated page - relations are discoverable without an extra click.
  • Editable through edit-release with an auto/manual split rather than only display.
  • API-first: the transitive resolver is exposed via install-information?resolve-deps=1 so launchers / modpack tooling can consume it directly.

The show-dependencies branch's solver concepts (cycle detection, tree resolution) match what's implemented here in bfsResolve; we add diamond dedup, version-range conflict detection, depth-limit cutoff, and incompatibility post-pass. Happy to fold in any specific feature from that prototype (mod-card overview, dedicated tree page, etc.) as follow-ups if useful.

Highlights

  • New table modRelations with one schema covering all 4 relation kinds + version ranges + auto/manual origin, strictly per-release (releaseId NOT NULL, unique on (releaseId, targetIdentifier, relationType)).
  • Auto-detected required relations are re-synced from modPeekResults.rawDependencies on every release upload (no modder action needed - their existing modinfo.json deps surface automatically).
  • Manual layer lets modders add optional / incompatible / tested-with declarations or override an auto-detected version, per release.
  • Manual relations clone forward from the previous release of the same identifier on each new release (template-style autofill).
  • BFS-based transitive resolver with parent-chain cycle detection (handles direct, indirect, self cycles, diamonds, version conflicts, missing deps, depth-limit truncation). The resolver picks each identifier's release first, then loads that release's relations - so the graph reflects exactly the release being installed.
  • API: GET /api/v2/mods/install-information?ids=...&resolve-deps=1 returns resolved (transitive tree, emitted in install order, dependencies before dependents), installOrder (the same sequence as a plain array for clients that do not preserve JSON object key order) + warnings with stable machine-readable kinds. Without the flag, response shape is byte-identical to before.
  • Backfill migration (db/144_migrate.php) populates modRelations from existing modPeekResults rows.

On the game-side integration

The game side is your call, not something I can contribute to. A few notes on how this PR tries not to constrain your future choices there:

  • The current in-game pinned-or-open contract isn't broken. modPeekResults.rawDependencies is still the source of truth for what the game sees today; this PR only mirrors it into modRelations as origin = 'auto'. Nothing in this PR exposes the manual / version-range layer to the game resolver.
  • Version ranges and the non-required kinds (optional, incompatible, tested_with) are moddb-only at this point. They're surfaced through install-information?resolve-deps=1 for tools that opt in (launchers, dependency-graph viewers), and as warnings rather than hard constraints, so the in-game resolver can keep its current semantics without contradiction.
  • Offline scenarios are unaffected since the resolver lives server-side here.

So if it stays purely moddb-side forever, the value is still there for modpack authors and for launchers consuming the new API field.

Screenshots

Public mod page - infobox with all 4 relation sections (auto-resolved mods linked, unresolved targets in italic, incompatible marked):

pr-v2-01-show-mod-infobox

Edit-release page - relations editor in context:

pr-v2-02-edit-release-relations

Edit-release editor - close-up of the auto/manual split (auto section is read-only, manual section lets you add/edit/X-remove rows):

pr-v2-03-edit-release-relations-zoom

API example

Backward-compat (no resolve-deps):

{ "data": { "rel-test-A": { "fileName": "...", "fileUrl": "..." } } }

With resolve-deps=1:

{
  "data": { "rel-test-A": { "fileName": "...", "fileUrl": "..." } },
  "resolved": {
    "rel-test-A": { "identifier": "rel-test-A", "version": 281483566907391, "fileName": "...", "fileUrl": "...", "requiredBy": ["<root>"], "depth": 0 }
  },
  "installOrder": ["rel-test-A"],
  "warnings": [
    { "kind": "tested_with_unmet", "from": "rel-test-A", "identifier": "rel-test-C" }
  ]
}

Warning kinds: cycle (with path), incompatible (with between + declaredBy), missing_dep (with identifier + requiredBy), version_conflict (with raw ranges), optional_unmet / tested_with_unmet, depth_limit. The vocabulary is declared as WARN_* constants in lib/relations.php and is a stable contract; clients can branch on kind.

Tests

  • tests/relations-pure.php - 38 pure unit tests (parser, range merge, BFS resolver, cycle detection, cycle guard, install order).
  • tests/relations-integration.php - 25 DB integration tests (CRUD per-release, sync, manual-wins merge, edit-view split, FK cascade on release delete, clone-from-previous-release, dangling target resolution, transitive resolution, form persistence, latest-release surfacing).
  • tests/api-install-information.php - 2 cURL-based E2E tests (backward-compat + resolve-deps shape incl. installOrder).
  • Full suite: 124 tests, 695 assertions, 1 pre-existing baseline failure (ApiV1Test::modIdentifier, caused by broken db/999_sampledata.sql on comments.textShort - out of scope of this PR; happy to file a follow-up).

Test plan

  • docker compose -f docker/docker-compose.yml exec php php tests/phpunit.phar --test-suffix=.php tests -> 124 tests, 1 baseline failure only
  • Manual: edit a release, add manual relations of each type, save, verify they persist
  • Manual: mark a manual relation for removal via the X button, save, verify it's gone
  • Manual: upload a new release of the same mod, verify the manual relations carried forward as template
  • Manual: open the public mod page, verify the 4 sections render correctly (italic for unresolved, strikethrough for incompatible)
  • Apply db/143_migrate.sql then run php db/144_migrate.php and verify modRelations is populated from existing modPeekResults rows
  • curl the install-information endpoint with and without resolve-deps=1 and confirm shapes

Notes for reviewers

  • resolveDanglingTargets is called from createNewRelease() (not createNewMod()) - this is intentional: a mod's identifier is set on its release, not the mod itself, so the retro-link can only happen once the first release lands.
  • _hydrateResolvedMod runs once per BFS node during transitive resolution. Flagged as a v2 optimization (dedicated loader that skips hydration for resolver use); not a blocker at current scale.
  • The unique index is straightforwardly (releaseId, targetIdentifier, relationType) - no NULL-distinct semantics involved.

cc @SaculRennorb (issue #55 author, started the show-dependencies prototype)

@Pixnop
Pixnop marked this pull request as ready for review May 11, 2026 19:36

@SaculRennorb SaculRennorb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My focus is currently elsewhere, so be warned that this will take a while for me to review, and most importantly think about, before any of it will make it to the live site.

Please rebase this onto the current staging, recent rewrites seem to have somewhat messed up the pr.

Some initial feedback:
I don't think it makes sense to associate dependencies with a mods page directly, especially the edit interface for them.
Dependencies really are per-release, and should be treated that way. "Mod" pages can manage multiple different mod identifiers, which might have wildly different dependencies (e.g. mymod, and mymodlinux).
I think it would be best to have the edit interface on the release edit page, which also resolves some of the issue with the late binding of information.
To alleviate the annoyance of having to re-input the information for every release, the system can autofill dependencies from the previous release of the same mod identifier, as a sort of template, much like auto-filling mod version and identifier works.

This system directly provides contradictory information to what the game uses for dependency resolution at the moment.
The game uses only pinned or completely open dependencies, but this system allows to extend or restrict these.
Since dependency resolution also needs to work in offline environments there are quite a few scenarios to consider here, which this does not address. This pr is really only concerned wit the moddb - which is reasonable ofcourse - but extending this system also needs integration in the game to be meaningful.
That already is a reason this is going to take a while to work though.

From a cursory glance at least the concepts seem reasonable.

Comment thread tests/relations-integration.php Outdated
@Pixnop

Pixnop commented May 15, 2026

Copy link
Copy Markdown
Author

Thanks for the review @SaculRennorb! Pushed a revised version (force-push on master, since it's the PR head). I've also updated the PR description above to reflect the new model and motivation; this comment is just the changelog vs a95ddb9.

# Your feedback What I did
1 "Please rebase this onto the current staging" Rebased onto upstream/staging. The 3 perf/fix commits were already cherry-picked into staging so git auto-dropped them; one conflict on lib/api/public/mods.php (your new releases endpoints vs my resolve-deps=1 block), resolved by keeping both. The PR is now a single commit on top of staging.
2 Dependencies per-release, not per-mod (mymod vs mymodlinux case) modRelations.releaseId is now NOT NULL, the sourceModId column is gone, and the unique index is (releaseId, targetIdentifier, relationType). No more "applies to the whole mod" code path - every relation belongs to exactly one release.
3 "edit interface on the release edit page" Moved the editor from edit-mod.tpl to edit-release.tpl (template renamed edit-release-relations.tpl). edit-mod.php no longer touches relations. The release page scopes its editor to that specific release's relations only, which naturally handles the mymod vs mymodlinux case.
4 "autofill from the previous release of the same mod identifier, as a sort of template" Added cloneManualRelationsFromPreviousRelease(), called from createNewRelease(). On a new release upload it looks up the previous release of the same identifier and copies its manual rows forward; auto-detected required rows are independently re-derived from the new release's own rawDependencies. Covered by testCloneManualRelationsFromPreviousReleaseCopiesManualOnly.
5 Inline: 1 /*ASSETTYPE_MOD*/ -> use the constant Replied on the thread - done in a9624c2 (now using ASSETTYPE_MOD / STATUS_RELEASED constants throughout the fixture).

On the in-game integration: I've added a section in the updated description framing what stays moddb-only here (version ranges and the non-required kinds) so the current pinned-or-open contract in the game isn't broken. Game-side is your call; let me know if anything in the moddb-side design would make your future work there harder than necessary.

Note for testers re-applying

The schema change is essentially DROP COLUMN sourceModId + MODIFY releaseId NOT NULL from the previous version of this PR. Since 134_migrate.sql uses CREATE TABLE IF NOT EXISTS, anyone who applied the old 134 will need to DROP TABLE modRelations first, then reapply. Happy to refactor 134 as an idempotent ALTER TABLE block if you'd prefer.

@Pixnop

Pixnop commented May 17, 2026

Copy link
Copy Markdown
Author

Small follow-up in 5f1c7af while waiting on review - took care of two items I'd flagged as v2 / "small limit" in my own notes:

1. _hydrateResolvedMod no longer runs on the BFS resolver path. Extracted _loadDedupedRelationsForRelease() (the de-duped row loader, no hydration) and the resolver now uses that directly. getRelationsForRelease() still hydrates for display callers. So that "v2 optimization" caveat in the reviewer notes is no longer accurate - dropping it.

2. install-information?resolve-deps=1 now honors the @version from the URL. Previously the resolver re-picked the latest version of each root identifier, which could differ from what the user requested as foo@1.2.3. resolveTransitiveDeps() now accepts an optional $rootReleaseMap arg pre-seeding the picked-releases cache; the endpoint builds that map from the (identifier, version) rows it already looks up. Transitive deps still fall through to pickReleaseForIdentifier. New test testResolveTransitiveDepsRespectsExplicitRootRelease covers it (creates two releases of the same identifier with different deps, confirms the explicit root is honored).

@Lueken

Lueken commented Jul 22, 2026

Copy link
Copy Markdown

I'm building a VS launcher and this is the piece I'd rather consume than reinvent, so a big +1 from the client side. Deriving required relations from modinfo.json and exposing transitive resolution over install-information?resolve-deps=1 is the right shape for one-click modpack installs, and pinning relations per release matches how compatibility actually works.

Two things that would help a launcher consume it cleanly:

  1. Stable, enumerated warning/conflict codes (rather than only human-readable strings), so a client can branch on them without string matching.
  2. The resolved set returned in install order (dependencies before dependents), so the launcher can write mods in one pass without its own topological sort.

Either way, glad to be an early test consumer as this lands. Really nice work, and thanks for picking this up.

@Lueken Lueken mentioned this pull request Jul 22, 2026
Lueken added a commit to Lueken/translocator that referenced this pull request Jul 22, 2026
- deps.rs: parse modinfo.json (JSON5, case-insensitive) from mod zips; report
  required deps missing from an install; skip base game (game/survival/creative)
- check_deps command
- App.tsx: after install, transitively install missing deps from ModDB by modid
- upgrades to install-information?resolve-deps=1 later (anegostudios/vsmoddb#117)
@SaculRennorb
SaculRennorb force-pushed the staging branch 2 times, most recently from ef6f567 to 489dcc9 Compare August 17, 2026 15:11
Pixnop added 2 commits August 24, 2026 15:51
Adds a first-class mod-relations system between mods, scoped per
release. Four relation kinds: required (auto-detected from
modinfo.json, also overridable), optional, incompatible, tested_with
(manual only).

Relations are pinned to a specific release so mods hosting multiple
identifiers can declare different dependencies per release. On a new
release upload, manual relations carry forward from the previous
release of the same identifier as a template; auto-detected required
relations are re-derived from the new release's own rawDependencies.

Surfaces relations:
- public mod page: latest release's merged auto+manual sections
- edit-release page: split auto/manual editor
- install-information API: optional resolve-deps=1 returns the
  transitive tree and warnings (cycle, incompatible, missing_dep,
  version_conflict, optional_unmet, tested_with_unmet, depth_limit).
  Without the flag, the response shape is byte-identical to before.

Includes db/143_migrate.sql (schema), db/144_migrate.php (backfill
from modPeekResults.rawDependencies), 32 pure unit tests, 25 DB
integration tests, and 2 cURL E2E tests.
Address launcher-consumer feedback on the resolve-deps payload:

- Declare the warning `kind` vocabulary as constants (WARN_CYCLE,
  WARN_DEPTH_LIMIT, WARN_MISSING_DEP, WARN_INCOMPATIBLE,
  WARN_VERSION_CONFLICT, WARN_OPTIONAL_UNMET, WARN_TESTED_WITH_UNMET)
  and document it as a stable contract clients can branch on.

- Emit `resolved` in install order, dependencies before dependents,
  via Kahn's algorithm over the requiredBy edges, with BFS discovery
  order as deterministic tie-break. Also expose the same sequence as
  a plain `installOrder` array for JSON clients that do not preserve
  object key order. Consumers can apply the set in one forward pass
  without needing their own topological sort.

Covered by 6 new pure resolver tests plus integration and E2E
assertions on the endpoint payload.
@Pixnop

Pixnop commented Aug 24, 2026

Copy link
Copy Markdown
Author

Rebased onto current staging (28d1753), the PR merges cleanly again. Two commits this time, so what changed since your last read is easy to isolate.

Commit 1 is the feature as reviewed, adapted to the new staging:

  • migrations renumbered to db/143_migrate.sql (schema) and db/144_migrate.php (backfill), since staging grew its own 134-142 in the meantime
  • test fixtures now set mods.category and files.size explicitly to match the current schema
  • dropped my no-chosen tweak in web/_ts/on-dom-loaded.ts, staging already ships the same check, so web/js/script.js is untouched now
  • relations section in edit-release.tpl re-anchored after the inlined file list (edit-asset-files.tpl is gone)
  • style.css recompiled, cachebusting bumped to 110

Commit 2 picks up @Lueken's two points. Warnings already carried a machine-readable kind; that vocabulary is now declared as constants (WARN_CYCLE, WARN_DEPTH_LIMIT, WARN_MISSING_DEP, WARN_INCOMPATIBLE, WARN_VERSION_CONFLICT, WARN_OPTIONAL_UNMET, WARN_TESTED_WITH_UNMET) and documented as a stable contract, so clients can branch on kind and any change there counts as an API break. And resolved now comes back in install order, dependencies before dependents (Kahn over the requiredBy edges), with a plain installOrder array alongside for JSON clients that don't preserve object key order. One forward pass, no client-side toposort. Six new pure resolver tests cover the ordering, plus integration and E2E assertions on the payload.

Full suite on the docker dev env: 124 tests, everything green except ApiV1Test::modIdentifier, which fails identically on pristine staging. 999_sampledata.sql creates no releases, so the maltiezcrossbows lookup 404s, and a fresh docker DB init stops on the legacy 100_migrate.sql anyway. Happy to file that separately.

@Pixnop
Pixnop requested a review from SaculRennorb August 24, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants