diff --git a/app/blueprints/admin/actions.py b/app/blueprints/admin/actions.py
index d584e392..8f4e1e7c 100644
--- a/app/blueprints/admin/actions.py
+++ b/app/blueprints/admin/actions.py
@@ -16,15 +16,17 @@
import os
+import sys
from typing import List
import requests
from celery import group
-from flask import redirect, url_for, flash, current_app
+from flask import redirect, url_for, flash, current_app, jsonify
from sqlalchemy import or_, and_
+from app.logic.game_support import GameSupportResolver
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
- NotificationType, PackageUpdateConfig, License, UserRank, PackageType
+ NotificationType, PackageUpdateConfig, License, UserRank, PackageType, PackageGameSupport
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
from app.utils import addNotification, get_system_user
@@ -321,3 +323,10 @@ def update_screenshot_sizes():
screenshot.height = height
db.session.commit()
+
+
+@action("Detect game support")
+def detect_game_support():
+ resolver = GameSupportResolver()
+ resolver.update_all()
+ db.session.commit()
diff --git a/app/logic/game_support.py b/app/logic/game_support.py
new file mode 100644
index 00000000..19ddd4cc
--- /dev/null
+++ b/app/logic/game_support.py
@@ -0,0 +1,161 @@
+# ContentDB
+# Copyright (C) 2022 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+import sys
+
+from typing import List, Dict
+
+from app.logic.LogicError import LogicError
+from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport, db
+
+"""
+get_game_support(package):
+ if package is a game:
+ return [ package ]
+
+ for all hard dependencies:
+ support = support AND get_meta_package_support(dep)
+
+ return support
+
+get_meta_package_support(meta):
+ for package implementing meta package:
+ support = support OR get_game_support(package)
+
+ return support
+"""
+
+
+minetest_game_mods = {
+ "beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
+ "give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
+ "binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
+ "keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
+}
+
+
+mtg_mod_blacklist = {
+ "repixture", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
+ "survivethedays"
+}
+
+
+class GameSupportResolver:
+ checked_packages = set()
+ checked_metapackages = set()
+ resolved_packages = {}
+ resolved_metapackages = {}
+
+ def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> set[Package]:
+ print(f"Resolving for {meta.name}", file=sys.stderr)
+
+ key = meta.name
+ if key in self.resolved_metapackages:
+ return self.resolved_metapackages.get(key)
+
+ if key in self.checked_metapackages:
+ print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
+ return set()
+
+ self.checked_metapackages.add(key)
+
+ retval = set()
+
+ for package in meta.packages:
+ if package.state != PackageState.APPROVED:
+ continue
+
+ if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
+ continue
+
+ ret = self.resolve(package, history)
+ if len(ret) == 0:
+ retval = set()
+ break
+
+ retval.update(ret)
+
+ self.resolved_metapackages[key] = retval
+ return retval
+
+ def resolve(self, package: Package, history: List[str]) -> set[Package]:
+ key = "{}/{}".format(package.author.username.lower(), package.name)
+ print(f"Resolving for {key}", file=sys.stderr)
+
+ history = history.copy()
+ history.append(key)
+
+ if package.type == PackageType.GAME:
+ return {package}
+
+ if key in self.resolved_packages:
+ return self.resolved_packages.get(key)
+
+ if key in self.checked_packages:
+ print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
+ return set()
+
+ self.checked_packages.add(key)
+
+ if len(history) >= 50:
+ raise LogicError(500, f"Too deep! {', '.join(history)}")
+
+ if package.type != PackageType.MOD:
+ raise LogicError(500, "Got non-mod")
+
+ retval = set()
+
+ for dep in package.dependencies.filter_by(optional=False).all():
+ ret = self.resolve_for_meta_package(dep.meta_package, history)
+ if len(ret) == 0:
+ continue
+ elif len(retval) == 0:
+ retval.update(ret)
+ else:
+ retval.intersection_update(ret)
+ if len(retval) == 0:
+ raise LogicError(500, f"Conflict! Supported games narrowed at {key}")
+
+ self.resolved_packages[key] = retval
+ return retval
+
+ def update_all(self) -> None:
+ for package in Package.query.filter_by(type=PackageType.MOD, state=PackageState.APPROVED).all():
+ retval = self.resolve(package, [])
+ for game in retval:
+ support = PackageGameSupport(package, game)
+ db.session.add(support)
+
+ def update(self, package: Package) -> None:
+ previous_supported: Dict[Package, PackageGameSupport] = {}
+ for support in package.supported_games.all():
+ previous_supported[support.game] = support
+
+ retval = self.resolve(package, [])
+ for game in retval:
+ lookup = previous_supported.pop(game, None)
+ if lookup:
+ if lookup.confidence == 0:
+ lookup.supports = True
+ db.session.merge(lookup)
+ else:
+ support = PackageGameSupport(package, game)
+ db.session.add(support)
+
+ for game, support in previous_supported.items():
+ if support.confidence == 0:
+ db.session.remove(support)
diff --git a/app/models/packages.py b/app/models/packages.py
index 78f4357a..4cf7b8ce 100644
--- a/app/models/packages.py
+++ b/app/models/packages.py
@@ -344,6 +344,25 @@ class Dependency(db.Model):
return retval
+class PackageGameSupport(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+
+ package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
+ package = db.relationship("Package", foreign_keys=[package_id])
+
+ game_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
+ game = db.relationship("Package", foreign_keys=[game_id])
+
+ supports = db.Column(db.Boolean, nullable=False, default=True)
+ confidence = db.Column(db.Integer, nullable=False, default=0)
+
+ __table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),)
+
+ def __init__(self, package, game):
+ self.package = package
+ self.game = game
+
+
class Package(db.Model):
query_class = PackageQuery
@@ -396,6 +415,12 @@ class Package(db.Model):
dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
+ supported_games = db.relationship("PackageGameSupport", back_populates="package", lazy="dynamic",
+ foreign_keys=[PackageGameSupport.package_id])
+
+ game_supported_mods = db.relationship("PackageGameSupport", back_populates="game", lazy="dynamic",
+ foreign_keys=[PackageGameSupport.game_id])
+
tags = db.relationship("Tag", secondary=Tags, back_populates="packages")
content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages")
@@ -471,6 +496,11 @@ class Package(db.Model):
def getSortedOptionalDependencies(self):
return self.getSortedDependencies(False)
+ def getSortedSupportedGames(self):
+ supported = self.supported_games.all()
+ supported.sort(key=lambda x: -x.game.score)
+ return supported
+
def getAsDictionaryKey(self):
return {
"name": self.name,
diff --git a/app/tasks/importtasks.py b/app/tasks/importtasks.py
index 141157cb..cb2e59e2 100644
--- a/app/tasks/importtasks.py
+++ b/app/tasks/importtasks.py
@@ -13,6 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+
import json
import os, shutil, gitdb
from zipfile import ZipFile
@@ -22,10 +23,11 @@ from kombu import uuid
from app.models import *
from app.tasks import celery, TaskError
-from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_system_user
+from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir
from .minetestcheck import build_tree, MinetestCheckError, ContentType
from ..logic.LogicError import LogicError
+from ..logic.game_support import GameSupportResolver
from ..logic.packages import do_edit_package, ALIASES
from ..utils.image import get_image_size
@@ -113,6 +115,10 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
for meta in getMetaPackages(optional_depends):
db.session.add(Dependency(package, meta=meta, optional=True))
+ # Update game supports
+ resolver = GameSupportResolver()
+ resolver.update(package)
+
# Update min/max
if tree.meta.get("min_minetest_version"):
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
diff --git a/app/templates/packages/view.html b/app/templates/packages/view.html
index d0086112..f27f2f2f 100644
--- a/app/templates/packages/view.html
+++ b/app/templates/packages/view.html
@@ -419,6 +419,19 @@
{% endif %}
+ {% if package.type == package.type.MOD %}
+
{{ _("Supported Games") }}
+ {% for support in package.getSortedSupportedGames() %}
+
+ {{ _("%(title)s by %(display_name)s",
+ title=support.game.title, display_name=support.game.author.display_name) }}
+
+ {% else %}
+ {{ _("No specifc game is required") }}
+ {% endfor %}
+ {% endif %}
+
{{ _("Information") }}
diff --git a/migrations/versions/e571b3498f9e_.py b/migrations/versions/e571b3498f9e_.py
new file mode 100644
index 00000000..73cb35fa
--- /dev/null
+++ b/migrations/versions/e571b3498f9e_.py
@@ -0,0 +1,34 @@
+"""empty message
+
+Revision ID: e571b3498f9e
+Revises: 3710e5fbbe87
+Create Date: 2022-02-01 19:30:59.537512
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = 'e571b3498f9e'
+down_revision = '3710e5fbbe87'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table('package_game_support',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('package_id', sa.Integer(), nullable=False),
+ sa.Column('game_id', sa.Integer(), nullable=False),
+ sa.Column('supports', sa.Boolean(), nullable=False),
+ sa.Column('confidence', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['game_id'], ['package.id'], ),
+ sa.ForeignKeyConstraint(['package_id'], ['package.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('game_id', 'package_id', name='_package_game_support_uc')
+ )
+
+
+def downgrade():
+ op.drop_table('package_game_support')