feat: unlock in-game galleries per owned version
This commit is contained in:
1 parent
e14184435c
commit
19924e1c5a
21 files changed
+977
-395
No files matched your search
@@ -0,0 +1,22 @@
|
||||
"""Gallery support for converted games; never changes scripts or story progress."""
|
||||
from app.translation import file_json
|
||||
|
||||
|
||||
def available(manifest: dict | None) -> bool:
|
||||
if not manifest or "game.json" not in manifest.get("files", {}):
|
||||
return False
|
||||
game = file_json(manifest, "game.json")
|
||||
if game.get("engine") not in (None, "renpy") or game.get("runtime"):
|
||||
return False
|
||||
screens = game.get("screens", {})
|
||||
if game.get("build", {}).get("name") == "MIST" and "rewatchgirlselectscreen" in screens:
|
||||
return True
|
||||
|
||||
def uses_gallery(value):
|
||||
if isinstance(value, dict):
|
||||
if value.get("k") == "call" and value.get("f", {}).get("k") == "name" and value["f"].get("id") in {"Gallery", "Replay"}:
|
||||
return True
|
||||
return any(uses_gallery(item) for item in value.values())
|
||||
return isinstance(value, list) and any(uses_gallery(item) for item in value)
|
||||
|
||||
return uses_gallery(game.get("init", [])) or uses_gallery(screens) or uses_gallery(game.get("defaults", []))
|
||||
@@ -82,6 +82,7 @@ class GameVersion(Base):
|
||||
# draft: visible to the owner and reviewers only
|
||||
visibility: Mapped[str] = mapped_column(String(16), default="published")
|
||||
kind: Mapped[str] = mapped_column(String(16), default="original") # original|translation|mod
|
||||
gallery_unlocked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
progress: Mapped[str] = mapped_column(String(255), default="")
|
||||
upload_name: Mapped[str | None] = mapped_column(String(255))
|
||||
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
|
||||
@@ -8,6 +8,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.blobs import blob_path, put_bytes
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.gallery import available as gallery_available
|
||||
from app.models import Game, GameVersion, User
|
||||
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut
|
||||
from app.translation import version_languages
|
||||
@@ -114,7 +115,8 @@ async def game_detail(game_id: int, user: Player, db: DB) -> dict:
|
||||
)
|
||||
).all()
|
||||
return {**GameOut.model_validate(game).model_dump(),
|
||||
"versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest)}
|
||||
"versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest),
|
||||
"gallery_available": gallery_available(v.manifest)}
|
||||
for v in versions]}
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ from starlette.requests import ClientDisconnect
|
||||
|
||||
from app import translation
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.gallery import available as gallery_available
|
||||
from app.models import Game, GameVersion, Revision, Save, User, VersionHistory
|
||||
from app.routers.games import get_game
|
||||
from app.schemas import (
|
||||
ForkIn,
|
||||
GallerySettingsIn,
|
||||
HistoryOut,
|
||||
PublishIn,
|
||||
SaveDetailOut,
|
||||
@@ -108,8 +110,10 @@ async def upload_version(
|
||||
|
||||
|
||||
@router.get("/versions/{version_id}", response_model=VersionDetailOut)
|
||||
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
|
||||
return await get_version(db, user, version_id)
|
||||
async def version_detail(version_id: int, user: Player, db: DB) -> dict[str, Any]:
|
||||
v = await get_version(db, user, version_id)
|
||||
return {**VersionDetailOut.model_validate(v).model_dump(),
|
||||
"gallery_available": await run_in_threadpool(gallery_available, v.manifest)}
|
||||
|
||||
|
||||
@router.get("/versions/{version_id}/manifest")
|
||||
@@ -117,7 +121,7 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A
|
||||
v = await get_version(db, user, version_id)
|
||||
if v.status != "ready" or not v.manifest:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
|
||||
return {**v.manifest, "revision": v.revision_id, "versionId": v.id}
|
||||
return {**v.manifest, "revision": v.revision_id, "versionId": v.id, "galleryUnlocked": v.gallery_unlocked}
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
|
||||
@@ -139,6 +143,7 @@ async def fork_version(version_id: int, body: ForkIn,
|
||||
status="ready",
|
||||
visibility="draft",
|
||||
kind=body.kind,
|
||||
gallery_unlocked=parent.gallery_unlocked,
|
||||
manifest=parent.manifest,
|
||||
base_revision_id=parent.revision_id,
|
||||
report=parent.report,
|
||||
@@ -243,6 +248,20 @@ async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db
|
||||
return v
|
||||
|
||||
|
||||
@router.patch("/versions/{version_id}/gallery", response_model=VersionOut)
|
||||
async def update_gallery(version_id: int, body: GallerySettingsIn, user: Player, db: DB) -> GameVersion:
|
||||
v = await get_version(db, user, version_id)
|
||||
if v.owner_id != user.id:
|
||||
raise HTTPException(403, "Открыть галерею можно только в своей версии")
|
||||
if v.status != "ready":
|
||||
raise HTTPException(409, "Версия ещё не готова")
|
||||
if body.gallery_unlocked and not await run_in_threadpool(gallery_available, v.manifest):
|
||||
raise HTTPException(422, "Галерея этой игры пока не поддерживается")
|
||||
v.gallery_unlocked = body.gallery_unlocked
|
||||
await db.commit()
|
||||
return v
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/retry", response_model=VersionOut)
|
||||
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
|
||||
v = await get_version(db, user, version_id)
|
||||
|
||||
@@ -68,6 +68,8 @@ class OwnerOut(ORM):
|
||||
|
||||
|
||||
class VersionOut(ORM):
|
||||
gallery_unlocked: bool = False
|
||||
gallery_available: bool = False
|
||||
revision_id: str | None = None
|
||||
base_revision_id: str | None = None
|
||||
id: int
|
||||
@@ -141,6 +143,10 @@ class PublishIn(BaseModel):
|
||||
make_main: bool = False
|
||||
|
||||
|
||||
class GallerySettingsIn(BaseModel):
|
||||
gallery_unlocked: bool
|
||||
|
||||
|
||||
class HistoryOut(ORM):
|
||||
id: int
|
||||
note: str
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Opt-in unlocked in-game galleries, scoped to one version."""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0007"
|
||||
down_revision = "0006"
|
||||
branch_labels = depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("game_versions", sa.Column("gallery_unlocked", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("game_versions", "gallery_unlocked")
|
||||
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app import gallery
|
||||
from app.routers import versions
|
||||
from app.schemas import ForkIn, GallerySettingsIn
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("game", "expected"), [
|
||||
({"build": {"name": "MIST"}, "screens": {"rewatchgirlselectscreen": {}}}, True),
|
||||
({"init": [{"k": "call", "f": {"k": "name", "id": "Gallery"}}]}, True),
|
||||
({"screens": {"replay": {"k": "call", "f": {"k": "name", "id": "Replay"}}}}, True),
|
||||
({"init": [], "screens": {}}, False),
|
||||
({"engine": "naninovel", "init": [{"k": "call", "f": {"k": "name", "id": "Gallery"}}]}, False),
|
||||
])
|
||||
def test_supported_gallery_detection(monkeypatch, game, expected):
|
||||
monkeypatch.setattr(gallery, "file_json", lambda *_: game)
|
||||
assert gallery.available({"files": {"game.json": {}}}) is expected
|
||||
assert not gallery.available(None)
|
||||
|
||||
|
||||
def test_only_owner_can_toggle_gallery_and_scripts_are_not_modified(monkeypatch):
|
||||
manifest = {"files": {"game.json": {"hash": "unchanged"}}}
|
||||
v = SimpleNamespace(owner_id=7, status="ready", manifest=manifest, gallery_unlocked=False)
|
||||
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
|
||||
monkeypatch.setattr(versions, "gallery_available", lambda _: True)
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
|
||||
async def scenario():
|
||||
body = GallerySettingsIn(gallery_unlocked=True)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await versions.update_gallery(1, body, SimpleNamespace(id=8), db)
|
||||
assert error.value.status_code == 403
|
||||
assert not v.gallery_unlocked
|
||||
result = await versions.update_gallery(1, body, SimpleNamespace(id=7), db)
|
||||
assert result.gallery_unlocked
|
||||
assert result.manifest is manifest
|
||||
await versions.update_gallery(1, GallerySettingsIn(gallery_unlocked=False), SimpleNamespace(id=7), db)
|
||||
assert not v.gallery_unlocked
|
||||
assert db.commit.await_count == 2
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("state", "supported", "code"), [("converting", True, 409), ("ready", False, 422)])
|
||||
def test_gallery_requires_ready_supported_game(monkeypatch, state, supported, code):
|
||||
v = SimpleNamespace(owner_id=7, status=state, manifest={}, gallery_unlocked=False)
|
||||
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
|
||||
monkeypatch.setattr(versions, "gallery_available", lambda _: supported)
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(versions.update_gallery(1, GallerySettingsIn(gallery_unlocked=True), SimpleNamespace(id=7), db))
|
||||
assert error.value.status_code == code
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
def test_player_manifest_uses_version_flag_instead_of_shared_manifest(monkeypatch):
|
||||
shared = {"files": {"game.json": {"hash": "unchanged"}}}
|
||||
v = SimpleNamespace(id=1, status="ready", manifest=shared, revision_id="revision", gallery_unlocked=True)
|
||||
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
|
||||
result = asyncio.run(versions.version_manifest(1, SimpleNamespace(id=7), None))
|
||||
assert result["galleryUnlocked"] is True
|
||||
assert "galleryUnlocked" not in shared
|
||||
v.gallery_unlocked = False
|
||||
assert asyncio.run(versions.version_manifest(1, SimpleNamespace(id=7), None))["galleryUnlocked"] is False
|
||||
|
||||
|
||||
def test_fork_inherits_gallery_setting_but_can_change_it_independently(monkeypatch):
|
||||
parent = SimpleNamespace(id=1, game_id=2, status="ready", gallery_unlocked=True,
|
||||
manifest={"files": {}}, revision_id="base", report={}, size_bytes=1)
|
||||
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=parent))
|
||||
monkeypatch.setattr(versions, "has_perm", lambda *_: True)
|
||||
added = []
|
||||
db = SimpleNamespace(add=added.append, commit=AsyncMock())
|
||||
fork = asyncio.run(versions.fork_version(1, ForkIn(name="My fork"), SimpleNamespace(id=7), db))
|
||||
assert fork.gallery_unlocked is True
|
||||
fork.gallery_unlocked = False
|
||||
assert parent.gallery_unlocked is True
|
||||
assert fork.manifest is parent.manifest
|
||||
Reference in new issue
Block a user