backup for Phase 2.5
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.3 on 2026-08-04 04:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0006_version_mods_manifest_version_pack_description_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='version',
|
||||
name='animation_id',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='version',
|
||||
name='animation_manifest',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -209,6 +209,8 @@ class Version(models.Model):
|
||||
pack_format = models.IntegerField(null=True, blank=True)
|
||||
pack_description = models.TextField(blank=True, default='')
|
||||
mods_manifest = models.JSONField(default=dict, blank=True)
|
||||
animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True)
|
||||
animation_manifest = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1142,3 +1142,181 @@ class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
version = self.project.versions.first()
|
||||
self.assertIsNone(version.pack_format)
|
||||
self.assertEqual(version.mods_manifest, {})
|
||||
|
||||
|
||||
def _animation_pack_zip(with_id=True, entity_x_player=False, problem=True):
|
||||
actor_a = {
|
||||
'label': 'actor1', 'entity_types': ['minecraft:player'],
|
||||
'actor_tags': ['gender.male'], 'activity': 'active', 'injector': 'V',
|
||||
}
|
||||
actor_b = {
|
||||
'label': 'actor2',
|
||||
'entity_types': ['minecraft:zombie'] if entity_x_player else ['minecraft:player'],
|
||||
'actor_tags': [] if entity_x_player else ['gender.female'],
|
||||
'activity': 'passive', 'receiver': True,
|
||||
}
|
||||
content_tags = ['missionary'] + (['bugged'] if problem else [])
|
||||
af = {
|
||||
'id': 'jakebreath:testpack', 'name': 'Test Pack', 'author': 'Alice, Bob',
|
||||
'version': '1.0.0', 'description': 'A test pack.',
|
||||
} if with_id else {'name': 'Test Pack', 'author': 'Alice'}
|
||||
return _zip_bytes({
|
||||
'pack.mcmeta': json.dumps({'pack': {'pack_format': 64}, 'animationframework': af}),
|
||||
'data/jakebreath/afw_animdefs/ground.json': json.dumps({
|
||||
'display_name': 'Ground', 'content_tags': content_tags,
|
||||
'actors': [actor_a, actor_b],
|
||||
'stages': [{'stage': 1, 'loop': True, 'cycle_seconds': 1.0},
|
||||
{'stage': 2, 'loop': False, 'non_peak': True}],
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
def _bom_pack_zip():
|
||||
mcmeta = b'\xef\xbb\xbf' + json.dumps({
|
||||
'pack': {'pack_format': 64},
|
||||
'animationframework': {'id': 'needsofnature:default', 'name': 'Default', 'author': 'NoN Team'},
|
||||
}).encode()
|
||||
return _zip_bytes({
|
||||
'pack.mcmeta': mcmeta,
|
||||
'data/needsofnature/afw_animdefs/solo.json': json.dumps({
|
||||
'actors': [{'label': 'actor1', 'entity_types': ['minecraft:player'], 'activity': 'passive'}],
|
||||
'content_tags': ['solo'],
|
||||
'stages': [{'stage': 1, 'loop': True}],
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
class UGCAnimationTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.alice = User.objects.create_user(username='Alice', password='pw')
|
||||
UserProfile.objects.get_or_create(user=self.alice)
|
||||
|
||||
def _write_zip(self, data):
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
|
||||
f.write(data)
|
||||
return f.name
|
||||
|
||||
def _upload_version(self, slug, name, content):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.post(
|
||||
reverse('library:api_upload_temp'),
|
||||
{'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return self.client.post(
|
||||
reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'},
|
||||
)
|
||||
|
||||
def test_read_animation_manifest_player_x_player(self):
|
||||
from library.zips import read_animation_manifest
|
||||
path = self._write_zip(_animation_pack_zip())
|
||||
try:
|
||||
manifest = read_animation_manifest(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
self.assertEqual(manifest['animation_id'], 'jakebreath:testpack')
|
||||
self.assertEqual(manifest['authors'], ['Alice', 'Bob'])
|
||||
anim = manifest['animations'][0]
|
||||
self.assertEqual(anim['name'], 'Ground')
|
||||
self.assertEqual(anim['type'], 'Pair')
|
||||
self.assertEqual(anim['content_tags'], ['missionary', 'bugged'])
|
||||
self.assertEqual(anim['problem_tags'], ['bugged'])
|
||||
self.assertEqual(anim['actors'][0]['injector'], 'V')
|
||||
self.assertEqual(anim['actors'][0]['injector_name'], 'Vaginal')
|
||||
self.assertEqual(anim['actors'][1]['gender'], 'female')
|
||||
self.assertEqual(anim['stages'][1]['climax'], True)
|
||||
|
||||
def test_read_animation_manifest_entity_x_player(self):
|
||||
from library.zips import read_animation_manifest
|
||||
path = self._write_zip(_animation_pack_zip(entity_x_player=True))
|
||||
try:
|
||||
manifest = read_animation_manifest(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
anim = manifest['animations'][0]
|
||||
self.assertEqual(anim['type'], 'Entity x Player')
|
||||
self.assertEqual(anim['actors'][1]['entity'], 'Zombie')
|
||||
self.assertEqual(anim['entity_names'], ['Zombie'])
|
||||
|
||||
def test_read_animation_manifest_bom_mcmeta(self):
|
||||
from library.zips import read_animation_manifest, read_pack_mcmeta
|
||||
path = self._write_zip(_bom_pack_zip())
|
||||
try:
|
||||
fmt, desc = read_pack_mcmeta(path)
|
||||
manifest = read_animation_manifest(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
self.assertEqual(fmt, 64)
|
||||
self.assertEqual(manifest['animation_id'], 'needsofnature:default')
|
||||
self.assertEqual(manifest['animations'][0]['type'], 'Solo')
|
||||
|
||||
def test_read_animation_manifest_not_animation(self):
|
||||
from library.zips import read_animation_manifest
|
||||
path = self._write_zip(_zip_bytes({'data/x.txt': 'x'}))
|
||||
try:
|
||||
manifest = read_animation_manifest(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
self.assertIsNone(manifest)
|
||||
|
||||
def test_version_upload_captures_manifest_and_tags(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']))
|
||||
version = self.project.versions.first()
|
||||
self.assertEqual(version.animation_id, 'jakebreath:testpack')
|
||||
self.assertEqual(version.animation_manifest['animations'][0]['name'], 'Ground')
|
||||
# Auto-created content tags (including the problem tag).
|
||||
names = set(TagList.objects.filter(project=self.project).values_list('tag__name', flat=True))
|
||||
self.assertIn('missionary', names)
|
||||
self.assertIn('bugged', names)
|
||||
|
||||
def test_version_upload_warns_without_animation_id(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']),
|
||||
fetch_redirect_response=False)
|
||||
version = self.project.versions.first()
|
||||
self.assertEqual(version.animation_id, '')
|
||||
self.assertTrue(version.animation_manifest)
|
||||
# The warning banner renders on the redirect target (before it's consumed).
|
||||
resp = self.client.get(reverse('library:project_detail', args=['anim-pack']))
|
||||
self.assertContains(resp, 'incompatible with the update-check API')
|
||||
|
||||
def test_latest_api_returns_version_and_url(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertEqual(data['version'], '1.0.0')
|
||||
self.assertEqual(data['pack_format'], 64)
|
||||
self.assertIn('/api/files/', data['download_url'])
|
||||
self.assertIn('?download=1', data['download_url'])
|
||||
|
||||
def test_latest_api_404_unknown_pack(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.get(reverse('library:api_packs_latest', args=['nope', 'missing']))
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_latest_api_404_without_animation_id(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
@@ -22,5 +22,6 @@ urlpatterns = [
|
||||
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
|
||||
path('api/draft/', views.api_draft, name='api_draft'),
|
||||
path('api/tags/autocomplete/', views.api_tags_autocomplete, name='api_tags_autocomplete'),
|
||||
path('api/packs/<str:namespace>/<str:pack_id>/latest', views.api_packs_latest, name='api_packs_latest'),
|
||||
path('api/users/autocomplete/', views.api_users_autocomplete, name='api_users_autocomplete'),
|
||||
]
|
||||
|
||||
+107
-10
@@ -30,7 +30,7 @@ from .models import (
|
||||
slugify_tag,
|
||||
)
|
||||
from .storage import delete_file_index, move_file_index, store_temp_file
|
||||
from .zips import parse_mods_manifest, read_pack_mcmeta
|
||||
from .zips import parse_mods_manifest, read_animation_manifest, read_pack_mcmeta
|
||||
|
||||
|
||||
def _open_indexed_file(file_index):
|
||||
@@ -316,10 +316,24 @@ def project_detail(request, slug):
|
||||
can_edit = project.can_edit(request.user)
|
||||
|
||||
guide_docs = []
|
||||
if project.category == 'guide':
|
||||
latest = versions.first()
|
||||
if latest is not None:
|
||||
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
|
||||
animation_manifest = None
|
||||
anim_stats = None
|
||||
latest = versions.first()
|
||||
if project.category == 'guide' and latest is not None:
|
||||
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
|
||||
if latest is not None and latest.animation_manifest:
|
||||
animation_manifest = latest.animation_manifest
|
||||
anims = animation_manifest.get('animations') or []
|
||||
entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player')
|
||||
solo_count = sum(1 for a in anims if a.get('type') == 'Solo')
|
||||
anim_stats = {
|
||||
'total': len(anims),
|
||||
'entity': entity_count,
|
||||
'player': len(anims) - entity_count - solo_count,
|
||||
'solo': solo_count,
|
||||
'flagged': sum(1 for a in anims if a.get('problem_tags')),
|
||||
'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
|
||||
}
|
||||
|
||||
context = {
|
||||
'project': project,
|
||||
@@ -328,10 +342,43 @@ def project_detail(request, slug):
|
||||
'tags': tags,
|
||||
'can_edit': can_edit,
|
||||
'guide_docs': guide_docs,
|
||||
'animation_manifest': animation_manifest,
|
||||
'anim_stats': anim_stats,
|
||||
}
|
||||
return render(request, 'library/project_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def api_packs_latest(request, namespace, pack_id):
|
||||
"""Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
|
||||
animation_id = f'{namespace}:{pack_id}'
|
||||
version = (
|
||||
Version.objects.filter(animation_id=animation_id)
|
||||
.select_related('project').prefetch_related('files__file')
|
||||
.order_by('-created_at').first()
|
||||
)
|
||||
if version is None:
|
||||
return JsonResponse({'detail': 'Not found'}, status=404)
|
||||
release = next(
|
||||
(vf for vf in version.files.all() if vf.kind == 'release'),
|
||||
version.files.first(),
|
||||
)
|
||||
if release is None:
|
||||
return JsonResponse({'detail': 'No file'}, status=404)
|
||||
download_url = request.build_absolute_uri(
|
||||
reverse('library:file_request', args=[release.file.uuid]) + '?download=1'
|
||||
)
|
||||
return JsonResponse({
|
||||
'namespace': namespace,
|
||||
'pack_id': pack_id,
|
||||
'name': version.project.title,
|
||||
'version': version.version_name,
|
||||
'pack_format': version.pack_format,
|
||||
'created_at': version.created_at.isoformat(),
|
||||
'download_url': download_url,
|
||||
})
|
||||
|
||||
|
||||
def guide_doc(request, slug, version_id, file_uuid):
|
||||
"""Render one markdown guide document server-side (used by the switcher)."""
|
||||
version = get_object_or_404(
|
||||
@@ -410,9 +457,34 @@ def _stored_path(file_index):
|
||||
return Path(settings.MEDIA_ROOT) / file_index.stored_path
|
||||
|
||||
|
||||
def _finalize_version(project, version_name, changelog, temp_uploads):
|
||||
ANIMATION_API_WARNING = (
|
||||
'Warning: your pack is incompatible with the update-check API endpoint and '
|
||||
'may not work with applications that check for updates. Add an '
|
||||
"'animationframework' block to pack.mcmeta, e.g.: "
|
||||
'{"pack":{"pack_format":64},"animationframework":{"id":"yourname:yourpack",'
|
||||
'"name":"Your Pack","author":"You","version":"1.0.0","description":"..."}}'
|
||||
)
|
||||
|
||||
|
||||
def _apply_content_tags(project, tag_names, actor):
|
||||
"""Auto-create a content:<tag> for every distinct animation content tag."""
|
||||
content_cat = TagCategory.objects.filter(slug='content').first()
|
||||
if content_cat is None:
|
||||
return
|
||||
for name in tag_names:
|
||||
name = slugify_tag(name)
|
||||
if not name:
|
||||
continue
|
||||
tag, _ = Tag.objects.get_or_create(
|
||||
name=name, category=content_cat, defaults={'created_by': actor},
|
||||
)
|
||||
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
|
||||
|
||||
|
||||
def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
||||
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles,
|
||||
capturing category-specific metadata (pack.mcmeta / mods manifest)."""
|
||||
capturing category-specific metadata (pack.mcmeta / mods manifest / animation
|
||||
manifest). Returns (version, missing_animation_id)."""
|
||||
version = Version.objects.create(
|
||||
project=project,
|
||||
version_name=version_name,
|
||||
@@ -421,6 +493,8 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
|
||||
pack_format = None
|
||||
pack_description = ''
|
||||
mods_manifest = {}
|
||||
animation_manifest = None
|
||||
missing_animation_id = False
|
||||
|
||||
for temp in temp_uploads:
|
||||
index = _adopt_temp(temp, project.pk, 'versions', 'version')
|
||||
@@ -442,6 +516,12 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
|
||||
manifest = parse_mods_manifest(_stored_path(index), index.original_filename)
|
||||
if manifest.get('files'):
|
||||
mods_manifest = manifest
|
||||
if is_zip and animation_manifest is None:
|
||||
manifest = read_animation_manifest(_stored_path(index))
|
||||
if manifest is not None:
|
||||
animation_manifest = manifest
|
||||
if not manifest.get('animation_id'):
|
||||
missing_animation_id = True
|
||||
|
||||
update_fields = []
|
||||
if pack_format is not None:
|
||||
@@ -451,9 +531,20 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
|
||||
if mods_manifest:
|
||||
version.mods_manifest = mods_manifest
|
||||
update_fields.append('mods_manifest')
|
||||
if animation_manifest is not None:
|
||||
version.animation_manifest = animation_manifest
|
||||
update_fields.append('animation_manifest')
|
||||
if animation_manifest.get('animation_id'):
|
||||
version.animation_id = animation_manifest['animation_id']
|
||||
update_fields.append('animation_id')
|
||||
if update_fields:
|
||||
version.save(update_fields=update_fields)
|
||||
return version
|
||||
|
||||
if animation_manifest is not None:
|
||||
_apply_content_tags(
|
||||
project, animation_manifest.get('content_tags') or [], actor,
|
||||
)
|
||||
return version, missing_animation_id
|
||||
|
||||
|
||||
def _form_values(*forms):
|
||||
@@ -494,12 +585,15 @@ def project_create(request):
|
||||
thumb.save(update_fields=['status'])
|
||||
project.save(update_fields=['thumbnail'])
|
||||
|
||||
_finalize_version(
|
||||
_, missing_animation_id = _finalize_version(
|
||||
project,
|
||||
version_form.cleaned_data['version_name'],
|
||||
version_form.cleaned_data['changelog'],
|
||||
version_files,
|
||||
request.user,
|
||||
)
|
||||
if missing_animation_id:
|
||||
messages.warning(request, ANIMATION_API_WARNING)
|
||||
|
||||
for temp in TempUpload.objects.filter(
|
||||
user=request.user, status='pending', kind='media',
|
||||
@@ -631,12 +725,15 @@ def version_upload(request, slug):
|
||||
if not version_files:
|
||||
form.add_error('version_name', 'Upload at least one version file before uploading.')
|
||||
else:
|
||||
_finalize_version(
|
||||
_, missing_animation_id = _finalize_version(
|
||||
project,
|
||||
form.cleaned_data['version_name'],
|
||||
form.cleaned_data['changelog'],
|
||||
version_files,
|
||||
request.user,
|
||||
)
|
||||
if missing_animation_id:
|
||||
messages.warning(request, ANIMATION_API_WARNING)
|
||||
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
|
||||
return redirect('library:project_detail', slug=project.slug)
|
||||
|
||||
|
||||
+167
-1
@@ -8,6 +8,14 @@ adopted file (under MEDIA_ROOT) and return plain dicts/values.
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
|
||||
PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
|
||||
|
||||
|
||||
def _decode(data):
|
||||
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
|
||||
return json.loads(data.decode('utf-8-sig', 'replace'))
|
||||
|
||||
|
||||
def _open_zip(path):
|
||||
"""Return a ZipFile for the path (None when it's not a zip)."""
|
||||
@@ -27,7 +35,7 @@ def read_pack_mcmeta(path):
|
||||
return None, ''
|
||||
try:
|
||||
try:
|
||||
data = json.loads(zf.read('pack.mcmeta').decode('utf-8', 'replace'))
|
||||
data = _decode(zf.read('pack.mcmeta'))
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
return None, ''
|
||||
pack = data.get('pack') or {}
|
||||
@@ -43,6 +51,164 @@ def read_pack_mcmeta(path):
|
||||
zf.close()
|
||||
|
||||
|
||||
def _friendly_entity(entity):
|
||||
"""minecraft:zombie -> Zombie; needsofnature:horse_liquid_collector -> Horse Liquid Collector."""
|
||||
name = entity.rsplit(':', 1)[-1]
|
||||
return name.replace('_', ' ').title()
|
||||
|
||||
|
||||
def _gender(actor_tags):
|
||||
for tag in actor_tags or []:
|
||||
if str(tag).startswith('gender.'):
|
||||
return str(tag).split('.', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _summarize_animdef(filename, data):
|
||||
stem = filename.rsplit('/', 1)[-1]
|
||||
if stem.endswith('.json'):
|
||||
stem = stem[:-5]
|
||||
display = data.get('display_name') or stem.replace('_', ' ').title()
|
||||
|
||||
participants = len(data.get('actors') or [])
|
||||
actors = []
|
||||
has_entity = False
|
||||
entity_names = []
|
||||
for actor in data.get('actors') or []:
|
||||
entity_types = actor.get('entity_types') or []
|
||||
entity = 'player'
|
||||
if entity_types:
|
||||
non_players = [e for e in entity_types if e != 'minecraft:player']
|
||||
if non_players:
|
||||
entity = _friendly_entity(non_players[0])
|
||||
has_entity = True
|
||||
entity_names.append(entity)
|
||||
injector = actor.get('injector')
|
||||
inj = None
|
||||
inj_name = None
|
||||
if injector is True:
|
||||
inj = 'injector'
|
||||
inj_name = 'injector'
|
||||
elif isinstance(injector, str) and injector:
|
||||
inj = injector
|
||||
inj_name = INJECTOR_NAMES.get(injector, injector)
|
||||
actors.append({
|
||||
'label': actor.get('label') or '',
|
||||
'entity': entity,
|
||||
'gender': _gender(actor.get('actor_tags')),
|
||||
'activity': actor.get('activity') or '',
|
||||
'injector': inj,
|
||||
'injector_name': inj_name,
|
||||
'receiver': bool(actor.get('receiver')),
|
||||
})
|
||||
|
||||
content_tags = [t for t in data.get('content_tags') or []]
|
||||
animation_tags = data.get('animation_tags') or []
|
||||
problem_tags = sorted({
|
||||
t for t in list(content_tags) + list(animation_tags)
|
||||
if str(t).lower() in PROBLEM_TAGS
|
||||
})
|
||||
|
||||
if participants == 1:
|
||||
type_ = 'Solo'
|
||||
elif has_entity:
|
||||
type_ = 'Entity x Player'
|
||||
elif participants == 2:
|
||||
type_ = 'Pair'
|
||||
elif participants == 3:
|
||||
type_ = 'Threesome'
|
||||
else:
|
||||
type_ = f'Group of {participants}'
|
||||
|
||||
stages = []
|
||||
for stage in data.get('stages') or []:
|
||||
stages.append({
|
||||
'stage': stage.get('stage'),
|
||||
'loop': bool(stage.get('loop')),
|
||||
'cycle_seconds': stage.get('cycle_seconds'),
|
||||
'speed': stage.get('speed'),
|
||||
'climax': bool(stage.get('non_peak') or stage.get('manual_peak')),
|
||||
'use_stage': stage.get('use_stage'),
|
||||
'joinable': bool(stage.get('allow_join', True)),
|
||||
'escapable': bool(stage.get('escapable', True)),
|
||||
})
|
||||
|
||||
return {
|
||||
'name': display,
|
||||
'content_tags': content_tags,
|
||||
'animation_tags': list(animation_tags),
|
||||
'type': type_,
|
||||
'entity_names': entity_names,
|
||||
'participants': participants,
|
||||
'actors': actors,
|
||||
'stages': stages,
|
||||
'weight': data.get('weight'),
|
||||
'block_requirements': bool(data.get('block_requirements')),
|
||||
'water': bool(data.get('water')),
|
||||
'problem_tags': problem_tags,
|
||||
}
|
||||
|
||||
|
||||
def read_animation_manifest(path):
|
||||
"""Build the animation manifest for a NoN animation pack.
|
||||
|
||||
Detected by the presence of data/*/afw_animdefs/*.json. Returns None when
|
||||
the archive isn't an animation pack. Reads the animationframework block
|
||||
(id/name/version/authors/description) plus a summary of every animdef.
|
||||
"""
|
||||
zf = _open_zip(path)
|
||||
if zf is None:
|
||||
return None
|
||||
try:
|
||||
names = set(zf.namelist())
|
||||
animdef_names = sorted(
|
||||
n for n in names if '/afw_animdefs/' in n and n.endswith('.json')
|
||||
)
|
||||
if not animdef_names:
|
||||
return None
|
||||
|
||||
animation_id = None
|
||||
pack_name = ''
|
||||
pack_version = ''
|
||||
description = ''
|
||||
authors = []
|
||||
try:
|
||||
mcmeta = _decode(zf.read('pack.mcmeta'))
|
||||
af = mcmeta.get('animationframework') or {}
|
||||
animation_id = af.get('id') or None
|
||||
pack_name = str(af.get('name') or '')
|
||||
pack_version = str(af.get('version') or '')
|
||||
description = str(af.get('description') or '')
|
||||
author = af.get('author')
|
||||
if author:
|
||||
authors = [a.strip() for a in str(author).split(',') if a.strip()]
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
animations = []
|
||||
distinct_content_tags = set()
|
||||
for name in animdef_names:
|
||||
try:
|
||||
data = _decode(zf.read(name))
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
continue
|
||||
summary = _summarize_animdef(name, data)
|
||||
distinct_content_tags.update(summary['content_tags'])
|
||||
animations.append(summary)
|
||||
|
||||
return {
|
||||
'animation_id': animation_id,
|
||||
'name': pack_name,
|
||||
'version': pack_version,
|
||||
'authors': authors,
|
||||
'description': description,
|
||||
'animations': animations,
|
||||
'content_tags': sorted(distinct_content_tags),
|
||||
}
|
||||
finally:
|
||||
zf.close()
|
||||
|
||||
|
||||
def parse_mods_manifest(path, filename=''):
|
||||
"""Inspect a modpack archive and build a manifest for the Mods tab.
|
||||
|
||||
|
||||
@@ -2859,3 +2859,172 @@ a.deletelink {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ========== Global messages banner ========== */
|
||||
.messages-banner {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.message-success {
|
||||
background: var(--md-sys-color-primary-container, #d8f3dc);
|
||||
color: var(--md-sys-color-on-primary-container, #1b4332);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
.message-warning {
|
||||
background: var(--md-sys-color-error-container, #fdecea);
|
||||
color: var(--md-sys-color-on-error-container, #7f1d1d);
|
||||
border-color: var(--md-sys-color-error, #b3261e);
|
||||
}
|
||||
.message-error {
|
||||
background: var(--md-sys-color-error-container, #fdecea);
|
||||
color: var(--md-sys-color-on-error-container, #7f1d1d);
|
||||
border-color: var(--md-sys-color-error, #b3261e);
|
||||
}
|
||||
|
||||
/* ========== Animations tab ========== */
|
||||
.animations-header h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.anim-pack-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.anim-authors {
|
||||
margin: 0 0 4px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.anim-description {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
.anim-warning {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--md-sys-color-error-container, #fdecea);
|
||||
color: var(--md-sys-color-on-error-container, #7f1d1d);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.animations-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 14px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
font-size: 0.82rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
.animations-summary strong {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
.anim-summary-flagged strong {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
.anim-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.anim-card {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
background: var(--md-sys-color-surface-container-low);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.anim-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.anim-name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.anim-type-badge {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.anim-problem {
|
||||
color: var(--md-sys-color-error);
|
||||
cursor: help;
|
||||
}
|
||||
.anim-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.anim-actors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.actor-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
font-size: 0.75rem;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
.actor-chip small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.injector-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--md-sys-color-secondary-container, #e8def8);
|
||||
color: var(--md-sys-color-on-secondary-container, #21005d);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.anim-stages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.stages-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.stage-chip {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
font-size: 0.7rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
.stage-chip .fa-star {
|
||||
color: var(--md-sys-color-error);
|
||||
margin-right: 2px;
|
||||
}
|
||||
.anim-extras {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.anim-extra {
|
||||
font-size: 0.72rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
</nav>
|
||||
</div>
|
||||
<main class="main-content">
|
||||
{% if messages %}
|
||||
<div class="messages-banner">
|
||||
{% for message in messages %}
|
||||
<div class="message message-{{ message.tags }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
{% if project.category == 'guide' %}Guide{% else %}Description{% endif %}
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="versions" role="tab">Versions</button>
|
||||
{% if animation_manifest %}<button class="tab-btn" data-tab="animations" role="tab">Animations</button>{% endif %}
|
||||
{% if project.category == 'modpack' %}<button class="tab-btn" data-tab="mods" role="tab">Mods</button>{% endif %}
|
||||
<button class="tab-btn" data-tab="gallery" role="tab">Gallery</button>
|
||||
</div>
|
||||
@@ -138,6 +139,96 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if animation_manifest %}
|
||||
<section class="tab-panel" id="tab-animations">
|
||||
<div class="card">
|
||||
<div class="animations-header">
|
||||
<h2><i class="fas fa-film"></i> Animations</h2>
|
||||
{% if animation_manifest.name %}
|
||||
<p class="anim-pack-name">{{ animation_manifest.name }}
|
||||
{% if animation_manifest.version %}<span class="pack-format-badge">v{{ animation_manifest.version }}</span>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p class="anim-authors">
|
||||
<i class="fas fa-user-pen"></i> by
|
||||
{% for author in animation_manifest.authors %}{{ author }}{% if not forloop.last %}, {% endif %}{% empty %}{{ project.owner.username }}{% endfor %}
|
||||
</p>
|
||||
{% if animation_manifest.description %}<p class="anim-description">{{ animation_manifest.description }}</p>{% endif %}
|
||||
{% if not animation_manifest.animation_id %}
|
||||
<p class="anim-warning"><i class="fas fa-exclamation-triangle"></i> This pack has no <code>animationframework.id</code> — it can't be reached through the update-check API.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if anim_stats %}
|
||||
<div class="animations-summary">
|
||||
<span><strong>{{ anim_stats.total }}</strong> animations</span>
|
||||
<span><strong>{{ anim_stats.player }}</strong> Player × Player</span>
|
||||
<span><strong>{{ anim_stats.entity }}</strong> Entity × Player</span>
|
||||
<span><strong>{{ anim_stats.solo }}</strong> solo</span>
|
||||
{% if anim_stats.flagged %}<span class="anim-summary-flagged"><strong>{{ anim_stats.flagged }}</strong> flagged ⚠</span>{% endif %}
|
||||
<span><strong>{{ anim_stats.tags|length }}</strong> tags</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="anim-grid">
|
||||
{% for anim in animation_manifest.animations %}
|
||||
<div class="anim-card">
|
||||
<div class="anim-card-head">
|
||||
<strong class="anim-name">{{ anim.name }}</strong>
|
||||
<span class="anim-type-badge">{{ anim.type }}</span>
|
||||
{% if anim.problem_tags %}
|
||||
<span class="anim-problem" title="{{ anim.problem_tags|join:', ' }}"><i class="fas fa-exclamation-triangle"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="anim-tags">
|
||||
{% for tag in anim.content_tags %}
|
||||
<span class="tag-chip" style="border-color: var(--md-sys-color-content, #d62828); color: var(--md-sys-color-content, #d62828);">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% for tag in anim.animation_tags %}
|
||||
<span class="tag-chip">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="anim-actors">
|
||||
{% for actor in anim.actors %}
|
||||
<span class="actor-chip">
|
||||
{% if actor.entity == 'player' %}
|
||||
<i class="fas fa-user"></i> Player
|
||||
{% else %}
|
||||
<i class="fas fa-paw"></i> {{ actor.entity }}
|
||||
{% endif %}
|
||||
{% if actor.gender %}{% if actor.gender == 'male' %} ♂{% elif actor.gender == 'female' %} ♀{% endif %}{% endif %}
|
||||
{% if actor.activity %}<small>· {{ actor.activity }}</small>{% endif %}
|
||||
{% if actor.injector %}
|
||||
<span class="injector-badge"{% if actor.injector != 'injector' %} title="Injector: {{ actor.injector_name }}"{% endif %}>
|
||||
{% if actor.injector == 'injector' %}injector{% else %}{{ actor.injector_name }} ({{ actor.injector }}){% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if actor.receiver %}<small>· receiver</small>{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="anim-stages">
|
||||
<span class="stages-count">{{ anim.stages|length }} stage{{ anim.stages|length|pluralize }}</span>
|
||||
{% for s in anim.stages %}
|
||||
<span class="stage-chip"{% if s.climax %} title="climax stage"{% endif %}>
|
||||
{% if s.climax %}<i class="fas fa-star"></i>{% endif %}{{ s.stage }}{% if s.loop %}↻{% endif %}{% if s.cycle_seconds %} {{ s.cycle_seconds }}s{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if anim.weight or anim.block_requirements or anim.water %}
|
||||
<div class="anim-extras">
|
||||
{% if anim.weight %}<span class="anim-extra">weight {{ anim.weight }}</span>{% endif %}
|
||||
{% if anim.block_requirements %}<span class="anim-extra"><i class="fas fa-cubes"></i> requires block support</span>{% endif %}
|
||||
{% if anim.water %}<span class="anim-extra"><i class="fas fa-water"></i> water</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if project.category == 'modpack' %}
|
||||
<section class="tab-panel" id="tab-mods">
|
||||
<div class="card">
|
||||
|
||||
Reference in New Issue
Block a user