diff --git a/app/blueprints/admin/actions.py b/app/blueprints/admin/actions.py index d584e39..8f4e1e7 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 0000000..19ddd4c --- /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 78f4357..4cf7b8c 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 141157c..cb2e59e 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 d008611..f27f2f2 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 0000000..73cb35f --- /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')