mirror of
https://github.com/rommapp/romm.git
synced 2026-10-11 22:40:04 +00:00
Play RPG Maker 2000/2003 games in the browser with EasyRPG (#5077)
* feat(player): play RPG Maker 2000/2003 games in the browser with EasyRPG Embed the EasyRPG web player for extracted game folders on the rpg-maker platform. The backend generates the player's index.json from the scanned files and merges in the free EasyRPG RTP under every name the official and fan RTPs give its assets, so games that lean on the RTP find them. In-game saves sync with the user's RomM saves through the player's IndexedDB storage. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(easyrpg): read the free RTP from a fixed path instead of an env var The full image always keeps the RTP at /var/www/html/assets/easyrpg/rtp, so the path is a constant beside the web path nginx serves it under. The dev container links its copy there at startup. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(easyrpg): code-review fixes for the save sync and asset endpoint - Wait for an in-flight push before the quit push, so the newest save uploads - Keep Play disabled until the fresh rom (and its saves) is fetched - Stop onPlay and the rom fetch from acting after the view unmounts - Leave through usePlayerExit like the other players - Handle a rejected IDB commit when the run itself fails - Track uploads by the browser's file name - Load only visibility and files per asset request, not the full rom - Serve DEV_MODE files as octet-stream, matching nginx - Make the traversal test actually reach the endpoint Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(easyrpg): simplify pass over the player and asset endpoint - Load only file paths per asset request, and look up RTP folders in O(1) - Reuse RomFile.file_name_for_download and usePlayerFullscreen - Share one dev/prod serve helper for game and RTP files - Derive the save sync's game key from the rom - Have the RTP table tool reuse normalize_name - Make save-module internals private and tidy the prepare/read loops Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(easyrpg): flag RPG Maker game folders on the rom schemas Gallery lists carry no files, so the frontend guessed that every multi-file rom on the rpg-maker platform was playable, including RPG Maker XP/VX/MV folders. Rom now has a deferred `is_easyrpg_game` subquery (an RPG_RT.ldb at the folder root), loaded with the other gallery file stats and derived from loaded files on the with_files path, and the frontend reads it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(easyrpg): drop server save sync, keep saves per user in the browser Server sync moves to a follow-up that puts the web players on device sync. Saves now stay in the player's IndexedDB, as js-dos does, with the same note on the start page. The player is launched as `<rom id>-<user id>` so each RomM account sharing a browser profile keeps its own saves; nginx and Vite drop the user id when routing to the backend. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(easyrpg): address Greptile review - Start timing the play session once the player page loads - Use object-form props in the view test's button mock - Keep the RTP table tool's docstring to one sentence Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(easyrpg): keep the RTP table in a fixtures folder Matches handler/metadata/fixtures, where the other bundled data files live. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
65247bf82c
commit
6f2a361481
@@ -55,6 +55,7 @@ frontend/assets/ruffle/*
|
||||
frontend/assets/jsdos
|
||||
frontend/assets/pico8
|
||||
frontend/assets/gme
|
||||
frontend/assets/easyrpg
|
||||
|
||||
# virtualenv
|
||||
.venv
|
||||
|
||||
+22
@@ -70,6 +70,28 @@ ADD --checksum=sha256:${FAKE08_WASM_SHA256} --chmod=644 \
|
||||
"https://raw.githubusercontent.com/fabkury/p3a/${FAKE08_P3A_COMMIT}/webui/pico8/fake08.wasm" \
|
||||
/emulators/pico8/fake08.wasm
|
||||
|
||||
ARG EASYRPG_VERSION=0.8.1.1
|
||||
ARG EASYRPG_SHA256=99b6963c943bb355e1b1221125cb4536ca2f627c329d8a89cee5a928cdd6a14f
|
||||
|
||||
ADD --checksum=sha256:${EASYRPG_SHA256} \
|
||||
"https://easyrpg.org/downloads/player/${EASYRPG_VERSION}/easyrpg-player-${EASYRPG_VERSION}-js.tar.gz" \
|
||||
/downloads/easyrpg.tar.gz
|
||||
# games/ is where the player looks for games, which the server routes to the backend.
|
||||
RUN mkdir -p /emulators/easyrpg && \
|
||||
tar -xzf /downloads/easyrpg.tar.gz -C /emulators/easyrpg --strip-components=1 && \
|
||||
rm -rf /emulators/easyrpg/games
|
||||
|
||||
# The free RTP (CC-BY-4.0) has no releases, so it is pinned by commit.
|
||||
ARG EASYRPG_RTP_COMMIT=993d88cbc78c658d348bbfa74a3b424d393d27e5
|
||||
ARG EASYRPG_RTP_SHA256=7e42abebbec94989f4ff214fc082511eef749114e76e3a342cb62d07c03d56e7
|
||||
|
||||
ADD --checksum=sha256:${EASYRPG_RTP_SHA256} \
|
||||
"https://github.com/EasyRPG/RTP/archive/${EASYRPG_RTP_COMMIT}.tar.gz" \
|
||||
/downloads/easyrpg-rtp.tar.gz
|
||||
RUN mkdir /emulators/easyrpg/rtp && \
|
||||
tar -xzf /downloads/easyrpg-rtp.tar.gz -C /emulators/easyrpg/rtp --strip-components=1 && \
|
||||
find /emulators/easyrpg/rtp \( -name '.git*' -o -name Makefile \) -exec rm -f {} +
|
||||
|
||||
|
||||
FROM ubuntu:22.04
|
||||
|
||||
|
||||
@@ -416,6 +416,7 @@ DISABLE_EMULATOR_JS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_EMULATOR_J
|
||||
DISABLE_RUFFLE_RS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_RUFFLE_RS"))
|
||||
DISABLE_JSDOS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_JSDOS"))
|
||||
DISABLE_PICO8: Final[bool] = safe_str_to_bool(_get_env("DISABLE_PICO8"))
|
||||
DISABLE_EASYRPG: Final[bool] = safe_str_to_bool(_get_env("DISABLE_EASYRPG"))
|
||||
|
||||
# FRONTEND
|
||||
KIOSK_MODE: Final[bool] = safe_str_to_bool(_get_env("KIOSK_MODE"))
|
||||
|
||||
@@ -11,6 +11,7 @@ from adapters.services.sigil import SigilService
|
||||
from config import (
|
||||
DEVICE_INSTALL_ENABLED,
|
||||
DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS,
|
||||
DISABLE_EASYRPG,
|
||||
DISABLE_EMULATOR_JS,
|
||||
DISABLE_JSDOS,
|
||||
DISABLE_LOGS_VIEWER,
|
||||
@@ -171,6 +172,7 @@ async def heartbeat() -> HeartbeatResponse:
|
||||
"DISABLE_RUFFLE_RS": DISABLE_RUFFLE_RS,
|
||||
"DISABLE_JSDOS": DISABLE_JSDOS,
|
||||
"DISABLE_PICO8": DISABLE_PICO8,
|
||||
"DISABLE_EASYRPG": DISABLE_EASYRPG,
|
||||
},
|
||||
"FRONTEND": {
|
||||
"DISABLE_USERPASS_LOGIN": DISABLE_USERPASS_LOGIN,
|
||||
|
||||
@@ -40,6 +40,7 @@ class EmulationDict(TypedDict):
|
||||
DISABLE_RUFFLE_RS: bool
|
||||
DISABLE_JSDOS: bool
|
||||
DISABLE_PICO8: bool
|
||||
DISABLE_EASYRPG: bool
|
||||
|
||||
|
||||
class FrontendDict(TypedDict):
|
||||
|
||||
@@ -433,6 +433,7 @@ class RomSchema(BaseModel):
|
||||
has_simple_single_file: bool
|
||||
has_nested_single_file: bool
|
||||
has_multiple_files: bool
|
||||
is_easyrpg_game: bool
|
||||
full_path: str
|
||||
created_at: UTCDatetime
|
||||
updated_at: UTCDatetime
|
||||
|
||||
@@ -169,6 +169,7 @@ from utils.zip_cache import (
|
||||
resolve_cached_zip,
|
||||
)
|
||||
|
||||
from .easyrpg import router as easyrpg_router
|
||||
from .files import router as files_router
|
||||
from .installs import router as installs_router
|
||||
from .manual import router as manual_router
|
||||
@@ -185,6 +186,7 @@ router = APIRouter(
|
||||
)
|
||||
router.include_router(upload_router)
|
||||
router.include_router(files_router)
|
||||
router.include_router(easyrpg_router)
|
||||
router.include_router(installs_router)
|
||||
router.include_router(manual_router)
|
||||
router.include_router(walkthrough_router)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from config import DEV_MODE, DISABLE_EASYRPG
|
||||
from decorators.auth import protected_route
|
||||
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
|
||||
from handler.auth.constants import Scope
|
||||
from handler.auth.dependencies import assert_rom_visible
|
||||
from handler.database import db_rom_handler
|
||||
from handler.easyrpg import INDEX_FILE, RTP_WEB_PATH, easyrpg_handler
|
||||
from handler.filesystem import fs_rom_handler
|
||||
from utils.nginx import FileRedirectResponse
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# As nginx serves them, so a game's HTML or SVG never renders on this origin.
|
||||
_OCTET_STREAM = "application/octet-stream"
|
||||
|
||||
|
||||
def _not_found(detail: str) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||
|
||||
|
||||
def _serve(disk_path: str | Path, web_path: PurePosixPath) -> Response:
|
||||
if DEV_MODE:
|
||||
return FileResponse(path=disk_path, media_type=_OCTET_STREAM)
|
||||
return FileRedirectResponse(download_path=web_path)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.get,
|
||||
"/{id}/easyrpg/{path:path}",
|
||||
[Scope.ROMS_READ],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def get_easyrpg_file(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
path: Annotated[str, PathVar(description="File path inside the game folder.")],
|
||||
) -> Response:
|
||||
"""Serve a generated `index.json`, or a game or RTP file it names, to the EasyRPG web player."""
|
||||
if DISABLE_EASYRPG:
|
||||
raise _not_found("EasyRPG is disabled")
|
||||
|
||||
# The player fetches every asset through here, so the full rom is never loaded.
|
||||
rom = db_rom_handler.get_rom_visibility(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
|
||||
assert_rom_visible(request, rom)
|
||||
|
||||
game_files = easyrpg_handler.game_files(db_rom_handler.present_rom_file_paths(id))
|
||||
if not easyrpg_handler.is_game(game_files):
|
||||
raise _not_found("Not an RPG Maker 2000/2003 game")
|
||||
|
||||
if path == INDEX_FILE:
|
||||
return JSONResponse(
|
||||
easyrpg_handler.build_index(game_files),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
if file := game_files.get(path):
|
||||
return _serve(
|
||||
fs_rom_handler.validate_path(file.full_path),
|
||||
PurePosixPath("/library", file.full_path),
|
||||
)
|
||||
|
||||
if rtp_file := easyrpg_handler.find_rtp_file(path):
|
||||
return _serve(
|
||||
f"{easyrpg_handler.rtp_path}/{rtp_file}",
|
||||
PurePosixPath(RTP_WEB_PATH, rtp_file),
|
||||
)
|
||||
|
||||
raise _not_found("File not found")
|
||||
@@ -687,6 +687,7 @@ def with_details[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
||||
undefer(Rom.multi_file),
|
||||
undefer(Rom.top_level_file_count),
|
||||
undefer(Rom.has_soundtrack),
|
||||
undefer(Rom.is_easyrpg_game),
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@@ -736,6 +737,7 @@ def with_simple_details[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
||||
undefer(Rom.multi_file),
|
||||
undefer(Rom.top_level_file_count),
|
||||
undefer(Rom.has_soundtrack),
|
||||
undefer(Rom.is_easyrpg_game),
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@@ -1604,6 +1606,7 @@ class DBRomsHandler(DBBaseHandler):
|
||||
undefer(Rom.multi_file),
|
||||
undefer(Rom.top_level_file_count),
|
||||
undefer(Rom.has_soundtrack),
|
||||
undefer(Rom.is_easyrpg_game),
|
||||
)
|
||||
|
||||
# Handle platform filtering - platform filtering always uses OR logic since ROMs belong to only one platform
|
||||
@@ -2858,6 +2861,26 @@ class DBRomsHandler(DBBaseHandler):
|
||||
.all()
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def present_rom_file_paths(
|
||||
self,
|
||||
rom_id: int,
|
||||
session: Session = INJECTED_SESSION,
|
||||
) -> list[RomFile]:
|
||||
"""A ROM's files on disk, loading only their paths and the rom's."""
|
||||
return list(
|
||||
session.scalars(
|
||||
select(RomFile)
|
||||
.filter_by(rom_id=rom_id, missing_from_fs=False)
|
||||
.options(
|
||||
load_only(RomFile.file_path, RomFile.file_name),
|
||||
joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name),
|
||||
)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def rom_files_for_rom_id(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Serves RPG Maker 2000/2003 game folders, with the free RTP merged in, to the EasyRPG web player."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import unicodedata
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from models.rom import EASYRPG_GAME_DATABASE, RomFile
|
||||
|
||||
# The image ships the free RTP beside the web player, which nginx serves.
|
||||
RTP_WEB_PATH: Final = "/assets/easyrpg/rtp"
|
||||
RTP_PATH: Final = f"/var/www/html{RTP_WEB_PATH}"
|
||||
|
||||
INDEX_FILE: Final = "index.json"
|
||||
# gencache keeps these extensions on files below the game root.
|
||||
_KEPT_EXTENSIONS: Final = (".ini", ".po")
|
||||
_DIRNAME_KEY: Final = "_dirname"
|
||||
_RTP_TABLE_PATH: Final = Path(__file__).parent / "fixtures" / "rtp_table.json"
|
||||
|
||||
type RtpTable = Mapping[str, Sequence[Sequence[str]]]
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""The key the player looks a file up by, as gencache derives it."""
|
||||
return unicodedata.normalize("NFKC", name.lower())
|
||||
|
||||
|
||||
def _strip_extension(name: str) -> str:
|
||||
dot = name.rfind(".")
|
||||
return name[:dot] if dot != -1 else name
|
||||
|
||||
|
||||
def _file_key(name: str, at_root: bool) -> str:
|
||||
key = normalize_name(name)
|
||||
if at_root or key.endswith(_KEPT_EXTENSIONS):
|
||||
return "exfont" if _strip_extension(key) == "exfont" else key
|
||||
return _strip_extension(key)
|
||||
|
||||
|
||||
def _rtp_aliases(rtp_table: RtpTable, category: str) -> dict[str, set[str]]:
|
||||
"""Every name an RTP asset of `category` goes by, keyed by each of them."""
|
||||
aliases: dict[str, set[str]] = {}
|
||||
for row in rtp_table.get(category, []):
|
||||
for name in row:
|
||||
aliases.setdefault(name, set()).update(row)
|
||||
return aliases
|
||||
|
||||
|
||||
def build_index(
|
||||
game_files: Iterable[str],
|
||||
rtp_files: Mapping[str, Sequence[str]],
|
||||
rtp_table: RtpTable,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a version 2 `index.json` for a game, the format gencache writes.
|
||||
|
||||
Args:
|
||||
game_files: Paths of the game's files, relative to its folder.
|
||||
rtp_files: RTP file names, keyed by their folder.
|
||||
rtp_table: Names of each RTP asset, keyed by category.
|
||||
"""
|
||||
cache: dict[str, Any] = {}
|
||||
for path in game_files:
|
||||
*dirs, name = path.split("/")
|
||||
node: Any = cache
|
||||
for dir_name in dirs:
|
||||
node = node.setdefault(normalize_name(dir_name), {_DIRNAME_KEY: dir_name})
|
||||
if not isinstance(node, dict):
|
||||
break
|
||||
else:
|
||||
if name != _DIRNAME_KEY:
|
||||
node.setdefault(_file_key(name, at_root=not dirs), name)
|
||||
|
||||
# The game's own files win over the RTP's.
|
||||
for dir_name, names in sorted(rtp_files.items()):
|
||||
category = normalize_name(dir_name)
|
||||
node = cache.setdefault(category, {_DIRNAME_KEY: dir_name})
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
aliases = _rtp_aliases(rtp_table, category)
|
||||
for name in sorted(names):
|
||||
stem = normalize_name(_strip_extension(name))
|
||||
for alias in sorted(aliases.get(stem, {stem})):
|
||||
node.setdefault(alias, name)
|
||||
|
||||
return {"metadata": {"version": 2}, "cache": cache}
|
||||
|
||||
|
||||
class EasyRpgHandler:
|
||||
def __init__(self, rtp_path: str = RTP_PATH) -> None:
|
||||
self.rtp_path = rtp_path
|
||||
|
||||
@cached_property
|
||||
def rtp_table(self) -> RtpTable:
|
||||
table: RtpTable = json.loads(_RTP_TABLE_PATH.read_text(encoding="utf-8"))
|
||||
return table
|
||||
|
||||
@cached_property
|
||||
def rtp_files(self) -> dict[str, list[str]]:
|
||||
"""The RTP's files by folder, or nothing when the image has no RTP."""
|
||||
try:
|
||||
folders = os.scandir(self.rtp_path)
|
||||
except OSError:
|
||||
return {}
|
||||
with folders:
|
||||
return {
|
||||
folder.name: sorted(
|
||||
entry.name
|
||||
for entry in os.scandir(folder.path)
|
||||
if entry.is_file() and not entry.name.startswith(".")
|
||||
)
|
||||
for folder in folders
|
||||
if folder.is_dir() and not folder.name.startswith(".")
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def _rtp_folders(self) -> dict[str, tuple[str, frozenset[str]]]:
|
||||
"""Each RTP folder and its file names, keyed by the normalized folder name."""
|
||||
return {
|
||||
normalize_name(folder): (folder, frozenset(names))
|
||||
for folder, names in self.rtp_files.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def game_files(files: Iterable[RomFile]) -> dict[str, RomFile]:
|
||||
"""A game's files, keyed by their path inside its folder."""
|
||||
return {file.file_name_for_download(): file for file in files}
|
||||
|
||||
@staticmethod
|
||||
def is_game(game_files: Iterable[str]) -> bool:
|
||||
return any(normalize_name(path) == EASYRPG_GAME_DATABASE for path in game_files)
|
||||
|
||||
def build_index(self, game_files: Iterable[str]) -> dict[str, Any]:
|
||||
return build_index(game_files, self.rtp_files, self.rtp_table)
|
||||
|
||||
def find_rtp_file(self, path: str) -> str | None:
|
||||
"""The RTP file `path` names, under whatever folder spelling the index gave it.
|
||||
|
||||
Returns:
|
||||
The file's path inside the RTP, or None when the RTP has no such file.
|
||||
"""
|
||||
dir_name, _, name = path.rpartition("/")
|
||||
folder, names = self._rtp_folders.get(
|
||||
normalize_name(dir_name), ("", frozenset())
|
||||
)
|
||||
return f"{folder}/{name}" if name in names else None
|
||||
|
||||
|
||||
easyrpg_handler = EasyRpgHandler()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,6 +263,9 @@ IDENTITY_ID_FIELDS: Final[tuple[str, ...]] = (
|
||||
# `rom_identity_keys` holds every provider's id in one column.
|
||||
IDENTITY_PROVIDER_ID_LENGTH: Final = 100
|
||||
|
||||
# The database file at the root of every RPG Maker 2000/2003 game folder.
|
||||
EASYRPG_GAME_DATABASE: Final = "rpg_rt.ldb"
|
||||
|
||||
|
||||
class RomIdentityKey(BaseModel):
|
||||
"""One row per (ROM, provider it has a match id for), scoped to a platform.
|
||||
@@ -1138,6 +1141,7 @@ class Rom(BaseModel):
|
||||
# Defined out-of-line at module scope via column_property
|
||||
multi_file: Mapped[bool]
|
||||
top_level_file_count: Mapped[int]
|
||||
is_easyrpg_game: Mapped[bool]
|
||||
|
||||
@property
|
||||
def has_simple_single_file(self) -> bool:
|
||||
@@ -1315,6 +1319,23 @@ Rom.top_level_file_count = column_property(
|
||||
deferred=True,
|
||||
)
|
||||
|
||||
Rom.is_easyrpg_game = column_property(
|
||||
select(RomFile.id)
|
||||
.where(
|
||||
and_(
|
||||
RomFile.rom_id == Rom.id,
|
||||
RomFile.file_path == _rom_full_path,
|
||||
func.lower(RomFile.file_name) == EASYRPG_GAME_DATABASE,
|
||||
RomFile.missing_from_fs.is_(False),
|
||||
)
|
||||
)
|
||||
.correlate_except(RomFile)
|
||||
.exists()
|
||||
.select()
|
||||
.scalar_subquery(),
|
||||
deferred=True,
|
||||
)
|
||||
|
||||
|
||||
def apply_file_stats(rom: Rom, files: Sequence[RomFile]) -> None:
|
||||
"""Fill the deferred file-stat columns from an already-loaded file list."""
|
||||
@@ -1335,6 +1356,16 @@ def apply_file_stats(rom: Rom, files: Sequence[RomFile]) -> None:
|
||||
"has_soundtrack",
|
||||
any(f.category == RomFileCategory.SOUNDTRACK for f in files),
|
||||
)
|
||||
set_committed_value(
|
||||
rom,
|
||||
"is_easyrpg_game",
|
||||
any(
|
||||
f.file_path == rom.full_path
|
||||
and f.file_name.lower() == EASYRPG_GAME_DATABASE
|
||||
and not f.missing_from_fs
|
||||
for f in files
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
SEARCH_TITLE_COLUMNS = ("name", *(column for column, _ in ALTERNATIVE_NAME_SOURCES))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
from tests.factories import make_rom
|
||||
|
||||
from endpoints.roms import easyrpg
|
||||
from handler.database import db_rom_handler
|
||||
from handler.database.base_handler import sync_session
|
||||
from handler.easyrpg import easyrpg_handler
|
||||
from models.permission import HiddenEntity, PermEntity
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom, RomFile
|
||||
from models.user import User
|
||||
|
||||
|
||||
def _make_game(admin_user: User, platform: Platform, *paths: str) -> Rom:
|
||||
rom = make_rom(platform, "Yume Nikki", fs_extension="")
|
||||
db_rom_handler.add_rom_user(rom_id=rom.id, user_id=admin_user.id)
|
||||
for path in paths:
|
||||
folder, _, name = f"{rom.full_path}/{path}".rpartition("/")
|
||||
db_rom_handler.add_rom_file(
|
||||
RomFile(rom_id=rom.id, file_name=name, file_path=folder, file_size_bytes=1)
|
||||
)
|
||||
return rom
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(admin_user: User, platform: Platform) -> Rom:
|
||||
return _make_game(admin_user, platform, "RPG_RT.ldb", "Music/Town.mid")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rtp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(
|
||||
easyrpg_handler.__dict__, "rtp_files", {"Music": ["Battle 1.mid"]}
|
||||
)
|
||||
|
||||
|
||||
def test_index_maps_the_games_files(client: TestClient, headers, game: Rom, rtp):
|
||||
response = client.get(f"/api/roms/{game.id}/easyrpg/index.json", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
cache = response.json()["cache"]
|
||||
assert cache["rpg_rt.ldb"] == "RPG_RT.ldb"
|
||||
assert cache["music"]["town"] == "Town.mid"
|
||||
assert cache["music"]["戦闘1"] == "Battle 1.mid"
|
||||
|
||||
|
||||
def test_game_files_are_handed_to_nginx(client: TestClient, headers, game: Rom):
|
||||
response = client.get(
|
||||
f"/api/roms/{game.id}/easyrpg/Music/Town.mid", headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.headers["x-accel-redirect"] == (
|
||||
f"/library/{game.full_path}/Music/Town.mid".replace(" ", "%20")
|
||||
)
|
||||
|
||||
|
||||
def test_rtp_files_are_served_from_the_player_assets(
|
||||
client: TestClient, headers, game: Rom, rtp
|
||||
):
|
||||
response = client.get(
|
||||
f"/api/roms/{game.id}/easyrpg/Music/Battle 1.mid", headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.headers["x-accel-redirect"]
|
||||
== "/assets/easyrpg/rtp/Music/Battle%201.mid"
|
||||
)
|
||||
|
||||
|
||||
# Encoded, or the client collapses the dot segments before the route sees them.
|
||||
@pytest.mark.parametrize("path", ["Music/Missing.mid", "%2E%2E/other/RPG_RT.ldb"])
|
||||
def test_unknown_paths_are_not_found(client: TestClient, headers, game: Rom, path):
|
||||
response = client.get(f"/api/roms/{game.id}/easyrpg/{path}", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json()["detail"] == "File not found"
|
||||
|
||||
|
||||
def test_a_folder_without_a_game_database_is_not_found(
|
||||
client: TestClient, headers, admin_user: User, platform: Platform
|
||||
):
|
||||
rom = _make_game(admin_user, platform, "game.bin")
|
||||
|
||||
response = client.get(f"/api/roms/{rom.id}/easyrpg/index.json", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_a_hidden_game_is_not_found(
|
||||
client: TestClient, editor_headers, editor_user: User, game: Rom
|
||||
):
|
||||
with sync_session.begin() as s:
|
||||
s.add(
|
||||
HiddenEntity(
|
||||
entity=PermEntity.ROMS, entity_id=game.id, user_id=editor_user.id
|
||||
)
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/roms/{game.id}/easyrpg/index.json", headers=editor_headers
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_is_not_found_while_disabled(mocker, client: TestClient, headers, game: Rom):
|
||||
mocker.patch.object(easyrpg, "DISABLE_EASYRPG", True)
|
||||
|
||||
response = client.get(f"/api/roms/{game.id}/easyrpg/index.json", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_requires_authentication(client: TestClient, game: Rom):
|
||||
response = client.get(f"/api/roms/{game.id}/easyrpg/index.json")
|
||||
|
||||
assert response.status_code in (
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
@@ -17,6 +17,7 @@ FILE_STAT_FIELDS = (
|
||||
"has_nested_single_file",
|
||||
"has_multiple_files",
|
||||
"has_soundtrack",
|
||||
"is_easyrpg_game",
|
||||
)
|
||||
|
||||
|
||||
@@ -90,6 +91,16 @@ def soundtrack_rom(admin_user: User, platform: Platform) -> Rom:
|
||||
return rom
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def easyrpg_rom(admin_user: User, platform: Platform) -> Rom:
|
||||
"""An RPG Maker 2000/2003 game folder, its database named in upper case."""
|
||||
rom = _add_rom(admin_user, platform, "rpg_game", fs_extension="")
|
||||
file_path = f"{rom.fs_path}/rpg_game"
|
||||
_add_file(rom, "RPG_RT.ldb", file_path=file_path)
|
||||
_add_file(rom, "Title.png", file_path=f"{file_path}/Title")
|
||||
return rom
|
||||
|
||||
|
||||
def _fetch_one(
|
||||
client: TestClient, access_token: str, platform: Platform, *, with_files: bool
|
||||
) -> dict[str, Any]:
|
||||
@@ -106,7 +117,14 @@ def _fetch_one(
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rom_fixture",
|
||||
["flat_rom", "folder_rom", "nested_rom", "soundtrack_rom", "fileless_rom"],
|
||||
[
|
||||
"flat_rom",
|
||||
"folder_rom",
|
||||
"nested_rom",
|
||||
"soundtrack_rom",
|
||||
"fileless_rom",
|
||||
"easyrpg_rom",
|
||||
],
|
||||
)
|
||||
def test_file_stats_match_between_derived_and_sql_paths(
|
||||
client: TestClient,
|
||||
@@ -129,6 +147,26 @@ def test_file_stats_match_between_derived_and_sql_paths(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_files", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
("rom_fixture", "expected"), [("easyrpg_rom", True), ("folder_rom", False)]
|
||||
)
|
||||
def test_is_easyrpg_game_needs_a_top_level_game_database(
|
||||
client: TestClient,
|
||||
access_token: str,
|
||||
platform: Platform,
|
||||
rom_fixture: str,
|
||||
expected: bool,
|
||||
with_files: bool,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
request.getfixturevalue(rom_fixture)
|
||||
|
||||
rom = _fetch_one(client, access_token, platform, with_files=with_files)
|
||||
|
||||
assert rom["is_easyrpg_game"] is expected
|
||||
|
||||
|
||||
def test_with_files_query_count_does_not_scale_with_file_count(
|
||||
client: TestClient,
|
||||
access_token: str,
|
||||
|
||||
@@ -52,6 +52,7 @@ def test_heartbeat(client):
|
||||
assert isinstance(emulation["DISABLE_RUFFLE_RS"], bool)
|
||||
assert isinstance(emulation["DISABLE_JSDOS"], bool)
|
||||
assert isinstance(emulation["DISABLE_PICO8"], bool)
|
||||
assert isinstance(emulation["DISABLE_EASYRPG"], bool)
|
||||
|
||||
assert "FRONTEND" in heartbeat
|
||||
frontend = heartbeat["FRONTEND"]
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from pathlib import Path
|
||||
|
||||
from handler.easyrpg import (
|
||||
EasyRpgHandler,
|
||||
build_index,
|
||||
easyrpg_handler,
|
||||
normalize_name,
|
||||
)
|
||||
|
||||
RTP_TABLE = {
|
||||
"music": [["戦闘1", "battle 1", "battle1"]],
|
||||
"system": [["システム", "system"]],
|
||||
}
|
||||
|
||||
|
||||
def test_root_files_keep_their_extension():
|
||||
index = build_index(["RPG_RT.ldb", "RPG_RT.lmt", "Map0001.lmu"], {}, {})
|
||||
|
||||
assert index["metadata"]["version"] == 2
|
||||
assert index["cache"] == {
|
||||
"rpg_rt.ldb": "RPG_RT.ldb",
|
||||
"rpg_rt.lmt": "RPG_RT.lmt",
|
||||
"map0001.lmu": "Map0001.lmu",
|
||||
}
|
||||
|
||||
|
||||
def test_folder_files_drop_their_extension_except_ini_and_po():
|
||||
cache = build_index(
|
||||
["ChipSet/World.png", "Language/Meta.ini", "Language/de.po"], {}, {}
|
||||
)["cache"]
|
||||
|
||||
assert cache["chipset"] == {"_dirname": "ChipSet", "world": "World.png"}
|
||||
assert cache["language"] == {
|
||||
"_dirname": "Language",
|
||||
"meta.ini": "Meta.ini",
|
||||
"de.po": "de.po",
|
||||
}
|
||||
|
||||
|
||||
def test_exfont_is_keyed_without_its_extension():
|
||||
cache = build_index(["ExFont.png"], {}, {})["cache"]
|
||||
|
||||
assert cache == {"exfont": "ExFont.png"}
|
||||
|
||||
|
||||
def test_keys_are_lowercased_and_width_normalized():
|
||||
cache = build_index(["Music/Town.mid"], {}, {})["cache"]
|
||||
|
||||
assert cache["music"] == {"_dirname": "Music", "town": "Town.mid"}
|
||||
|
||||
|
||||
def test_nested_folders_nest_in_the_index():
|
||||
cache = build_index(["Language/de/Picture/Title.png"], {}, {})["cache"]
|
||||
|
||||
assert cache["language"]["de"]["picture"] == {
|
||||
"_dirname": "Picture",
|
||||
"title": "Title.png",
|
||||
}
|
||||
|
||||
|
||||
def test_rtp_assets_answer_to_every_translated_name():
|
||||
cache = build_index(["RPG_RT.ldb"], {"Music": ["Battle 1.mid"]}, RTP_TABLE)["cache"]
|
||||
|
||||
assert cache["music"] == {
|
||||
"_dirname": "Music",
|
||||
"戦闘1": "Battle 1.mid",
|
||||
"battle 1": "Battle 1.mid",
|
||||
"battle1": "Battle 1.mid",
|
||||
}
|
||||
|
||||
|
||||
def test_rtp_assets_join_the_games_folder_under_its_spelling():
|
||||
cache = build_index(["music/Town.mid"], {"Music": ["Battle 1.mid"]}, RTP_TABLE)[
|
||||
"cache"
|
||||
]
|
||||
|
||||
assert cache["music"]["_dirname"] == "music"
|
||||
assert cache["music"]["town"] == "Town.mid"
|
||||
assert cache["music"]["戦闘1"] == "Battle 1.mid"
|
||||
|
||||
|
||||
def test_game_files_win_over_the_rtp():
|
||||
cache = build_index(["Music/戦闘1.mid"], {"Music": ["Battle 1.mid"]}, RTP_TABLE)[
|
||||
"cache"
|
||||
]
|
||||
|
||||
assert cache["music"]["戦闘1"] == "戦闘1.mid"
|
||||
assert cache["music"]["battle1"] == "Battle 1.mid"
|
||||
|
||||
|
||||
def test_rtp_files_outside_the_table_keep_their_own_name():
|
||||
cache = build_index([], {"ChipSet": ["retro_World.png"]}, RTP_TABLE)["cache"]
|
||||
|
||||
assert cache["chipset"] == {"_dirname": "ChipSet", "retro_world": "retro_World.png"}
|
||||
|
||||
|
||||
def test_reserved_dirname_file_is_skipped():
|
||||
cache = build_index(["Music/_dirname"], {}, {})["cache"]
|
||||
|
||||
assert cache["music"] == {"_dirname": "Music"}
|
||||
|
||||
|
||||
def _rtp_dir(tmp_path: Path) -> Path:
|
||||
for path in ("Music/Battle 1.mid", "System/System.png", "Music/.gitignore"):
|
||||
(tmp_path / path).parent.mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / path).write_bytes(b"")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_rtp_files_are_listed_by_folder(tmp_path: Path):
|
||||
handler = EasyRpgHandler(rtp_path=str(_rtp_dir(tmp_path)))
|
||||
|
||||
assert handler.rtp_files == {
|
||||
"Music": ["Battle 1.mid"],
|
||||
"System": ["System.png"],
|
||||
}
|
||||
|
||||
|
||||
def test_missing_rtp_folder_lists_nothing(tmp_path: Path):
|
||||
handler = EasyRpgHandler(rtp_path=str(tmp_path / "missing"))
|
||||
|
||||
assert handler.rtp_files == {}
|
||||
|
||||
|
||||
def test_find_rtp_file_ignores_the_folder_case(tmp_path: Path):
|
||||
handler = EasyRpgHandler(rtp_path=str(_rtp_dir(tmp_path)))
|
||||
|
||||
assert handler.find_rtp_file("music/Battle 1.mid") == "Music/Battle 1.mid"
|
||||
assert handler.find_rtp_file("Music/battle 1.mid") is None
|
||||
assert handler.find_rtp_file("Music/../System/System.png") is None
|
||||
|
||||
|
||||
def test_is_game_needs_the_database_at_the_root():
|
||||
assert EasyRpgHandler.is_game(["RPG_RT.LDB", "Music/Town.mid"])
|
||||
assert not EasyRpgHandler.is_game(["Game/RPG_RT.ldb"])
|
||||
|
||||
|
||||
def test_rtp_table_ships_with_the_handler():
|
||||
music = easyrpg_handler.rtp_table["music"]
|
||||
|
||||
assert any(normalize_name("戦闘1") in row and "battle 1" in row for row in music)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Regenerate handler/easyrpg/fixtures/rtp_table.json from EasyRPG Player's RTP table."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from handler.easyrpg import normalize_name
|
||||
|
||||
ROW_RE = re.compile(r"^\s*\{(.*)\},\s*$")
|
||||
CELL_RE = re.compile(r'"((?:[^"\\]|\\.)*)"|nullptr')
|
||||
OUTPUT = (
|
||||
Path(__file__).parent.parent / "handler" / "easyrpg" / "fixtures" / "rtp_table.json"
|
||||
)
|
||||
USAGE = "usage: uv run python -m tools.generate_easyrpg_rtp_table <Player>/src/rtp_table.cpp"
|
||||
|
||||
|
||||
def parse_rows(source: str) -> dict[str, list[list[str]]]:
|
||||
table: dict[str, list[list[str]]] = {}
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
for line in source.splitlines():
|
||||
match = ROW_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
cells = [m.group(1) for m in CELL_RE.finditer(match.group(1))]
|
||||
# Category and name tables have a single column.
|
||||
if len(cells) < 3 or cells[0] is None:
|
||||
continue
|
||||
category, *names = cells
|
||||
aliases = list(dict.fromkeys(normalize_name(name) for name in names if name))
|
||||
key = (category, *aliases)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
table.setdefault(category, []).append(aliases)
|
||||
return table
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit(USAGE)
|
||||
table = parse_rows(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
OUTPUT.write_text(
|
||||
json.dumps(table, ensure_ascii=False, indent=1, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Wrote {sum(len(rows) for rows in table.values())} rows to {OUTPUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+24
-1
@@ -190,7 +190,7 @@ RUN case "${TARGETARCH}" in \
|
||||
chmod 0755 /rom-converto
|
||||
|
||||
|
||||
# FETCH EMULATORJS, RUFFLE, JS-DOS AND FAKE-08
|
||||
# FETCH EMULATORJS, RUFFLE, JS-DOS, FAKE-08 AND EASYRPG
|
||||
# Keep the pins below in sync with the dev image's copies in ../Dockerfile.
|
||||
# checkov:skip=CKV_DOCKER_4:every ADD below is a remote URL pinned by --checksum
|
||||
FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS emulator-stage
|
||||
@@ -242,6 +242,28 @@ ADD --checksum=sha256:${FAKE08_WASM_SHA256} --chmod=644 \
|
||||
"https://raw.githubusercontent.com/fabkury/p3a/${FAKE08_P3A_COMMIT}/webui/pico8/fake08.wasm" \
|
||||
/fake08/fake08.wasm
|
||||
|
||||
ARG EASYRPG_VERSION=0.8.1.1
|
||||
ARG EASYRPG_SHA256=99b6963c943bb355e1b1221125cb4536ca2f627c329d8a89cee5a928cdd6a14f
|
||||
|
||||
ADD --checksum=sha256:${EASYRPG_SHA256} \
|
||||
"https://easyrpg.org/downloads/player/${EASYRPG_VERSION}/easyrpg-player-${EASYRPG_VERSION}-js.tar.gz" \
|
||||
/downloads/easyrpg.tar.gz
|
||||
# games/ is where the player looks for games, which the server routes to the backend.
|
||||
RUN mkdir /easyrpg && \
|
||||
tar -xzf /downloads/easyrpg.tar.gz -C /easyrpg --strip-components=1 && \
|
||||
rm -rf /easyrpg/games
|
||||
|
||||
# The free RTP (CC-BY-4.0) has no releases, so it is pinned by commit.
|
||||
ARG EASYRPG_RTP_COMMIT=993d88cbc78c658d348bbfa74a3b424d393d27e5
|
||||
ARG EASYRPG_RTP_SHA256=7e42abebbec94989f4ff214fc082511eef749114e76e3a342cb62d07c03d56e7
|
||||
|
||||
ADD --checksum=sha256:${EASYRPG_RTP_SHA256} \
|
||||
"https://github.com/EasyRPG/RTP/archive/${EASYRPG_RTP_COMMIT}.tar.gz" \
|
||||
/downloads/easyrpg-rtp.tar.gz
|
||||
RUN mkdir /easyrpg/rtp && \
|
||||
tar -xzf /downloads/easyrpg-rtp.tar.gz -C /easyrpg/rtp --strip-components=1 && \
|
||||
find /easyrpg/rtp \( -name '.git*' -o -name Makefile \) -exec rm -f {} +
|
||||
|
||||
|
||||
# BUILD NGINX MODULE WITH MOD_ZIP
|
||||
FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS nginx-build
|
||||
@@ -379,6 +401,7 @@ COPY --from=emulator-stage /emulatorjs ${WEBSERVER_FOLDER}/assets/emulatorjs
|
||||
COPY --from=emulator-stage /ruffle ${WEBSERVER_FOLDER}/assets/ruffle
|
||||
COPY --from=emulator-stage /jsdos/dist ${WEBSERVER_FOLDER}/assets/jsdos
|
||||
COPY --from=emulator-stage /fake08 ${WEBSERVER_FOLDER}/assets/pico8
|
||||
COPY --from=emulator-stage /easyrpg ${WEBSERVER_FOLDER}/assets/easyrpg
|
||||
|
||||
|
||||
FROM slim-image AS dev-slim
|
||||
|
||||
@@ -30,6 +30,13 @@ map $request_uri $coop_header {
|
||||
~^/console/rom/[0-9]+/play "same-origin";
|
||||
}
|
||||
|
||||
# The EasyRPG web player fetches each file of a game from games/<rom id>-<user id>/
|
||||
# beside itself. Built from the raw $request_uri, so names keep the client's encoding.
|
||||
map $request_uri $easyrpg_backend_uri {
|
||||
default "";
|
||||
~^/assets/easyrpg/games/(?<rom_id>[0-9]+)-[0-9]+/(?<easyrpg_path>[^?]*) /api/roms/$rom_id/easyrpg/$easyrpg_path;
|
||||
}
|
||||
|
||||
# Library resources under /assets/romm/resources/ are a mix. Covers (?ts=) and
|
||||
# manuals (?v=) are versioned by a known cache-busting query, so they can be
|
||||
# frozen; screenshots and RA badges reuse stable filenames (0.jpg, ...) and are
|
||||
@@ -82,6 +89,13 @@ server {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location ^~ /assets/easyrpg/games/ {
|
||||
if ($easyrpg_backend_uri = "") {
|
||||
return 404;
|
||||
}
|
||||
proxy_pass http://wsgi_server$easyrpg_backend_uri;
|
||||
}
|
||||
|
||||
# Everything else under /assets keeps a stable URL across image upgrades
|
||||
location /assets {
|
||||
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||
|
||||
@@ -928,10 +928,11 @@ With rom-converto enabled (`ROM_CONVERTO_ENABLED`, `converto.download_conversion
|
||||
|
||||
#### ROM Files
|
||||
|
||||
| Method | Path | Scope | Description |
|
||||
| ------ | ---------------------------- | --------- | --------------------------------------- |
|
||||
| GET | `/{id}/files` | ROMS_READ | Get ROM file metadata |
|
||||
| GET | `/{id}/files/content/{name}` | ROMS_READ | Download file (nginx X-Accel or direct) |
|
||||
| Method | Path | Scope | Description |
|
||||
| ------ | ---------------------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| GET | `/{id}/files` | ROMS_READ | Get ROM file metadata |
|
||||
| GET | `/{id}/files/content/{name}` | ROMS_READ | Download file (nginx X-Accel or direct) |
|
||||
| GET | `/{id}/easyrpg/{path}` | ROMS_READ | RPG Maker game files and generated `index.json` for the EasyRPG player |
|
||||
|
||||
#### ROM Installs
|
||||
|
||||
@@ -1941,6 +1942,7 @@ Falls back to `FakeRedis` in test mode.
|
||||
| `DISABLE_RUFFLE_RS` | `false` | Hide Ruffle Flash player |
|
||||
| `DISABLE_JSDOS` | `false` | Hide js-dos player |
|
||||
| `DISABLE_PICO8` | `false` | Hide PICO-8 player |
|
||||
| `DISABLE_EASYRPG` | `false` | Hide EasyRPG player |
|
||||
|
||||
#### Task Scheduling
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@ Server capability flags used throughout the UI:
|
||||
|
||||
```typescript
|
||||
METADATA_SOURCES: { IGDB, SS, MOBY, RA, STEAMGRIDDB, LAUNCHBOX, ... }
|
||||
EMULATION: { DISABLE_EMULATOR_JS, DISABLE_RUFFLE_RS, DISABLE_JSDOS, DISABLE_PICO8 }
|
||||
EMULATION: { DISABLE_EMULATOR_JS, DISABLE_RUFFLE_RS, DISABLE_JSDOS, DISABLE_PICO8, DISABLE_EASYRPG }
|
||||
FRONTEND: { DISABLE_USERPASS_LOGIN, DISABLE_LOGS_VIEWER, YOUTUBE_BASE_URL }
|
||||
OIDC: { ENABLED, AUTOLOGIN, PROVIDER, RP_INITIATED_LOGOUT }
|
||||
TASKS: { scheduled task configurations }
|
||||
|
||||
@@ -36,6 +36,12 @@ if [[ -n ${EMULATOR_ASSETS_DIR-} && -d ${EMULATOR_ASSETS_DIR} ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# The backend reads the free RTP where the full image keeps it.
|
||||
if [[ -n ${EMULATOR_ASSETS_DIR-} && -d ${EMULATOR_ASSETS_DIR}/easyrpg/rtp ]]; then
|
||||
mkdir -p /var/www/html/assets/easyrpg
|
||||
ln -sfn "${EMULATOR_ASSETS_DIR}/easyrpg/rtp" /var/www/html/assets/easyrpg/rtp
|
||||
fi
|
||||
|
||||
# Define a signal handler to propagate termination signals
|
||||
function handle_termination() {
|
||||
echo "Terminating child processes..."
|
||||
|
||||
@@ -149,6 +149,7 @@ DISABLE_EMULATOR_JS=false # Disable in-browser play via EmulatorJS
|
||||
DISABLE_RUFFLE_RS=false # Disable in-browser Flash playback via RuffleRS
|
||||
DISABLE_JSDOS=false # Disable in-browser Win3.x and Win9.x playback via js-dos
|
||||
DISABLE_PICO8=false # Disable in-browser PICO-8 playback via FAKE-08
|
||||
DISABLE_EASYRPG=false # Disable in-browser RPG Maker 2000/2003 playback via EasyRPG
|
||||
|
||||
# ROM conversion
|
||||
ROM_CONVERTO_ENABLED=false # Enable rom-converto for the "Convert library" task and ?format= download conversions
|
||||
|
||||
@@ -103,6 +103,7 @@ export type DetailedRomSchema = {
|
||||
has_simple_single_file: boolean;
|
||||
has_nested_single_file: boolean;
|
||||
has_multiple_files: boolean;
|
||||
is_easyrpg_game: boolean;
|
||||
full_path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -7,5 +7,6 @@ export type EmulationDict = {
|
||||
DISABLE_RUFFLE_RS: boolean;
|
||||
DISABLE_JSDOS: boolean;
|
||||
DISABLE_PICO8: boolean;
|
||||
DISABLE_EASYRPG: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export type SimpleRomSchema = {
|
||||
has_simple_single_file: boolean;
|
||||
has_nested_single_file: boolean;
|
||||
has_multiple_files: boolean;
|
||||
is_easyrpg_game: boolean;
|
||||
full_path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Може да се играе",
|
||||
"playable-both": "Може да се играе в браузъра и да се стриймва от {label}",
|
||||
"playable-browser-dosbox": "Може да се играе в браузъра чрез DOSBox",
|
||||
"playable-browser-easyrpg": "Може да се играе в браузъра чрез EasyRPG",
|
||||
"playable-browser-emulatorjs": "Може да се играе в браузъра чрез EmulatorJS",
|
||||
"playable-browser-jsdos": "Може да се играе в браузъра чрез js-dos",
|
||||
"playable-browser-pico8": "Може да се играе в браузъра чрез PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Дискове",
|
||||
"download-memory-card": "Изтегляне на картата",
|
||||
"easyrpg-browser-save-warning": "Запазените данни на EasyRPG се съхраняват само в този браузър и не се синхронизират с RomM.",
|
||||
"easyrpg-screen": "Игрален екран на EasyRPG",
|
||||
"edit-memory-card": "Редактиране на карта памет",
|
||||
"emulator": "Емулатор",
|
||||
"error-hint-auth": "Възможно е да нямате права за стрийминг или сесията ви да е изтекла. Опитайте да влезете отново.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Hratelné",
|
||||
"playable-both": "Hratelné v prohlížeči a streamovatelné z {label}",
|
||||
"playable-browser-dosbox": "Hratelné v prohlížeči přes DOSBox",
|
||||
"playable-browser-easyrpg": "Hratelné v prohlížeči přes EasyRPG",
|
||||
"playable-browser-emulatorjs": "Hratelné v prohlížeči přes EmulatorJS",
|
||||
"playable-browser-jsdos": "Hratelné v prohlížeči přes js-dos",
|
||||
"playable-browser-pico8": "Hratelné v prohlížeči přes PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Disky",
|
||||
"download-memory-card": "Stáhnout kartu",
|
||||
"easyrpg-browser-save-warning": "Pozice EasyRPG se ukládají pouze v tomto prohlížeči a nesynchronizují se s RomM.",
|
||||
"easyrpg-screen": "Obrazovka hry EasyRPG",
|
||||
"edit-memory-card": "Upravit paměťovou kartu",
|
||||
"emulator": "Emulátor",
|
||||
"error-hint-auth": "Možná nemáte oprávnění ke streamování nebo vaše relace vypršela. Zkuste se přihlásit znovu.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Spielbar",
|
||||
"playable-both": "Im Browser spielbar und streambar über {label}",
|
||||
"playable-browser-dosbox": "Im Browser spielbar über DOSBox",
|
||||
"playable-browser-easyrpg": "Im Browser spielbar über EasyRPG",
|
||||
"playable-browser-emulatorjs": "Im Browser spielbar über EmulatorJS",
|
||||
"playable-browser-jsdos": "Im Browser spielbar über js-dos",
|
||||
"playable-browser-pico8": "Im Browser spielbar über PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Discs",
|
||||
"download-memory-card": "Karte herunterladen",
|
||||
"easyrpg-browser-save-warning": "EasyRPG-Spielstände werden nur in diesem Browser gespeichert und nicht mit RomM synchronisiert.",
|
||||
"easyrpg-screen": "EasyRPG-Spielbildschirm",
|
||||
"edit-memory-card": "Speicherkarte bearbeiten",
|
||||
"emulator": "Emulator",
|
||||
"error-hint-auth": "Möglicherweise hast du keine Berechtigung zum Streamen oder deine Sitzung ist abgelaufen. Versuche, dich erneut anzumelden.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Playable",
|
||||
"playable-both": "Playable in browser and streamable from {label}",
|
||||
"playable-browser-dosbox": "Playable in browser through DOSBox",
|
||||
"playable-browser-easyrpg": "Playable in browser through EasyRPG",
|
||||
"playable-browser-emulatorjs": "Playable in browser through EmulatorJS",
|
||||
"playable-browser-jsdos": "Playable in browser through js-dos",
|
||||
"playable-browser-pico8": "Playable in browser through PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Discs",
|
||||
"download-memory-card": "Download card",
|
||||
"easyrpg-browser-save-warning": "EasyRPG saves are stored only in this browser and are not synced with RomM.",
|
||||
"easyrpg-screen": "EasyRPG game screen",
|
||||
"edit-memory-card": "Edit memory card",
|
||||
"emulator": "Emulator",
|
||||
"error-hint-auth": "You may not have permission to stream, or your login session expired. Try signing in again.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Playable",
|
||||
"playable-both": "Playable in browser and streamable from {label}",
|
||||
"playable-browser-dosbox": "Playable in browser through DOSBox",
|
||||
"playable-browser-easyrpg": "Playable in browser through EasyRPG",
|
||||
"playable-browser-emulatorjs": "Playable in browser through EmulatorJS",
|
||||
"playable-browser-jsdos": "Playable in browser through js-dos",
|
||||
"playable-browser-pico8": "Playable in browser through PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Discs",
|
||||
"download-memory-card": "Download card",
|
||||
"easyrpg-browser-save-warning": "EasyRPG saves are stored only in this browser and are not synced with RomM.",
|
||||
"easyrpg-screen": "EasyRPG game screen",
|
||||
"edit-memory-card": "Edit memory card",
|
||||
"emulator": "Emulator",
|
||||
"error-hint-auth": "You may not have permission to stream, or your login session expired. Try signing in again.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Jugable",
|
||||
"playable-both": "Jugable en el navegador y transmisible desde {label}",
|
||||
"playable-browser-dosbox": "Jugable en el navegador mediante DOSBox",
|
||||
"playable-browser-easyrpg": "Jugable en el navegador mediante EasyRPG",
|
||||
"playable-browser-emulatorjs": "Jugable en el navegador mediante EmulatorJS",
|
||||
"playable-browser-jsdos": "Jugable en el navegador mediante js-dos",
|
||||
"playable-browser-pico8": "Jugable en el navegador mediante PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Discos",
|
||||
"download-memory-card": "Descargar tarjeta",
|
||||
"easyrpg-browser-save-warning": "Las partidas de EasyRPG se almacenan solo en este navegador y no se sincronizan con RomM.",
|
||||
"easyrpg-screen": "Pantalla de juego de EasyRPG",
|
||||
"edit-memory-card": "Editar tarjeta de memoria",
|
||||
"emulator": "Emulador",
|
||||
"error-hint-auth": "Puede que no tengas permiso para transmitir o que tu sesión haya expirado. Intenta iniciar sesión de nuevo.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Jouable",
|
||||
"playable-both": "Jouable dans le navigateur et diffusable depuis {label}",
|
||||
"playable-browser-dosbox": "Jouable dans le navigateur via DOSBox",
|
||||
"playable-browser-easyrpg": "Jouable dans le navigateur via EasyRPG",
|
||||
"playable-browser-emulatorjs": "Jouable dans le navigateur via EmulatorJS",
|
||||
"playable-browser-jsdos": "Jouable dans le navigateur via js-dos",
|
||||
"playable-browser-pico8": "Jouable dans le navigateur via PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Disques",
|
||||
"download-memory-card": "Télécharger la carte",
|
||||
"easyrpg-browser-save-warning": "Les sauvegardes EasyRPG sont stockées uniquement dans ce navigateur et ne sont pas synchronisées avec RomM.",
|
||||
"easyrpg-screen": "Écran de jeu EasyRPG",
|
||||
"edit-memory-card": "Modifier la carte mémoire",
|
||||
"emulator": "Émulateur",
|
||||
"error-hint-auth": "Vous n'avez peut-être pas l'autorisation de streamer, ou votre session a expiré. Essayez de vous reconnecter.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Játszható",
|
||||
"playable-both": "Böngészőben játszható és streamelhető innen: {label}",
|
||||
"playable-browser-dosbox": "Böngészőben játszható DOSBox segítségével",
|
||||
"playable-browser-easyrpg": "Böngészőben játszható EasyRPG segítségével",
|
||||
"playable-browser-emulatorjs": "Böngészőben játszható EmulatorJS segítségével",
|
||||
"playable-browser-jsdos": "Böngészőben játszható js-dos segítségével",
|
||||
"playable-browser-pico8": "Böngészőben játszható PICO-8 segítségével",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Lemezek",
|
||||
"download-memory-card": "Kártya letöltése",
|
||||
"easyrpg-browser-save-warning": "Az EasyRPG mentések csak ebben a böngészőben tárolódnak, és nem szinkronizálódnak a RomM-mal.",
|
||||
"easyrpg-screen": "EasyRPG játékképernyő",
|
||||
"edit-memory-card": "Memóriakártya szerkesztése",
|
||||
"emulator": "Emulátor",
|
||||
"error-hint-auth": "Lehet, hogy nincs jogosultságod a streameléshez, vagy lejárt a munkameneted. Próbálj meg újra bejelentkezni.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Giocabile",
|
||||
"playable-both": "Giocabile nel browser e trasmissibile da {label}",
|
||||
"playable-browser-dosbox": "Giocabile nel browser tramite DOSBox",
|
||||
"playable-browser-easyrpg": "Giocabile nel browser tramite EasyRPG",
|
||||
"playable-browser-emulatorjs": "Giocabile nel browser tramite EmulatorJS",
|
||||
"playable-browser-jsdos": "Giocabile nel browser tramite js-dos",
|
||||
"playable-browser-pico8": "Giocabile nel browser tramite PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Dischi",
|
||||
"download-memory-card": "Scarica card",
|
||||
"easyrpg-browser-save-warning": "I salvataggi di EasyRPG vengono archiviati solo in questo browser e non vengono sincronizzati con RomM.",
|
||||
"easyrpg-screen": "Schermo di gioco EasyRPG",
|
||||
"edit-memory-card": "Modifica memory card",
|
||||
"emulator": "Emulatore",
|
||||
"error-hint-auth": "Potresti non avere il permesso di trasmettere in streaming oppure la tua sessione è scaduta. Prova ad accedere di nuovo.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "プレイ可能",
|
||||
"playable-both": "ブラウザでプレイ可能、{label} からストリーミング可能",
|
||||
"playable-browser-dosbox": "DOSBox によりブラウザでプレイ可能",
|
||||
"playable-browser-easyrpg": "EasyRPG によりブラウザでプレイ可能",
|
||||
"playable-browser-emulatorjs": "EmulatorJS によりブラウザでプレイ可能",
|
||||
"playable-browser-jsdos": "js-dos によりブラウザでプレイ可能",
|
||||
"playable-browser-pico8": "PICO-8 によりブラウザでプレイ可能",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "ディスク",
|
||||
"download-memory-card": "カードをダウンロード",
|
||||
"easyrpg-browser-save-warning": "EasyRPG のセーブデータはこのブラウザーにのみ保存され、RomM とは同期されません。",
|
||||
"easyrpg-screen": "EasyRPG のゲーム画面",
|
||||
"edit-memory-card": "メモリーカードを編集",
|
||||
"emulator": "エミュレーター",
|
||||
"error-hint-auth": "ストリーミングの権限がないか、ログインセッションの有効期限が切れている可能性があります。もう一度サインインしてみてください。",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "플레이 가능",
|
||||
"playable-both": "브라우저에서 플레이 가능, {label}에서 스트리밍 가능",
|
||||
"playable-browser-dosbox": "DOSBox를 통해 브라우저에서 플레이 가능",
|
||||
"playable-browser-easyrpg": "EasyRPG을 통해 브라우저에서 플레이 가능",
|
||||
"playable-browser-emulatorjs": "EmulatorJS를 통해 브라우저에서 플레이 가능",
|
||||
"playable-browser-jsdos": "js-dos를 통해 브라우저에서 플레이 가능",
|
||||
"playable-browser-pico8": "PICO-8을 통해 브라우저에서 플레이 가능",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "디스크",
|
||||
"download-memory-card": "카드 다운로드",
|
||||
"easyrpg-browser-save-warning": "EasyRPG 저장 데이터는 이 브라우저에만 저장되며 RomM과 동기화되지 않습니다.",
|
||||
"easyrpg-screen": "EasyRPG 게임 화면",
|
||||
"edit-memory-card": "메모리 카드 편집",
|
||||
"emulator": "에뮬레이터",
|
||||
"error-hint-auth": "스트리밍 권한이 없거나 로그인 세션이 만료되었을 수 있습니다. 다시 로그인해 보세요.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Grywalne",
|
||||
"playable-both": "Grywalne w przeglądarce i dostępne w streamingu z {label}",
|
||||
"playable-browser-dosbox": "Grywalne w przeglądarce przez DOSBox",
|
||||
"playable-browser-easyrpg": "Grywalne w przeglądarce przez EasyRPG",
|
||||
"playable-browser-emulatorjs": "Grywalne w przeglądarce przez EmulatorJS",
|
||||
"playable-browser-jsdos": "Grywalne w przeglądarce przez js-dos",
|
||||
"playable-browser-pico8": "Grywalne w przeglądarce przez PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Płyty",
|
||||
"download-memory-card": "Pobierz kartę",
|
||||
"easyrpg-browser-save-warning": "Zapisy EasyRPG są przechowywane tylko w tej przeglądarce i nie są synchronizowane z RomM.",
|
||||
"easyrpg-screen": "Ekran gry EasyRPG",
|
||||
"edit-memory-card": "Edytuj kartę pamięci",
|
||||
"emulator": "Emulator",
|
||||
"error-hint-auth": "Możesz nie mieć uprawnień do streamowania lub twoja sesja wygasła. Spróbuj zalogować się ponownie.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Jogável",
|
||||
"playable-both": "Jogável no navegador e transmissível de {label}",
|
||||
"playable-browser-dosbox": "Jogável no navegador via DOSBox",
|
||||
"playable-browser-easyrpg": "Jogável no navegador via EasyRPG",
|
||||
"playable-browser-emulatorjs": "Jogável no navegador via EmulatorJS",
|
||||
"playable-browser-jsdos": "Jogável no navegador via js-dos",
|
||||
"playable-browser-pico8": "Jogável no navegador via PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Discos",
|
||||
"download-memory-card": "Baixar cartão",
|
||||
"easyrpg-browser-save-warning": "Os saves do EasyRPG são armazenados apenas neste navegador e não são sincronizados com o RomM.",
|
||||
"easyrpg-screen": "Tela de jogo do EasyRPG",
|
||||
"edit-memory-card": "Editar cartão de memória",
|
||||
"emulator": "Emulador",
|
||||
"error-hint-auth": "Talvez você não tenha permissão para transmitir ou sua sessão expirou. Tente entrar novamente.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Se poate juca",
|
||||
"playable-both": "Se poate juca în browser și se poate transmite de pe {label}",
|
||||
"playable-browser-dosbox": "Se poate juca în browser prin DOSBox",
|
||||
"playable-browser-easyrpg": "Se poate juca în browser prin EasyRPG",
|
||||
"playable-browser-emulatorjs": "Se poate juca în browser prin EmulatorJS",
|
||||
"playable-browser-jsdos": "Se poate juca în browser prin js-dos",
|
||||
"playable-browser-pico8": "Se poate juca în browser prin PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Discuri",
|
||||
"download-memory-card": "Descarcă cardul",
|
||||
"easyrpg-browser-save-warning": "Salvările EasyRPG sunt stocate numai în acest browser și nu sunt sincronizate cu RomM.",
|
||||
"easyrpg-screen": "Ecran de joc EasyRPG",
|
||||
"edit-memory-card": "Editează cardul de memorie",
|
||||
"emulator": "Emulator",
|
||||
"error-hint-auth": "Este posibil să nu ai permisiunea de a transmite sau sesiunea ta a expirat. Încearcă să te autentifici din nou.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Можно играть",
|
||||
"playable-both": "Запускается в браузере и транслируется с {label}",
|
||||
"playable-browser-dosbox": "Запускается в браузере через DOSBox",
|
||||
"playable-browser-easyrpg": "Запускается в браузере через EasyRPG",
|
||||
"playable-browser-emulatorjs": "Запускается в браузере через EmulatorJS",
|
||||
"playable-browser-jsdos": "Запускается в браузере через js-dos",
|
||||
"playable-browser-pico8": "Запускается в браузере через PICO-8",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Диски",
|
||||
"download-memory-card": "Скачать карту",
|
||||
"easyrpg-browser-save-warning": "Сохранения EasyRPG хранятся только в этом браузере и не синхронизируются с RomM.",
|
||||
"easyrpg-screen": "Игровой экран EasyRPG",
|
||||
"edit-memory-card": "Изменить карту памяти",
|
||||
"emulator": "Эмулятор",
|
||||
"error-hint-auth": "Возможно, у вас нет прав на стриминг или ваша сессия истекла. Попробуйте войти снова.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "Oynanabilir",
|
||||
"playable-both": "Tarayıcıda oynanabilir ve {label} üzerinden yayınlanabilir",
|
||||
"playable-browser-dosbox": "DOSBox ile tarayıcıda oynanabilir",
|
||||
"playable-browser-easyrpg": "EasyRPG ile tarayıcıda oynanabilir",
|
||||
"playable-browser-emulatorjs": "EmulatorJS ile tarayıcıda oynanabilir",
|
||||
"playable-browser-jsdos": "js-dos ile tarayıcıda oynanabilir",
|
||||
"playable-browser-pico8": "PICO-8 ile tarayıcıda oynanabilir",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "Diskler",
|
||||
"download-memory-card": "Kartı indir",
|
||||
"easyrpg-browser-save-warning": "EasyRPG kayıtları yalnızca bu tarayıcıda saklanır ve RomM ile eşitlenmez.",
|
||||
"easyrpg-screen": "EasyRPG oyun ekranı",
|
||||
"edit-memory-card": "Hafıza kartını düzenle",
|
||||
"emulator": "Emülatör",
|
||||
"error-hint-auth": "Yayın yapma izniniz olmayabilir veya oturumunuzun süresi dolmuş olabilir. Yeniden oturum açmayı deneyin.",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "可玩",
|
||||
"playable-both": "可在浏览器中游玩,也可从 {label} 串流",
|
||||
"playable-browser-dosbox": "可通过 DOSBox 在浏览器中游玩",
|
||||
"playable-browser-easyrpg": "可通过 EasyRPG 在浏览器中游玩",
|
||||
"playable-browser-emulatorjs": "可通过 EmulatorJS 在浏览器中游玩",
|
||||
"playable-browser-jsdos": "可通过 js-dos 在浏览器中游玩",
|
||||
"playable-browser-pico8": "可通过 PICO-8 在浏览器中游玩",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "容器桌面",
|
||||
"discs": "光盘",
|
||||
"download-memory-card": "下载记忆卡",
|
||||
"easyrpg-browser-save-warning": "EasyRPG 存档仅保存在此浏览器中,不会与 RomM 同步。",
|
||||
"easyrpg-screen": "EasyRPG 游戏画面",
|
||||
"edit-memory-card": "编辑记忆卡",
|
||||
"emulator": "模拟器",
|
||||
"error-hint-auth": "您可能没有串流权限,或者登录会话已过期。请尝试重新登录。",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"playable": "可遊玩",
|
||||
"playable-both": "可在瀏覽器中遊玩,也可從 {label} 串流",
|
||||
"playable-browser-dosbox": "可透過 DOSBox 在瀏覽器中遊玩",
|
||||
"playable-browser-easyrpg": "可透過 EasyRPG 在瀏覽器中遊玩",
|
||||
"playable-browser-emulatorjs": "可透過 EmulatorJS 在瀏覽器中遊玩",
|
||||
"playable-browser-jsdos": "可透過 js-dos 在瀏覽器中遊玩",
|
||||
"playable-browser-pico8": "可透過 PICO-8 在瀏覽器中遊玩",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"desktop-title": "Container desktop",
|
||||
"discs": "光碟",
|
||||
"download-memory-card": "下載記憶卡",
|
||||
"easyrpg-browser-save-warning": "EasyRPG 存檔僅保存在此瀏覽器中,不會與 RomM 同步。",
|
||||
"easyrpg-screen": "EasyRPG 遊戲畫面",
|
||||
"edit-memory-card": "編輯記憶卡",
|
||||
"emulator": "模擬器",
|
||||
"error-hint-auth": "您可能沒有串流權限,或登入工作階段已過期。請嘗試重新登入。",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const ROUTES = {
|
||||
EMULATORJS: "emulatorjs",
|
||||
JSDOS: "jsdos",
|
||||
PICO8: "pico8",
|
||||
EASYRPG: "easyrpg",
|
||||
RUFFLE: "ruffle",
|
||||
STREAM: "stream",
|
||||
STREAM_DESKTOP: "stream-desktop",
|
||||
|
||||
@@ -249,6 +249,14 @@ const routes = [
|
||||
v2: v2For(ROUTES.PICO8),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "rom/:rom/easyrpg",
|
||||
name: ROUTES.EASYRPG,
|
||||
components: {
|
||||
default: () => import("@/views/Home.vue"),
|
||||
v2: v2For(ROUTES.EASYRPG),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "rom/:rom/ruffle",
|
||||
name: ROUTES.RUFFLE,
|
||||
|
||||
@@ -47,6 +47,7 @@ const defaultHeartbeat: Heartbeat = {
|
||||
DISABLE_RUFFLE_RS: false,
|
||||
DISABLE_JSDOS: false,
|
||||
DISABLE_PICO8: false,
|
||||
DISABLE_EASYRPG: false,
|
||||
},
|
||||
FRONTEND: {
|
||||
DISABLE_USERPASS_LOGIN: false,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import type { Config } from "@/stores/config";
|
||||
import type { Heartbeat } from "@/stores/heartbeat";
|
||||
import {
|
||||
isEasyRpgEmulationSupported,
|
||||
isEasyRpgGame,
|
||||
isJsDosBundle,
|
||||
isJsDosEmulationSupported,
|
||||
isPico8EmulationSupported,
|
||||
@@ -18,6 +20,7 @@ function makeHeartbeat(
|
||||
DISABLE_RUFFLE_RS: false,
|
||||
DISABLE_JSDOS: false,
|
||||
DISABLE_PICO8: false,
|
||||
DISABLE_EASYRPG: false,
|
||||
...emulation,
|
||||
},
|
||||
} as Heartbeat;
|
||||
@@ -124,3 +127,34 @@ describe("PICO-8 support", () => {
|
||||
expect(isPico8Rom(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EasyRPG support", () => {
|
||||
it("supports the rpg-maker platform and configured remaps", () => {
|
||||
expect(isEasyRpgEmulationSupported("rpg-maker", makeHeartbeat())).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isEasyRpgEmulationSupported(
|
||||
"easyrpg",
|
||||
makeHeartbeat(),
|
||||
makeConfig({ easyrpg: "rpg-maker" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isEasyRpgEmulationSupported("snes", makeHeartbeat())).toBe(false);
|
||||
});
|
||||
|
||||
it("respects the DISABLE_EASYRPG admin toggle", () => {
|
||||
expect(
|
||||
isEasyRpgEmulationSupported(
|
||||
"rpg-maker",
|
||||
makeHeartbeat({ DISABLE_EASYRPG: true }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("follows the backend's game folder check", () => {
|
||||
expect(isEasyRpgGame(makeRom({ is_easyrpg_game: true }))).toBe(true);
|
||||
expect(isEasyRpgGame(makeRom({ is_easyrpg_game: false }))).toBe(false);
|
||||
expect(isEasyRpgGame(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -710,6 +710,37 @@ export function isPico8Rom(rom: SimpleRom | null | undefined) {
|
||||
return name?.endsWith(".p8") === true || name?.endsWith(".p8.png") === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if EasyRPG emulation is supported for a given platform.
|
||||
*
|
||||
* @param platformSlug The platform slug.
|
||||
* @param heartbeat The heartbeat object.
|
||||
* @param config Optional configuration object.
|
||||
* @returns True if supported, false otherwise.
|
||||
*/
|
||||
export function isEasyRpgEmulationSupported(
|
||||
platformSlug: string,
|
||||
heartbeat: Heartbeat,
|
||||
config?: Config,
|
||||
) {
|
||||
if (heartbeat.EMULATION.DISABLE_EASYRPG) return false;
|
||||
|
||||
const slug = resolvePlatformSlug(platformSlug, config);
|
||||
return slug.toLowerCase() === "rpg-maker";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ROM is an extracted RPG Maker 2000/2003 game folder.
|
||||
*
|
||||
* The web player fetches each file on demand, so it cannot read an archive.
|
||||
*
|
||||
* @param rom The ROM to check.
|
||||
* @returns True if the ROM is a game folder, false otherwise.
|
||||
*/
|
||||
export function isEasyRpgGame(rom: SimpleRom | null | undefined) {
|
||||
return rom?.is_easyrpg_game === true;
|
||||
}
|
||||
|
||||
export type PlayingStatus =
|
||||
RomUserStatus | "backlogged" | "now_playing" | "hidden";
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export const ROM_DEFAULTS: SimpleRom = {
|
||||
has_simple_single_file: true,
|
||||
has_nested_single_file: false,
|
||||
has_multiple_files: false,
|
||||
is_easyrpg_game: false,
|
||||
full_path: "/romm/library/snes/Chrono Trigger.sfc",
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
|
||||
@@ -159,6 +159,7 @@ const syntheticRom = computed<SimpleRom>(() => ({
|
||||
has_simple_single_file: false,
|
||||
has_nested_single_file: false,
|
||||
has_multiple_files: false,
|
||||
is_easyrpg_game: false,
|
||||
full_path: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
|
||||
@@ -11,6 +11,8 @@ const support = vi.hoisted(() => ({
|
||||
jsDos: vi.fn(() => false),
|
||||
pico8: vi.fn(() => false),
|
||||
pico8Rom: vi.fn(() => false),
|
||||
easyRpg: vi.fn(() => false),
|
||||
easyRpgGame: vi.fn(() => false),
|
||||
ruffle: vi.fn(() => false),
|
||||
// js-dos also demands its own bundle format; on by default so the engine
|
||||
// stubs stay the only variable.
|
||||
@@ -45,6 +47,8 @@ vi.mock("@/utils", () => ({
|
||||
isJsDosEmulationSupported: support.jsDos,
|
||||
isPico8EmulationSupported: support.pico8,
|
||||
isPico8Rom: support.pico8Rom,
|
||||
isEasyRpgEmulationSupported: support.easyRpg,
|
||||
isEasyRpgGame: support.easyRpgGame,
|
||||
isRuffleEmulationSupported: support.ruffle,
|
||||
isJsDosBundle: support.jsDosBundle,
|
||||
}));
|
||||
@@ -63,6 +67,8 @@ beforeEach(() => {
|
||||
support.jsDos.mockReturnValue(false);
|
||||
support.pico8.mockReturnValue(false);
|
||||
support.pico8Rom.mockReturnValue(false);
|
||||
support.easyRpg.mockReturnValue(false);
|
||||
support.easyRpgGame.mockReturnValue(false);
|
||||
support.ruffle.mockReturnValue(false);
|
||||
support.jsDosBundle.mockReturnValue(true);
|
||||
streamContainer.value = null;
|
||||
@@ -74,6 +80,7 @@ describe("useCanPlay", () => {
|
||||
["EJS", "ejs", "canPlayEJS", []],
|
||||
["js-dos", "jsDos", "canPlayJsDos", []],
|
||||
["PICO-8", "pico8", "canPlayPico8", ["pico8Rom"]],
|
||||
["EasyRPG", "easyRpg", "canPlayEasyRpg", ["easyRpgGame"]],
|
||||
["Ruffle", "ruffle", "canPlayRuffle", []],
|
||||
] as const)(
|
||||
"reports %s support on its own flag",
|
||||
@@ -89,7 +96,7 @@ describe("useCanPlay", () => {
|
||||
|
||||
// A physical game, or one whose file vanished from the library, has nothing
|
||||
// to hand the emulator: every route boots from the download endpoint.
|
||||
it.each(["ejs", "jsDos", "pico8", "ruffle"] as const)(
|
||||
it.each(["ejs", "jsDos", "pico8", "easyRpg", "ruffle"] as const)(
|
||||
"refuses %s for a rom with no file on disk",
|
||||
(stub) => {
|
||||
support[stub].mockReturnValue(true);
|
||||
@@ -107,6 +114,8 @@ describe("useCanPlay", () => {
|
||||
support.jsDos.mockReturnValue(true);
|
||||
support.pico8.mockReturnValue(true);
|
||||
support.pico8Rom.mockReturnValue(true);
|
||||
support.easyRpg.mockReturnValue(true);
|
||||
support.easyRpgGame.mockReturnValue(true);
|
||||
support.ruffle.mockReturnValue(true);
|
||||
streamContainer.value = {};
|
||||
nativeSupported.value = true;
|
||||
@@ -115,6 +124,7 @@ describe("useCanPlay", () => {
|
||||
canPlayEJS,
|
||||
canPlayJsDos,
|
||||
canPlayPico8,
|
||||
canPlayEasyRpg,
|
||||
canPlayRuffle,
|
||||
canPlayStream,
|
||||
canPlayNative,
|
||||
@@ -124,6 +134,7 @@ describe("useCanPlay", () => {
|
||||
expect(canPlayEJS.value).toBe(false);
|
||||
expect(canPlayJsDos.value).toBe(false);
|
||||
expect(canPlayPico8.value).toBe(false);
|
||||
expect(canPlayEasyRpg.value).toBe(false);
|
||||
expect(canPlayRuffle.value).toBe(false);
|
||||
expect(canPlayStream.value).toBe(false);
|
||||
expect(canPlayNative.value).toBe(false);
|
||||
@@ -193,6 +204,16 @@ describe("useCanPlay", () => {
|
||||
expect(canPlay.value).toBe(false);
|
||||
});
|
||||
|
||||
// The web player fetches each file on demand, so it cannot open an archive.
|
||||
it("refuses EasyRPG for a rom that is not an extracted game folder", () => {
|
||||
support.easyRpg.mockReturnValue(true);
|
||||
support.easyRpgGame.mockReturnValue(false);
|
||||
const { canPlay, canPlayEasyRpg } = useCanPlay(() => makeRom());
|
||||
|
||||
expect(canPlayEasyRpg.value).toBe(false);
|
||||
expect(canPlay.value).toBe(false);
|
||||
});
|
||||
|
||||
it("stays unplayable when no engine supports the platform", () => {
|
||||
const { canPlay } = useCanPlay(() => makeRom());
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
// inside PlayBtn.vue; v2 lifts it to a composable so the card overlay
|
||||
// and the menu item agree with the details-header CTA.
|
||||
//
|
||||
// "Playable" means EJS, js-dos, PICO-8, or Ruffle can run the platform on this
|
||||
// "Playable" means EJS, js-dos, PICO-8, EasyRPG, or Ruffle can run the platform on this
|
||||
// server (admin toggles + platform support + WebGL availability) and there
|
||||
// is a file to boot, or a streaming container is configured for the
|
||||
// platform, or the desktop shell has a locally installed emulator for it.
|
||||
// A physical game or one missing from the filesystem has nothing
|
||||
// to hand the emulator, and js-dos additionally needs the file to be one of
|
||||
// its own bundles. The individual flags are exposed so the play action can
|
||||
// pick the right route (EJS vs js-dos vs PICO-8 vs Ruffle vs Stream vs
|
||||
// Native).
|
||||
// to hand the emulator, js-dos additionally needs the file to be one of its
|
||||
// own bundles, and EasyRPG an extracted game folder. The individual flags are
|
||||
// exposed so the play action can pick the right route (EJS vs js-dos vs
|
||||
// PICO-8 vs EasyRPG vs Ruffle vs Stream vs Native).
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
import storeConfig from "@/stores/config";
|
||||
@@ -20,6 +20,8 @@ import { useNativeStore } from "@/stores/native";
|
||||
import type { SimpleRom } from "@/stores/roms";
|
||||
import { useStreamingStore } from "@/stores/streaming";
|
||||
import {
|
||||
isEasyRpgEmulationSupported,
|
||||
isEasyRpgGame,
|
||||
isEJSEmulationSupported,
|
||||
isJsDosBundle,
|
||||
isJsDosEmulationSupported,
|
||||
@@ -33,6 +35,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
|
||||
canPlayEJS: ComputedRef<boolean>;
|
||||
canPlayJsDos: ComputedRef<boolean>;
|
||||
canPlayPico8: ComputedRef<boolean>;
|
||||
canPlayEasyRpg: ComputedRef<boolean>;
|
||||
canPlayRuffle: ComputedRef<boolean>;
|
||||
canPlayStream: ComputedRef<boolean>;
|
||||
canPlayNative: ComputedRef<boolean>;
|
||||
@@ -65,6 +68,11 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
|
||||
() => onPico8Platform.value && isPico8Rom(getRom()),
|
||||
);
|
||||
|
||||
const onEasyRpgPlatform = supportedBy(isEasyRpgEmulationSupported);
|
||||
const canPlayEasyRpg = computed(
|
||||
() => onEasyRpgPlatform.value && isEasyRpgGame(getRom()),
|
||||
);
|
||||
|
||||
// The broker is handed the ROM file, so a physical game or one missing
|
||||
// from the filesystem has nothing to stream any more than it has to boot.
|
||||
const canPlayStream = computed(() => {
|
||||
@@ -86,6 +94,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
|
||||
canPlayEJS.value ||
|
||||
canPlayJsDos.value ||
|
||||
canPlayPico8.value ||
|
||||
canPlayEasyRpg.value ||
|
||||
canPlayRuffle.value ||
|
||||
canPlayStream.value ||
|
||||
canPlayNative.value,
|
||||
@@ -96,6 +105,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
|
||||
canPlayEJS,
|
||||
canPlayJsDos,
|
||||
canPlayPico8,
|
||||
canPlayEasyRpg,
|
||||
canPlayRuffle,
|
||||
canPlayStream,
|
||||
canPlayNative,
|
||||
|
||||
@@ -30,6 +30,7 @@ const CHAIN_ROUTES: ReadonlySet<string> = new Set([
|
||||
ROUTES.ROM,
|
||||
ROUTES.EMULATORJS,
|
||||
ROUTES.PICO8,
|
||||
ROUTES.EASYRPG,
|
||||
ROUTES.RUFFLE,
|
||||
ROUTES.STREAM,
|
||||
]);
|
||||
|
||||
@@ -16,6 +16,7 @@ const confirmProtectedLaunch = { value: true };
|
||||
const canPlayEJS = { value: true };
|
||||
const canPlayJsDos = { value: false };
|
||||
const canPlayPico8 = { value: false };
|
||||
const canPlayEasyRpg = { value: false };
|
||||
const canPlayRuffle = { value: false };
|
||||
const canPlayNative = { value: false };
|
||||
const streamContainer = { value: null as object | null };
|
||||
@@ -109,6 +110,7 @@ vi.mock("@/v2/composables/useCanPlay", () => ({
|
||||
canPlayEJS,
|
||||
canPlayJsDos,
|
||||
canPlayPico8,
|
||||
canPlayEasyRpg,
|
||||
canPlayRuffle,
|
||||
canPlayNative,
|
||||
canPlayStream: {
|
||||
@@ -169,6 +171,7 @@ beforeEach(() => {
|
||||
canPlayEJS.value = true;
|
||||
canPlayJsDos.value = false;
|
||||
canPlayPico8.value = false;
|
||||
canPlayEasyRpg.value = false;
|
||||
canPlayRuffle.value = false;
|
||||
canPlayNative.value = false;
|
||||
streamContainer.value = null;
|
||||
@@ -423,6 +426,16 @@ describe("useGameActions.play — launch confirmation", () => {
|
||||
expect(push).toHaveBeenCalledWith("/rom/1/pico8");
|
||||
});
|
||||
|
||||
it("goes to EasyRPG for an RPG Maker game", async () => {
|
||||
canPlayEJS.value = false;
|
||||
canPlayEasyRpg.value = true;
|
||||
const actions = useGameActions(() => makeRom());
|
||||
|
||||
await actions.play();
|
||||
|
||||
expect(push).toHaveBeenCalledWith("/rom/1/easyrpg");
|
||||
});
|
||||
|
||||
it("prefers js-dos over EmulatorJS for its platforms", async () => {
|
||||
canPlayJsDos.value = true;
|
||||
const actions = useGameActions(() => makeRom());
|
||||
@@ -530,6 +543,10 @@ describe("useGameActions.playPath", () => {
|
||||
expect(actions.playPath("local")).toBe("/rom/1/pico8");
|
||||
|
||||
canPlayPico8.value = false;
|
||||
canPlayEasyRpg.value = true;
|
||||
expect(actions.playPath("local")).toBe("/rom/1/easyrpg");
|
||||
|
||||
canPlayEasyRpg.value = false;
|
||||
expect(actions.playPath("local")).toBe("/rom/1/ruffle");
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface GameActionsOptions {
|
||||
/** Which player a launch is asking for. "auto" lets availability decide. */
|
||||
export type PlayTarget = "auto" | "local" | "stream";
|
||||
|
||||
type PlayerSlug = "stream" | "jsdos" | "ejs" | "pico8" | "ruffle";
|
||||
type PlayerSlug = "stream" | "jsdos" | "ejs" | "pico8" | "easyrpg" | "ruffle";
|
||||
|
||||
// A conversion can take a while on large discs; stop polling after about an hour.
|
||||
const FORMAT_POLL_SECONDS = 30;
|
||||
@@ -92,6 +92,7 @@ export function useGameActions(
|
||||
canPlayEJS,
|
||||
canPlayJsDos,
|
||||
canPlayPico8,
|
||||
canPlayEasyRpg,
|
||||
canPlayRuffle,
|
||||
canPlayStream,
|
||||
canPlayNative,
|
||||
@@ -105,6 +106,7 @@ export function useGameActions(
|
||||
canPlayEJS.value ||
|
||||
canPlayJsDos.value ||
|
||||
canPlayPico8.value ||
|
||||
canPlayEasyRpg.value ||
|
||||
canPlayRuffle.value,
|
||||
);
|
||||
|
||||
@@ -388,6 +390,7 @@ export function useGameActions(
|
||||
else if (canPlayJsDos.value) slug = "jsdos";
|
||||
else if (canPlayEJS.value) slug = "ejs";
|
||||
else if (canPlayPico8.value) slug = "pico8";
|
||||
else if (canPlayEasyRpg.value) slug = "easyrpg";
|
||||
else if (canPlayRuffle.value) slug = "ruffle";
|
||||
// Last, because the play page offers the native launch beside whichever
|
||||
// in-browser core the branches above would have picked.
|
||||
|
||||
@@ -12,6 +12,7 @@ const ruffleSlugs = new Set<string>();
|
||||
const dosboxSlugs = new Set<string>();
|
||||
const jsDosSlugs = new Set<string>();
|
||||
const pico8Slugs = new Set<string>();
|
||||
const easyRpgSlugs = new Set<string>();
|
||||
const streamContainers = new Map<string, { label: string; emulator: string }>();
|
||||
const streamingEnabled = { value: true };
|
||||
|
||||
@@ -19,6 +20,7 @@ vi.mock("@/utils", () => ({
|
||||
isEJSEmulationSupported: (slug: string) => ejsSlugs.has(slug),
|
||||
isJsDosEmulationSupported: (slug: string) => jsDosSlugs.has(slug),
|
||||
isPico8EmulationSupported: (slug: string) => pico8Slugs.has(slug),
|
||||
isEasyRpgEmulationSupported: (slug: string) => easyRpgSlugs.has(slug),
|
||||
isRuffleEmulationSupported: (slug: string) => ruffleSlugs.has(slug),
|
||||
getSupportedEJSCores: (slug: string) =>
|
||||
dosboxSlugs.has(slug) ? ["dosbox_pure"] : ["snes9x"],
|
||||
@@ -83,6 +85,7 @@ beforeEach(() => {
|
||||
dosboxSlugs.clear();
|
||||
jsDosSlugs.clear();
|
||||
pico8Slugs.clear();
|
||||
easyRpgSlugs.clear();
|
||||
streamContainers.clear();
|
||||
streamingEnabled.value = true;
|
||||
});
|
||||
@@ -102,6 +105,13 @@ describe("usePlatformPlayable", () => {
|
||||
expect(emulator.value).toBe("pico8");
|
||||
});
|
||||
|
||||
it("resolves EasyRPG as a browser player", () => {
|
||||
easyRpgSlugs.add("rpg-maker");
|
||||
const { mode, emulator } = usePlatformPlayable(() => "rpg-maker");
|
||||
expect(mode.value).toBe("browser");
|
||||
expect(emulator.value).toBe("easyrpg");
|
||||
});
|
||||
|
||||
it("resolves streaming-only as stream and carries the container label", () => {
|
||||
streamContainers.set("ps2", { label: "PCSX2", emulator: "pcsx2" });
|
||||
const { mode, playable, streamLabel } = usePlatformPlayable(() => "ps2");
|
||||
@@ -189,6 +199,9 @@ describe("playTooltip", () => {
|
||||
expect(playTooltip("browser", "pico8", null)).toBe(
|
||||
"platform.playable-browser-pico8",
|
||||
);
|
||||
expect(playTooltip("browser", "easyrpg", null)).toBe(
|
||||
"platform.playable-browser-easyrpg",
|
||||
);
|
||||
expect(playTooltip("browser", "emulatorjs", null)).toBe(
|
||||
"platform.playable-browser-emulatorjs",
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useNativeStore } from "@/stores/native";
|
||||
import { useStreamingStore } from "@/stores/streaming";
|
||||
import {
|
||||
getSupportedEJSCores,
|
||||
isEasyRpgEmulationSupported,
|
||||
isEJSEmulationSupported,
|
||||
isJsDosEmulationSupported,
|
||||
isPico8EmulationSupported,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
} from "@/utils";
|
||||
|
||||
export type PlatformEmulator =
|
||||
"emulatorjs" | "ruffle" | "jsdos" | "pico8" | "dosbox" | null;
|
||||
"emulatorjs" | "ruffle" | "jsdos" | "pico8" | "easyrpg" | "dosbox" | null;
|
||||
|
||||
export type PlatformPlayMode = "browser" | "stream" | "both" | null;
|
||||
|
||||
@@ -58,6 +59,7 @@ function resolveEmulator(
|
||||
if (isRuffleEmulationSupported(slug, heartbeat, config)) return "ruffle";
|
||||
if (isJsDosEmulationSupported(slug, heartbeat, config)) return "jsdos";
|
||||
if (isPico8EmulationSupported(slug, heartbeat, config)) return "pico8";
|
||||
if (isEasyRpgEmulationSupported(slug, heartbeat, config)) return "easyrpg";
|
||||
if (!isEJSEmulationSupported(slug, heartbeat, config)) return null;
|
||||
const cores = getSupportedEJSCores(resolvePlatformSlug(slug, config));
|
||||
if (cores.includes("dosbox_pure")) return "dosbox";
|
||||
@@ -170,6 +172,8 @@ export function playTooltip(
|
||||
return t("platform.playable-browser-jsdos");
|
||||
case "pico8":
|
||||
return t("platform.playable-browser-pico8");
|
||||
case "easyrpg":
|
||||
return t("platform.playable-browser-easyrpg");
|
||||
case "dosbox":
|
||||
return t("platform.playable-browser-dosbox");
|
||||
default:
|
||||
|
||||
@@ -34,6 +34,7 @@ const MORPH_ROM_ROUTES: ReadonlySet<string> = new Set([
|
||||
ROUTES.EMULATORJS,
|
||||
ROUTES.JSDOS,
|
||||
ROUTES.PICO8,
|
||||
ROUTES.EASYRPG,
|
||||
ROUTES.RUFFLE,
|
||||
]);
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export const v2RouteComponents: Partial<Record<RouteName, V2Route>> = {
|
||||
[ROUTES.EMULATORJS]: () => import("@/v2/views/Player/EmulatorJS.vue"),
|
||||
[ROUTES.JSDOS]: () => import("@/v2/views/Player/JsDos.vue"),
|
||||
[ROUTES.PICO8]: () => import("@/v2/views/Player/Pico8.vue"),
|
||||
[ROUTES.EASYRPG]: () => import("@/v2/views/Player/EasyRpg.vue"),
|
||||
[ROUTES.RUFFLE]: () => import("@/v2/views/Player/Ruffle.vue"),
|
||||
[ROUTES.STREAM]: () => import("@/v2/views/Player/Stream.vue"),
|
||||
[ROUTES.STREAM_DESKTOP]: () => import("@/v2/views/Player/Desktop.vue"),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import EasyRpg from "./EasyRpg.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
flushPlaySession: vi.fn(),
|
||||
getRom: vi.fn(),
|
||||
playSessionStart: vi.fn(),
|
||||
routerReplace: vi.fn(() => Promise.resolve()),
|
||||
setPlaying: vi.fn(),
|
||||
setStageActive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n");
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
onBeforeRouteLeave: vi.fn(),
|
||||
useRoute: () => ({ params: { rom: "1" } }),
|
||||
useRouter: () => ({ replace: mocks.routerReplace }),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/router", () => ({
|
||||
ROUTES: { ROM: "rom", PLATFORM: "platform" },
|
||||
}));
|
||||
|
||||
vi.mock("@/services/api/rom", () => ({
|
||||
default: { getRom: mocks.getRom },
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/auth", () => ({
|
||||
default: () => ({ user: { id: 7 } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/playing", () => ({
|
||||
default: () => ({
|
||||
setPlaying: mocks.setPlaying,
|
||||
setStageActive: mocks.setStageActive,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/roms", () => ({
|
||||
default: () => ({ getDetailedRom: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/components/shared/GameCover.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/useBackgroundArt", () => ({
|
||||
useBackgroundArt: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/useFullscreenPref", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return { useFullscreenPref: () => ({ fullscreenOnPlay: ref(false) }) };
|
||||
});
|
||||
|
||||
vi.mock("@/v2/composables/usePageTitle", () => ({
|
||||
usePageTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/usePlaySession", () => ({
|
||||
usePlaySession: () => ({
|
||||
start: mocks.playSessionStart,
|
||||
flush: mocks.flushPlaySession,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/stores/galleryRoms", () => ({
|
||||
default: () => ({ getRomById: () => null }),
|
||||
}));
|
||||
|
||||
const rom = {
|
||||
id: 1,
|
||||
name: "Yume Nikki",
|
||||
fs_name_no_ext: "Yume Nikki",
|
||||
platform_id: 2,
|
||||
platform_slug: "rpg-maker",
|
||||
rom_user: { status: null },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getRom.mockResolvedValue({ data: rom });
|
||||
});
|
||||
|
||||
function mountView(): VueWrapper {
|
||||
return mount(EasyRpg, {
|
||||
global: {
|
||||
stubs: {
|
||||
RBtn: {
|
||||
name: "RBtn",
|
||||
props: { to: { type: String, default: undefined } },
|
||||
emits: ["click"],
|
||||
template: "<button @click=\"$emit('click')\"><slot /></button>",
|
||||
},
|
||||
RCard: { template: "<div><slot /></div>" },
|
||||
RSpinner: true,
|
||||
RSwitch: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function play(): Promise<VueWrapper> {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await wrapper.get(".r-v2-player__play").trigger("click");
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("EasyRpg", () => {
|
||||
it("boots the game under a save name scoped to the user", async () => {
|
||||
const wrapper = await play();
|
||||
|
||||
expect(wrapper.get("iframe").attributes("src")).toBe(
|
||||
"/assets/easyrpg/index.html?game=1-7",
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("starts timing the session once the player page loads", async () => {
|
||||
const wrapper = await play();
|
||||
expect(mocks.playSessionStart).not.toHaveBeenCalled();
|
||||
|
||||
await wrapper.get("iframe").trigger("load");
|
||||
await wrapper.get("iframe").trigger("load");
|
||||
|
||||
expect(mocks.playSessionStart).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.playSessionStart).toHaveBeenCalledWith(rom);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("records the play session and returns to the game on quit", async () => {
|
||||
const wrapper = await play();
|
||||
|
||||
await wrapper.get(".r-v2-player__quit").trigger("click");
|
||||
|
||||
expect(mocks.flushPlaySession).toHaveBeenCalled();
|
||||
expect(mocks.routerReplace).toHaveBeenCalledWith("/rom/1");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("records the play session when the page goes away", async () => {
|
||||
const wrapper = await play();
|
||||
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
|
||||
expect(mocks.flushPlaySession).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { RSwitch } from "@v2/lib";
|
||||
import { useEventListener } from "@vueuse/core";
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
} from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
import romApi from "@/services/api/rom";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import type { DetailedRom } from "@/stores/roms";
|
||||
import PlayerShell from "@/v2/components/Player/PlayerShell.vue";
|
||||
import { useFullscreenPref } from "@/v2/composables/useFullscreenPref";
|
||||
import { useIsAlive } from "@/v2/composables/useIsAlive";
|
||||
import { usePlaySession } from "@/v2/composables/usePlaySession";
|
||||
import { usePlayerExit } from "@/v2/composables/usePlayerExit";
|
||||
import { usePlayerFullscreen } from "@/v2/composables/usePlayerFullscreen";
|
||||
import { usePlayerHero } from "@/v2/composables/usePlayerHero";
|
||||
import { usePlayingWhile } from "@/v2/composables/useStageActive";
|
||||
import { useUnloadGuard } from "@/v2/composables/useUnloadGuard";
|
||||
import { focusFromInput } from "@/v2/utils/autofocus";
|
||||
|
||||
const { t } = useI18n();
|
||||
const exit = usePlayerExit();
|
||||
const alive = useIsAlive();
|
||||
const authStore = storeAuth();
|
||||
const { fullscreenOnPlay } = useFullscreenPref();
|
||||
const playSession = usePlaySession();
|
||||
|
||||
const rom = shallowRef<DetailedRom | null>(null);
|
||||
const gameRunning = ref(false);
|
||||
// The game takes the keyboard inside its frame, so app hotkeys stand down.
|
||||
usePlayingWhile(gameRunning);
|
||||
const quitting = ref(false);
|
||||
const frame = ref<HTMLIFrameElement | null>(null);
|
||||
const { enter: enterFullscreen } = usePlayerFullscreen(frame);
|
||||
let sessionStarted = false;
|
||||
|
||||
const { romId, heroRom, title, platformLabel } = usePlayerHero(rom);
|
||||
|
||||
// The player keeps saves in a browser database named after the game, so the
|
||||
// user id keeps each RomM account's saves apart. The server ignores it.
|
||||
const playerSrc = computed(
|
||||
() => `/assets/easyrpg/index.html?game=${romId}-${authStore.user?.id}`,
|
||||
);
|
||||
|
||||
async function onPlay() {
|
||||
const currentRom = rom.value;
|
||||
if (!currentRom || authStore.user?.id == null) return;
|
||||
|
||||
gameRunning.value = true;
|
||||
|
||||
await nextTick();
|
||||
if (fullscreenOnPlay.value) {
|
||||
void enterFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Browsers deliver keyboard and gamepad input only to the focused frame.
|
||||
// Play time starts here, so a player page that never loads records none.
|
||||
function onFrameLoad() {
|
||||
focusFromInput(frame.value);
|
||||
if (rom.value && !sessionStarted) {
|
||||
sessionStarted = true;
|
||||
playSession.start(rom.value);
|
||||
}
|
||||
}
|
||||
|
||||
function leavePlayer(destination: string) {
|
||||
if (quitting.value) return;
|
||||
quitting.value = true;
|
||||
playSession.flush();
|
||||
exit.leave(destination);
|
||||
}
|
||||
|
||||
function onlyQuit() {
|
||||
leavePlayer(`/rom/${romId}`);
|
||||
}
|
||||
|
||||
useUnloadGuard(() => gameRunning.value && !quitting.value);
|
||||
|
||||
onMounted(async () => {
|
||||
const romResponse = await romApi.getRom({ romId });
|
||||
if (!alive.value) return;
|
||||
rom.value = romResponse.data;
|
||||
});
|
||||
|
||||
onBeforeRouteLeave((to) => exit.guard(to));
|
||||
|
||||
useEventListener(window, "pagehide", () => playSession.flush());
|
||||
|
||||
onBeforeUnmount(() => playSession.flush());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PlayerShell
|
||||
:hero-rom="heroRom"
|
||||
:title="title"
|
||||
:platform-label="platformLabel"
|
||||
:rom-id="romId"
|
||||
:ready="!!rom"
|
||||
:running="gameRunning"
|
||||
:quitting="quitting"
|
||||
@play="onPlay"
|
||||
@quit="onlyQuit"
|
||||
>
|
||||
<template #settings>
|
||||
<RSwitch v-model="fullscreenOnPlay" :label="t('play.full-screen')" />
|
||||
|
||||
<p class="r-v2-easyrpg__save-note">
|
||||
{{ t("play.easyrpg-browser-save-warning") }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template #brand>
|
||||
<div class="r-v2-easyrpg__brand">
|
||||
<span>{{ t("play.powered-by") }}</span>
|
||||
<span class="r-v2-easyrpg__brand-name">EasyRPG</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #stage>
|
||||
<iframe
|
||||
v-if="gameRunning"
|
||||
ref="frame"
|
||||
class="r-v2-easyrpg__stage"
|
||||
:src="playerSrc"
|
||||
:title="t('play.easyrpg-screen')"
|
||||
allow="autoplay; fullscreen; gamepad"
|
||||
@load="onFrameLoad"
|
||||
/>
|
||||
</template>
|
||||
</PlayerShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.r-v2-easyrpg__save-note {
|
||||
margin: 0;
|
||||
color: var(--r-color-fg-muted);
|
||||
font-size: var(--r-font-size-sm);
|
||||
}
|
||||
|
||||
.r-v2-easyrpg__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
font-size: var(--r-font-size-xs);
|
||||
color: var(--r-color-fg-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
.r-v2-easyrpg__brand-name {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
color: var(--r-color-fg-secondary);
|
||||
}
|
||||
|
||||
.r-v2-easyrpg__stage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -82,6 +82,15 @@ export default defineConfig(({ mode }) => {
|
||||
"/api": upstream,
|
||||
"^/(?:ws|netplay)": { ...upstream, ws: true },
|
||||
"/openapi.json": upstream,
|
||||
// The EasyRPG web player fetches each game file from games/<rom id>-<user id>/ beside itself.
|
||||
"^/assets/easyrpg/games/": {
|
||||
...upstream,
|
||||
rewrite: (/** @type {string} */ path) =>
|
||||
path.replace(
|
||||
/^\/assets\/easyrpg\/games\/(\d+)-\d+\//,
|
||||
"/api/roms/$1/easyrpg/",
|
||||
),
|
||||
},
|
||||
// Local dev serves covers from the frontend/assets symlink.
|
||||
...(remote ? { "/assets/romm": upstream } : {}),
|
||||
};
|
||||
@@ -156,6 +165,7 @@ export default defineConfig(({ mode }) => {
|
||||
"**/assets/jsdos/**",
|
||||
"**/assets/pico8/**",
|
||||
"**/assets/gme/**",
|
||||
"**/assets/easyrpg/**",
|
||||
],
|
||||
},
|
||||
proxy,
|
||||
|
||||
Reference in New Issue
Block a user