Add game support detection

Part of #232
This commit is contained in:
rubenwardy 2022-02-01 20:53:59 +00:00
parent 765b5603c1
commit d529634b7f
6 changed files with 256 additions and 3 deletions

@ -16,15 +16,17 @@
import os import os
import sys
from typing import List from typing import List
import requests import requests
from celery import group 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 sqlalchemy import or_, and_
from app.logic.game_support import GameSupportResolver
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \ 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.forumtasks import importTopicList, checkAllForumAccounts
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
from app.utils import addNotification, get_system_user from app.utils import addNotification, get_system_user
@ -321,3 +323,10 @@ def update_screenshot_sizes():
screenshot.height = height screenshot.height = height
db.session.commit() db.session.commit()
@action("Detect game support")
def detect_game_support():
resolver = GameSupportResolver()
resolver.update_all()
db.session.commit()

161
app/logic/game_support.py Normal file

@ -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 <https://www.gnu.org/licenses/>.
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)

@ -344,6 +344,25 @@ class Dependency(db.Model):
return retval 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): class Package(db.Model):
query_class = PackageQuery 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]) 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") tags = db.relationship("Tag", secondary=Tags, back_populates="packages")
content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages") content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages")
@ -471,6 +496,11 @@ class Package(db.Model):
def getSortedOptionalDependencies(self): def getSortedOptionalDependencies(self):
return self.getSortedDependencies(False) 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): def getAsDictionaryKey(self):
return { return {
"name": self.name, "name": self.name,

@ -13,6 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import json import json
import os, shutil, gitdb import os, shutil, gitdb
from zipfile import ZipFile from zipfile import ZipFile
@ -22,10 +23,11 @@ from kombu import uuid
from app.models import * from app.models import *
from app.tasks import celery, TaskError 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 app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir
from .minetestcheck import build_tree, MinetestCheckError, ContentType from .minetestcheck import build_tree, MinetestCheckError, ContentType
from ..logic.LogicError import LogicError from ..logic.LogicError import LogicError
from ..logic.game_support import GameSupportResolver
from ..logic.packages import do_edit_package, ALIASES from ..logic.packages import do_edit_package, ALIASES
from ..utils.image import get_image_size from ..utils.image import get_image_size
@ -113,6 +115,10 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
for meta in getMetaPackages(optional_depends): for meta in getMetaPackages(optional_depends):
db.session.add(Dependency(package, meta=meta, optional=True)) db.session.add(Dependency(package, meta=meta, optional=True))
# Update game supports
resolver = GameSupportResolver()
resolver.update(package)
# Update min/max # Update min/max
if tree.meta.get("min_minetest_version"): if tree.meta.get("min_minetest_version"):
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None) release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)

@ -419,6 +419,19 @@
</dl> </dl>
{% endif %} {% endif %}
{% if package.type == package.type.MOD %}
<h3>{{ _("Supported Games") }}</h3>
{% for support in package.getSortedSupportedGames() %}
<a class="badge badge-primary"
href="{{ support.game.getURL('packages.view') }}">
{{ _("%(title)s by %(display_name)s",
title=support.game.title, display_name=support.game.author.display_name) }}
</a>
{% else %}
{{ _("No specifc game is required") }}
{% endfor %}
{% endif %}
<h3> <h3>
{{ _("Information") }} {{ _("Information") }}
</h3> </h3>

@ -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')