diff --git a/.gitignore b/.gitignore index 49dfa956c..f6ffb4853 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ frontend/assets/ruffle/* frontend/assets/jsdos frontend/assets/pico8 frontend/assets/gme +frontend/assets/easyrpg # virtualenv .venv diff --git a/Dockerfile b/Dockerfile index cdd246962..f1609601a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 88b62a27e..a2f89ab89 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -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")) diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index c2911fe21..99448368e 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -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, diff --git a/backend/endpoints/responses/heartbeat.py b/backend/endpoints/responses/heartbeat.py index 9bcf7b186..20b3a5b1b 100644 --- a/backend/endpoints/responses/heartbeat.py +++ b/backend/endpoints/responses/heartbeat.py @@ -40,6 +40,7 @@ class EmulationDict(TypedDict): DISABLE_RUFFLE_RS: bool DISABLE_JSDOS: bool DISABLE_PICO8: bool + DISABLE_EASYRPG: bool class FrontendDict(TypedDict): diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index d21841e66..65bef8fd1 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -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 diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 461661a2e..88dbc2bba 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -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) diff --git a/backend/endpoints/roms/easyrpg.py b/backend/endpoints/roms/easyrpg.py new file mode 100644 index 000000000..24290028a --- /dev/null +++ b/backend/endpoints/roms/easyrpg.py @@ -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") diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 5fcdd3584..81da0651f 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -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, diff --git a/backend/handler/easyrpg/__init__.py b/backend/handler/easyrpg/__init__.py new file mode 100644 index 000000000..f21410eac --- /dev/null +++ b/backend/handler/easyrpg/__init__.py @@ -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() diff --git a/backend/handler/easyrpg/fixtures/rtp_table.json b/backend/handler/easyrpg/fixtures/rtp_table.json new file mode 100644 index 000000000..ee098045d --- /dev/null +++ b/backend/handler/easyrpg/fixtures/rtp_table.json @@ -0,0 +1,2753 @@ +{ + "backdrop": [ + ["ダンジョン1", "dungeon1", "cave1"], + ["ダンジョン2", "dungeon2", "lavacave2"], + ["ダンジョン3", "dungeon3", "icecave3"], + ["ダンジョン4", "dungeon4", "cave4"], + ["ダンジョン5", "dungeon5", "brickcave5"], + ["宇宙", "space", "galaxy"], + ["山道", "mountainpath", "canyon"], + ["岩場", "rockyarea", "snowcanyon"], + ["廃墟", "ruins", "wasteruins"], + ["森1", "forest1"], + ["森2", "forest2"], + ["橋", "bridge"], + ["毒沼", "poisonswamp", "swamp"], + ["海", "sea"], + ["玉座", "throne", "castle"], + ["異空間", "strangespace", "lightspeed"], + ["砂浜", "sandybeach", "seabeach"], + ["砂漠", "desert"], + ["神殿", "shrine", "greece"], + ["空", "sky"], + ["船上", "shipdeck", "ship"], + ["草原", "grassland", "grass"], + ["荒地", "wasteland"], + ["街中", "downtown", "town"], + ["雪原", "snowfield", "snow"], + ["cave9"], + ["dark3"], + ["desert5"], + ["dungeon1"], + ["dungeon2"], + ["falls2"], + ["forest4"], + ["future1"], + ["future3"], + ["mtn4"], + ["nbridge"], + ["plainsg"], + ["ship1"], + ["underw1"], + ["お墓", "graveyard", "grave", "바닥", "墳場"], + ["お寺", "temple1", "shrine", "temple", "절", "寺廟"], + ["ダンジョン1", "dungeon1", "cave", "donjohn1", "던젼1", "迷宮1"], + ["ダンジョン2", "dungeon2", "lavacave", "donjohnq", "던젼2", "迷宮2"], + ["ダンジョン3", "dungeon3", "icecave", "donjohnr", "던젼3", "迷宮3"], + ["ダンジョン4", "dungeon4", "stalagmites", "donjohns", "던젼4", "迷宮4"], + ["ダンジョン5", "dungeon5", "labyrinth", "donjohnt", "던젼5", "迷宮5"], + ["ダンジョン6", "dungeon6", "traininghall", "donjohnu", "던젼6", "迷宮6"], + ["宇宙", "universe", "space", "우주"], + ["山道", "mountain road", "mountain", "mountainpath", "산길", "山路"], + ["岩場", "rocky road", "rockbed", "rocks", "바위", "岩壁"], + ["廃墟", "ruins1", "wasteland", "ruins", "폐허", "廢墟"], + [ + "旧市街", + "old town", + "ghost-town", + "city", + "oldurban", + "구시가지", + "老街" + ], + ["森1", "forest1", "forestp", "숲1", "森林1"], + ["森2", "forest2", "forestq", "숲2", "森林2"], + ["橋", "bridge", "다리"], + ["毒沼", "swamp", "poisonswamp", "독연못"], + [ + "洋館", + "mansion", + "building", + "insidecastle", + "westernstylebuilding", + "양관(서양식집)" + ], + ["海", "sea", "ocean", "바다"], + ["玉座", "castle", "ballseat", "옥좌", "王位"], + [ + "異空間", + "space", + "strange", + "spacelight", + "strangespacial", + "이공간", + "異次元空間" + ], + ["砂浜", "beach", "sandybeach", "사구", "淺灘"], + ["砂漠", "desert", "사막", "沙漠"], + ["神殿", "temple2", "ruins1", "sanctuary", "신전"], + ["空", "sky", "clouds", "empty", "하늘", "天空"], + ["船上", "ship", "shipdeck", "onboat", "선상"], + ["草原", "grassland", "plains", "grass", "grassyplain", "초원"], + ["荒地", "wasteland", "barren", "roughforging", "황야"], + ["街中", "town", "cityhouse", "city", "길가운데", "街道"], + ["路上", "road", "도로 위", "道路"], + ["遺跡", "ruins2", "insidepyramid", "ruins", "유적"], + ["銭湯", "bathhouse", "bath", "공중 목욕탕", "澡堂"], + ["闘技場", "arena", "competitionplace", "투기장", "競技場"], + ["雪原", "snow field", "snowfield", "snow", "설원"] + ], + "battle": [ + ["その他", "other", "etc"], + ["バリア", "barrier"], + ["ブレス", "breath", "poison"], + ["上昇", "buff", "up"], + ["下降", "debuff", "down"], + ["冷気", "ice", "cold"], + ["剣1", "sword1"], + ["剣2", "sword2"], + ["吸収", "bite", "absorption"], + ["回復", "recovery", "sun"], + ["大地", "earth"], + ["打撃", "blow", "hit"], + ["斧", "axe"], + ["暗黒", "dark"], + ["槍", "spear"], + ["水", "water"], + ["治療", "treatment", "sphere"], + ["炎1", "fire1"], + ["炎2", "fire2"], + ["爆発", "explosion"], + ["爪", "claw", "fang"], + ["矢", "arrow"], + ["神聖", "holy"], + ["蘇生", "ressurection", "ray"], + ["雷", "thunder", "zip"], + ["鞭", "whip"], + ["風", "wind"], + ["麻痺", "paralysis"], + ["enemyhp"], + ["qande"], + [ + "2003その他1", + "2003 other1", + "misc1", + "2003-other1", + "2003addition1", + "2003기타1", + "2003其他1" + ], + [ + "2003その他2", + "2003 other2", + "misc2", + "2003-other2", + "2003addition2", + "2003기타2", + "2003其他2" + ], + [ + "2003バリア", + "2003 barrier", + "barrier1", + "2003-barrier", + "2003barrier", + "2003베리어", + "2003護罩" + ], + [ + "2003ブレス", + "2003 breath", + "breath1", + "2003-poison", + "2003breath", + "2003브레스", + "2003氣息" + ], + [ + "2003光柱", + "2003 light pillar", + "lightray", + "2003-lines", + "2003luminouspillar", + "2003주위의 빛" + ], + [ + "2003冷気", + "2003 cold", + "ice1", + "2003-cold", + "2003coldair", + "2003냉기", + "2003冰" + ], + [ + "2003剣", + "2003 sword", + "sword1", + "2003-sword", + "2003sword", + "2003검", + "2003劍" + ], + [ + "2003吸収", + "2003 absorb", + "absorb1", + "2003-absorption", + "2003absorption", + "2003흡수", + "2003吸收" + ], + [ + "2003回復", + "2003 recovery", + "healing1", + "2003-sun", + "2003recovery", + "2003회복" + ], + [ + "2003大地", + "2003 earth", + "earth1", + "2003-earth", + "2003ground", + "2003대지" + ], + ["2003弓", "2003 bow", "bow1", "2003-arrow", "2003bow", "2003화살"], + [ + "2003打撃", + "2003 blow", + "strike", + "2003-hit", + "2003shock", + "2003타격", + "2003打擊" + ], + ["2003斧", "2003 axe", "axe1", "2003-axe", "2003ax", "2003도끼"], + [ + "2003暗黒", + "2003 dark", + "dark1", + "2003-dark", + "2003dark", + "2003암흑", + "2003暗黑" + ], + ["2003槍", "2003 spear", "spear1", "2003-spear", "2003spear", "2003창"], + ["2003水", "2003 water", "water1", "2003-water", "2003water", "2003물"], + ["2003治療", "2003 cure", "cure1", "2003-sphere", "2003remedy", "2003치료"], + ["2003炎", "2003 fire", "fire1", "2003-fire", "2003flame", "2003화염"], + [ + "2003爆発", + "2003 explosion", + "explode1", + "2003-explosion", + "2003explosive", + "2003폭발", + "2003爆發" + ], + ["2003爪", "2003 claw", "claw1", "2003-claws", "2003nail", "2003손톱"], + ["2003神聖", "2003 holy", "holy1", "2003-holy", "2003holy", "2003신성"], + [ + "2003蘇生", + "2003 revive", + "revive1", + "2003-angel", + "2003revival", + "2003소생", + "2003復活" + ], + ["2003雷", "2003 thunder", "bolt1", "2003-zip", "2003thunder", "2003전격"], + ["2003鞭", "2003 whip", "whip1", "2003-whip", "2003rod", "2003채찍"], + ["2003風", "2003 wind", "wind1", "2003-wind", "2003wind", "2003바람"], + [ + "2003麻痺", + "2003 paralysis", + "paralyze1", + "2003-paralysis", + "2003paralysis", + "2003마비" + ], + ["その他", "other", "misc3", "addition", "기타", "其他"], + ["バリア", "barrier", "barrier2", "배리어", "護罩"], + ["ブレス", "breath", "breath2", "호흡", "氣息"], + ["上昇", "buff", "increase", "rise", "상승"], + ["下降", "debuff", "decrease", "drop", "하강"], + ["冷気", "ice", "ice2", "coldair", "냉기", "冰"], + ["剣1", "sword1", "sword2", "검1", "劍1"], + ["剣2", "sword2", "sword3", "검2", "劍2"], + ["吸収", "bite", "absorb2", "absorption", "흡수", "吸收"], + ["回復", "recovery", "healing2", "회복"], + ["大地", "earth", "earth2", "ground", "대지"], + ["打撃", "blow", "attack", "shock", "타격", "打擊"], + ["斧", "axe", "axe2", "ax", "도끼"], + ["暗黒", "dark", "dark2", "암흑", "暗黑"], + ["槍", "spear", "spear2", "창"], + ["水", "water", "water2", "물"], + ["治療", "treatment", "cure2", "remedy", "치료"], + ["炎1", "fire1", "fire2", "flame1", "화염1"], + ["炎2", "fire2", "fire3", "flame2", "화염2"], + ["爆発", "explosion", "explode2", "explosive", "폭발", "爆發"], + ["爪", "claw", "claw2", "nail", "손톱"], + ["矢", "arrow", "bow2", "bow", "화살", "箭"], + ["神聖", "holy", "holy2", "신성"], + ["蘇生", "ressurection", "revive2", "revivial", "소생", "復活"], + ["雷", "thunder", "bolt2", "전격"], + ["鞭", "whip", "whip2", "rod", "채찍"], + ["風", "wind", "wind2", "바람"], + ["麻痺", "paralysis", "paralyze2", "마비"] + ], + "battlecharset": [ + [ + "エルフ女a", + "female elf a", + "elf-f-1", + "tuanaa", + "elfwomana", + "엘프女 a", + "妖精女a" + ], + [ + "エルフ女b", + "female elf b", + "elf-f-2", + "tuanab", + "elfwomanb", + "엘프女 b", + "妖精女b" + ], + [ + "エルフ男a", + "male elf a", + "elf-m-1", + "temmada", + "elfmana", + "엘프男 a", + "妖精男a" + ], + [ + "エルフ男b", + "male elf b", + "elf-m-2", + "temmadb", + "elfmanb", + "엘프男 b", + "妖精男b" + ], + [ + "中華女a", + "chinese woman a", + "chinese-f-1", + "chuzaa", + "chinesewomana", + "중화女a" + ], + [ + "中華女b", + "chinese woman b", + "chinese-f-2", + "chuzab", + "chinesewomanb", + "중화女b" + ], + [ + "中華男a", + "chinese man a", + "chinese-m-1", + "chinga", + "chinesemana", + "중화男a" + ], + [ + "中華男b", + "chinese man b", + "chinese-m-2", + "chingb", + "chinesemanb", + "중화男b" + ], + [ + "侍a", + "samurai a", + "samurai1", + "garrea", + "samuraia", + "사무라이a", + "武士a" + ], + [ + "侍b", + "samurai b", + "samurai2", + "garreb", + "samuraib", + "사무라이b", + "武士b" + ], + [ + "僧侶女a", + "female monk a", + "priestess1", + "kloria", + "monkwomana", + "승려女a" + ], + [ + "僧侶女b", + "female monk b", + "priestess2", + "klorib", + "monkwomanb", + "승려女b" + ], + ["僧侶男a", "male monk a", "priest1", "siossa", "monkmana", "승려男a"], + ["僧侶男b", "male monk b", "priest2", "siossb", "monkmanb", "승려男b"], + ["勇者女a", "female hero a", "hero-f-1", "noddaa", "herowomana", "용자女a"], + ["勇者女b", "female hero b", "hero-f-2", "noddab", "herowomanb", "용자女b"], + ["勇者男a", "male hero a", "hero-m-1", "jemmona", "heromana", "용자男a"], + ["勇者男b", "male hero b", "hero-m-2", "jemmonb", "heromanb", "용자男b"], + ["女性1a", "woman1 a", "woman1-1", "cletoa", "womanépa", "여성1a"], + ["女性1b", "woman1 b", "woman1-2", "cletob", "womanépb", "여성1b"], + ["女性2a", "woman2 a", "woman2-1", "jikuaa", "womanéqa", "여성2a"], + ["女性2b", "woman2 b", "woman2-2", "jikuab", "womanéqb", "여성2b"], + ["女性3a", "woman3 a", "woman3-1", "irregiaa", "womanéra", "여성3a"], + ["女性3b", "woman3 b", "woman3-2", "irregiab", "womanérb", "여성3b"], + ["女性4a", "woman4 a", "woman4-1", "nysmaa", "womanésa", "여성4a"], + ["女性4b", "woman4 b", "woman4-2", "nysmab", "womanésb", "여성4b"], + ["女性5a", "woman5 a", "woman5-1", "beruaa", "womanéta", "여성5a"], + ["女性5b", "woman5 b", "woman5-2", "beruab", "womanétb", "여성5b"], + [ + "忍者女a", + "female ninja a", + "ninja-f-1", + "ginnya", + "ninjawomana", + "닌자女a" + ], + [ + "忍者女b", + "female ninja b", + "ninja-f-2", + "ginnyb", + "ninjawomanb", + "닌자女b" + ], + ["忍者男a", "male ninja a", "ninja-m-1", "xeoda", "ninjamana", "닌자男a"], + ["忍者男b", "male ninja b", "ninja-m-2", "xeodb", "ninjamanb", "닌자男b"], + [ + "戦士女a", + "female warrior a", + "soldier-f-1", + "eljeaa", + "soldierwomana", + "전사女a", + "戰士女a" + ], + [ + "戦士女b", + "female warrior b", + "soldier-f-2", + "eljeab", + "soldierwomanb", + "전사女b", + "戰士女b" + ], + [ + "戦士男a", + "male warrior a", + "soldier-m-1", + "raglea", + "soldiermana", + "전사男a", + "戰士男a" + ], + [ + "戦士男b", + "male warrior b", + "solider-m-2", + "ragleb", + "soldiermanb", + "전사男b", + "戰士男b" + ], + [ + "格闘家女a", + "female fighter a", + "monk-f-1", + "phesaa", + "grapplehousewomana", + "격투가女a", + "格鬥家女a" + ], + [ + "格闘家女b", + "female fighter b", + "monk-f-2", + "phesab", + "grapplehousewomanb", + "격투가女b", + "格鬥家女b" + ], + [ + "格闘家男a", + "male fighter a", + "monk-m-1", + "rasnena", + "grapplehousemana", + "격투가男a", + "格鬥家男a" + ], + [ + "格闘家男b", + "male fighter b", + "monk-m-2", + "rasnenb", + "grapplehousemanb", + "격투가男b", + "格鬥家男b" + ], + [ + "海賊女a", + "female pirate a", + "pirate-f-1", + "rennaa", + "piratewomana", + "해적女a" + ], + [ + "海賊女b", + "female pirate b", + "pirate-f-2", + "rennab", + "piratewomanb", + "해적女b" + ], + [ + "海賊男a", + "male pirate a", + "pirate-m-1", + "goxana", + "piratemana", + "해적男a" + ], + ["海賊男b", "male pirate b", "pirate-m-2", "goxanb", "pirateb", "해적男b"], + ["男性1a", "man1 a", "man1-1", "jidada", "manépa", "남성1a"], + ["男性1b", "man1 b", "man1-2", "jidadb", "manépb", "남성1b"], + ["男性2a", "man2 a", "man2-1", "mjitta", "manéqa", "남성2a"], + ["男性2b", "man2 b", "man2-2", "mjittb", "manéqb", "남성2b"], + ["男性3a", "man3 a", "man3-1", "chuaza", "manéra", "남성3a"], + ["男性3b", "man3 b", "man3-2", "chuazb", "manérb", "남성3b"], + ["男性4a", "man4 a", "man4-1", "shella", "manésa", "남성4a"], + ["男性4b", "man4 b", "man4-2", "shellb", "manésb", "남성4b"], + ["男性5a", "man5 a", "man5-1", "frossa", "manéta", "남성5a"], + ["男性5b", "man5 b", "man5-2", "frossb", "manétb", "남성5b"], + [ + "盗賊女a", + "female bandit a", + "thief-f-1", + "otipaa", + "thiefwomana", + "도적女a", + "盜賊女a" + ], + [ + "盗賊女b", + "female bandit b", + "thief-f-2", + "otipab", + "thiefwomanb", + "도적女b", + "盜賊女b" + ], + [ + "盗賊男a", + "male bandit a", + "thief-m-1", + "unnona", + "thiefmana", + "도적男a", + "盜賊男a" + ], + [ + "盗賊男b", + "male bandit b", + "thief-m-2", + "unnonb", + "thiefmanb", + "도적男b", + "盜賊男b" + ], + [ + "鎧武者a", + "armored warror a", + "armor1", + "joegoa", + "armourwarriora", + "갑옷무사a", + "鎧甲武者a" + ], + [ + "鎧武者b", + "armored warror b", + "armor2", + "joegob", + "armourwarriorb", + "갑옷무사b", + "鎧甲武者b" + ], + [ + "魔術師女a", + "female mage a", + "mage-f-1", + "ljitia", + "magicteacherwomana", + "마술사女a" + ], + [ + "魔術師女b", + "female mage b", + "mage-f-2", + "ljitib", + "magicteacherwomanb", + "마술사女b" + ], + [ + "魔術師男a", + "male mage a", + "mage-m-1", + "rivraa", + "magicteachermana", + "마술사男a" + ], + [ + "魔術師男b", + "male mage b", + "mage-m-2", + "rivrab", + "magicteachermanb", + "마술사男b" + ] + ], + "battleweapon": [["武器", "weapon", "weapons", "standart", "무기"]], + "charset": [ + ["オブジェクト1", "object1"], + ["オブジェクト2", "object2"], + ["モンスター1", "monster1"], + ["モンスター2", "monster2"], + ["一般1", "people1"], + ["一般2", "people2", "people7"], + ["一般3", "people3"], + ["一般4", "people4"], + ["一般5", "people5"], + ["主人公1", "actor1", "chara1"], + ["主人公2", "actor2", "chara2"], + ["主人公3", "actor3", "chara3"], + ["主人公4", "actor4", "chara4"], + ["乗り物", "vehicles", "vehicle"], + ["動物", "animal"], + ["chubby1"], + ["chubby2"], + ["crown1"], + ["crown2"], + ["crown3"], + ["crown4"], + ["crown5"], + ["crown6"], + ["crown7"], + ["future1"], + ["future2"], + ["future3"], + ["men1"], + ["women1"], + ["actraiser3.1"], + ["african"], + ["alex"], + ["alien3"], + ["alien4"], + ["angel"], + ["arquivo-x"], + ["bahamut_lagoon"], + ["bartender"], + ["bloodydoors"], + ["brats01"], + ["breathoffire4"], + ["brian"], + ["chara01"], + ["chara02"], + ["chara03"], + ["chara04"], + ["chara05"], + ["chara06"], + ["chara07"], + ["chara08"], + ["chara09"], + ["chara10"], + ["chara11"], + ["chara12"], + ["chara13"], + ["chara14"], + ["chara15"], + ["chara16"], + ["chara6gold"], + ["charaa1"], + ["charaa2"], + ["chara_c1"], + ["chara_c2"], + ["chara_h_9"], + ["chara_misc_1"], + ["chubby2b"], + ["crosshairandtargets"], + ["crystals"], + ["ctchar"], + ["ctportal"], + ["dbz_chara1"], + ["dbz_chara2"], + ["dbz_orig1"], + ["don_chara1"], + ["don_collection_10"], + ["don_collection_11"], + ["don_collection_12"], + ["don_collection_13"], + ["don_collection_14"], + ["don_collection_15"], + ["don_collection_16"], + ["don_collection_17"], + ["don_collection_18"], + ["don_collection_19"], + ["don_collection_1"], + ["don_collection_20"], + ["don_collection_21"], + ["don_collection_22"], + ["don_collection_23"], + ["don_collection_24"], + ["don_collection_25"], + ["don_collection_26"], + ["don_collection_27"], + ["don_collection_28"], + ["don_collection_2"], + ["don_collection_3"], + ["don_collection_4"], + ["don_collection_5"], + ["don_collection_6"], + ["don_collection_7"], + ["don_collection_8"], + ["don_collection_9"], + ["don_collection_mm"], + ["don_fish"], + ["don_pikachuu"], + ["don_pokeballz"], + ["don_xfiles"], + ["earthbound1"], + ["earthbound2"], + ["earthbound3"], + ["earthboundsheet"], + ["edit1"], + ["egyptian"], + ["eskimo"], + ["evil1"], + ["expression"], + ["ff3_soldiers"], + ["ff7_chara1"], + ["ff83"], + ["ff9chara"], + ["fft_archers"], + ["fft_thieves"], + ["flags_n_chests"], + ["free01"], + ["free02"], + ["free03"], + ["free04"], + ["future_soldiers1"], + ["fx_chara16"], + ["gohanssj1-2"], + ["goldendoors"], + ["greece"], + ["houshin5"], + ["illustset1"], + ["islander"], + ["items_1"], + ["items_2"], + ["items_3"], + ["link"], + ["linofull"], + ["mario"], + ["megaman1"], + ["minato_c"], + ["mk"], + ["mohawk"], + ["newaya"], + ["object3"], + ["object6"], + ["object9"], + ["objecta"], + ["objectb"], + ["objectc"], + ["objectd"], + ["objecte"], + ["objectf"], + ["objectg"], + ["objecth"], + ["objecti"], + ["objectj"], + ["objectk"], + ["objectl"], + ["objects3"], + ["omek"], + ["patrick"], + ["pirate"], + ["pose19"], + ["pose2"], + ["pose3"], + ["pose"], + ["poses_kirby_dolphin"], + ["pw_chara"], + ["robot"], + ["robots1"], + ["rolf'sarmy1"], + ["rolf'sarmy2"], + ["rs3_1"], + ["sailormoon1"], + ["savepoint"], + ["set_ggi1"], + ["set_hei1"], + ["set_ipa1"], + ["set_ipa2"], + ["set_ken1"], + ["set_mon1"], + ["simchara"], + ["simpsons"], + ["slime1"], + ["slime2"], + ["soldiers_brats"], + ["soldiers"], + ["sparkleitems_1"], + ["sparkleitems_2"], + ["sparkleitems_3"], + ["sparkleitems_4"], + ["ss4gku_futrbulma"], + ["staroc1"], + ["starwar1"], + ["starwar2"], + ["sv"], + ["tenchi"], + ["torch_1"], + ["torch"], + ["treasure"], + ["vegetable"], + ["vehicle3"], + ["window"], + ["wizard"], + ["x-files_lh"], + ["xfmainchars2"], + ["xfmainchars"], + ["xmas_chara1"], + ["x-men-movie"], + ["zlightup"], + ["オブジェクト1", "object1", "objects1", "오브젝트1", "物件1"], + ["オブジェクト2", "object2", "objects2", "오브젝트2", "物件2"], + ["モンスター1", "monster1", "monsters1", "몬스터1", "怪物1"], + ["モンスター2", "monster2", "monsters2", "몬스터2", "怪物2"], + ["一般1", "people1", "char1", "general1", "일반1"], + ["一般2", "people2", "char2", "general2", "일반2"], + ["一般3", "people3", "char3", "general3", "일반3"], + ["一般4", "people4", "char4", "general4", "일반4"], + ["一般5", "people5", "char5", "general5", "일반5"], + [ + "主人公1", + "actor1", + "hero1", + "chara1", + "protagonist1", + "주인공1", + "主角1" + ], + [ + "主人公2", + "actor2", + "hero2", + "chara2", + "protagonist2", + "주인공2", + "主角2" + ], + [ + "主人公3", + "actor3", + "hero3", + "chara3", + "protagonist3", + "주인공3", + "主角3" + ], + [ + "主人公4", + "actor4", + "hero4", + "chara4", + "protagonist4", + "주인공4", + "主角4" + ], + ["乗り物", "vehicles", "vehicle", "탈것", "交通工具"], + ["動物", "animal", "animals", "동물"] + ], + "chipset": [ + ["ダンジョン", "dungeon"], + ["内装", "interior", "inner"], + ["基本", "world", "basis"], + ["外観", "exterior", "outline"], + ["船", "ship"], + ["chipset1"], + ["chipset2"], + ["chipset3"], + ["2ktownset"], + ["3d_castl"], + ["3d_inner"], + ["3d_town"], + ["3d_town_"], + ["airship1"], + ["airship2"], + ["airship4"], + ["air_ship"], + ["army"], + ["bof22_b"], + ["bof22"], + ["bof2"], + ["bof"], + ["boxing_r"], + ["casino"], + ["castle_2_"], + ["castle"], + ["chipset10"], + ["chipset11b"], + ["chipset11"], + ["chipset12"], + ["chipset13b"], + ["chipset13"], + ["chipset14"], + ["chipset15"], + ["chipset17"], + ["chipset18"], + ["chipset19"], + ["chipset1old"], + ["chipset_1"], + ["chipset20"], + ["chipset2old"], + ["chipset_2"], + ["chipset_3"], + ["chipset4"], + ["chipset5b"], + ["chipset5c"], + ["chipset5"], + ["chipset6b"], + ["chipset6c"], + ["chipset6"], + ["chipset7"], + ["chipset8"], + ["chipset9"], + ["chronohouses"], + ["darktown"], + ["darkworld"], + ["dumalchipset01"], + ["dungeon2"], + ["dw3"], + ["earthboundchips_2"], + ["earthboundchips"], + ["ebchip_00"], + ["ebinnerchips"], + ["ff62"], + ["ff6_airship"], + ["ff6_inner"], + ["ff6"], + ["ff6_town"], + ["forest"], + ["forest_t"], + ["future_i"], + ["future_w"], + ["golden1"], + ["grey_cas"], + ["hell"], + ["house2"], + ["house3"], + ["house4"], + ["house5"], + ["house"], + ["japanese_town"], + ["lufia2_b"], + ["lufia2_d"], + ["lufia2_house"], + ["lufia2"], + ["lufia2_t"], + ["map_town01"], + ["minato1"], + ["modern2"], + ["modern3"], + ["modern_b"], + ["modern_c"], + ["modern"], + ["modern_world"], + ["modified"], + ["outcastle"], + ["phantasystar"], + ["pocket_m"], + ["pokemontiles"], + ["ps4"], + ["robotrektown"], + ["rs3"], + ["school_chipset"], + ["sd3_b"], + ["sd3_c"], + ["sd3_d"], + ["sd3"], + ["sompalace2"], + ["sompalace"], + ["som"], + ["space"], + ["suik2heroshouse"], + ["suik2house"], + ["suik2inside"], + ["suik2kyaroinside"], + ["suik2kyaro"], + ["terranig"], + ["topcity"], + ["top-totus"], + ["town_21"], + ["town2"], + ["town3"], + ["town4"], + ["townset_greg"], + ["underground"], + ["village3"], + ["village"], + ["white"], + ["woods"], + ["world2"], + ["world"], + ["xfileschipset1"], + ["zelda3_b"], + ["zelda3_d"], + ["zelda3"], + ["zeldadw"], + ["ダンジョン", "dungeon", "internal1", "donjohn", "던젼", "迷宮"], + ["内装", "interior", "building", "internal2", "내부", "內部"], + ["基本", "world", "main", "basic", "기본"], + ["外観", "exterior", "town", "village", "appearance", "외견", "外部"], + ["船", "ship", "boat", "배"] + ], + "faceset": [ + ["モンスター", "monster", "monsters"], + ["一般1", "people1"], + ["一般2", "people2"], + ["主人公1", "actor1", "chara1"], + ["主人公2", "actor2", "chara2"], + ["face1"], + ["face2"], + ["face3"], + ["face4"], + ["anime3"], + ["atelier_b"], + ["atelier"], + ["bof2"], + ["bulma"], + ["ccfaces1"], + ["ccfaces2"], + ["ccfaces3"], + ["cc"], + ["ch01_all"], + ["chrono_face"], + ["cool"], + ["ct2"], + ["dbz_b"], + ["dbz_c"], + ["dbz_face_1"], + ["dbz_face1"], + ["dbzface1"], + ["dbz_face_2"], + ["dbz_face_3"], + ["dbz"], + ["don_faces1"], + ["don_faces2"], + ["ebfacesx1"], + ["expresion"], + ["expression4"], + ["expression5"], + ["expression6"], + ["expression7_b"], + ["expression7"], + ["expression"], + ["face002"], + ["face01"], + ["face03"], + ["ff4"], + ["ff6_b"], + ["ff7face"], + ["ff7"], + ["ff8_b"], + ["ff8"], + ["ff92"], + ["ff9faceset"], + ["ff9"], + ["fire_emblem_b"], + ["fire_emblem_c"], + ["fire_emblem"], + ["hourai"], + ["image1"], + ["item_b"], + ["item_c"], + ["item"], + ["kof"], + ["langrissa_b"], + ["langrissa"], + ["lodfaces"], + ["lom_b"], + ["lom_c"], + ["lom"], + ["marvel"], + ["medieval2"], + ["medieval"], + ["megaman(face)"], + ["mm2"], + ["monster2"], + ["piccoloface"], + ["pokeface"], + ["pokemon_2"], + ["pokemon2"], + ["ranmaface"], + ["sailorfaces1"], + ["sailor"], + ["samurai_spirit4_b"], + ["samurai_spirit4"], + ["slayers_face_0"], + ["slayers_face_1"], + ["sonicface1"], + ["sonicface2"], + ["sonicface3"], + ["srm95"], + ["staroceanface"], + ["star_wars_b"], + ["star_wars"], + ["suikoden_b"], + ["suikoden_c"], + ["suikoden"], + ["teikyou_b"], + ["teikyou"], + ["top"], + ["xenogears_b"], + ["xenogears_c"], + ["xenogears_d"], + ["xenogears"], + ["xeno"], + ["zelda64"], + ["モンスター", "monster", "monsters", "몬스터", "怪物"], + ["一般1", "people1", "faces1", "people", "general1", "일반1"], + ["一般2", "people2", "faces2", "peopleanimal", "general2", "일반2"], + ["主人公1", "actor1", "hero1", "face1", "protagonist1", "주인공1", "主角1"], + ["主人公2", "actor2", "hero2", "face2", "protagonist2", "주인공2", "主角2"] + ], + "gameover": [ + ["ゲームオーバー", "game over", "gameover"], + ["ゲームオーバー", "game over", "gameover", "게임오버", "遊戲結束"], + ["遊戲結束2"] + ], + "monster": [ + ["ヴァンパイア", "vampire"], + ["オーク", "orc", "hog"], + ["ガーゴイル", "gargoyle"], + ["キマイラ", "chimera"], + ["クラーケン", "kraken", "octopus"], + ["グリフォン", "griffon", "griphon"], + ["ケルベロス", "cerberus", "hellhound"], + ["こうもり", "bat"], + ["ゴースト", "ghost"], + ["ゴーレム", "golem"], + ["コカトリス", "cockatorice", "rooster"], + ["ゴブリン", "goblin", "troll"], + ["コボルト", "kobold", "wolfman"], + ["スケルトン", "skeleton"], + ["スライム", "slime"], + ["ゾンビ", "zombie"], + ["ダークエルフ", "dark elf", "darkelf"], + ["デーモン", "demon", "flyingdemon"], + ["ドラゴン", "dragona", "greendragon1"], + ["ドラゴンナイト", "dragon knight", "dragonknight"], + ["トルーパー", "knight", "darkrider"], + ["トレント", "treant", "oak"], + ["ハーピー", "harpy"], + ["ヒュドラ", "hydra"], + ["ブラックナイト", "black knight", "darkknight"], + ["ホーネット", "hornet", "bee"], + ["マーマン", "merman", "aquamen"], + ["マミー", "mummy"], + ["ミノタウロス", "minotaur"], + ["ミミック", "mimic", "ogrechest"], + ["メデューサ", "medusa"], + ["リザードマン", "lizardman", "lizardmen"], + ["リッチ", "lich", "darkspirit"], + ["ワーウルフ", "werewolf", "wolf"], + ["ワイバーン", "wyvern", "bluedragon"], + ["一般人1", "people1", "boy1"], + ["一般人2", "people2", "girl2"], + ["一般人3", "people3", "boy3"], + ["一般人4", "people4", "girl4"], + ["一般人5", "people5", "man5"], + ["一般人6", "people6", "girl6"], + ["一般人7", "people7", "oldman7"], + ["一般人8", "people8", "granny"], + ["使い魔", "familiar", "imp"], + ["兵士", "soldier", "warrior"], + ["大グモ", "giant spider", "spider"], + ["大サソリ", "giant scorpion", "redscorpion"], + ["大蛇", "snake"], + ["天使", "angel"], + ["女王", "queen", "princess"], + ["忍者", "ninja"], + ["怪魚", "monster fish", "fish"], + ["戦士1", "warrior1", "hero1"], + ["戦士2", "warrior2", "hero2"], + ["武者", "warrior", "samurai"], + ["死神", "reaper", "death"], + ["海賊", "pirate", "gnome"], + ["王", "king"], + ["盗賊", "thief"], + ["鬼火", "wisp", "firescull"], + ["魔王", "demon lord", "cloakdemon"], + ["魔神", "demon god", "satan"], + ["魔術師1", "magician1", "witch1"], + ["魔術師2", "magician2", "witch2"], + ["龍", "dragonb", "greendragon2"], + ["2souls"], + ["bloody_orb"], + ["damn_monkey"], + ["demon_king2"], + ["dlicker"], + ["dortylla"], + ["dragoon1"], + ["fire_fox"], + ["flaret"], + ["gekua"], + ["golem2"], + ["gugu"], + ["igogosha"], + ["kisser"], + ["ladyblue"], + ["leshi"], + ["mad_monkey"], + ["myss"], + ["napper"], + ["old_pirat"], + ["porker"], + ["pupsy"], + ["skeleton_magi"], + ["slime_death"], + ["torturle"], + ["zombie2"], + ["アーリマン", "ahriman", "ariman", "maliciousflower", "아리맨", "阿利曼"], + ["アスラ", "asura", "weaponmaster", "multiarmdemon", "아수라", "阿蘇拉"], + ["アナコンダ", "anaconda", "snake", "아나콘다", "巨蟒"], + ["アリゲーター", "alligator", "crocodile", "엘리게이터", "短吻鱷"], + [ + "アンデッドナイト", + "undead knight", + "u-knight", + "deadpirate", + "pimpskeleton", + "언데드 나이트", + "不死騎士" + ], + [ + "イフリート", + "ifreet", + "efreet", + "efrit", + "ifrit", + "이프리트", + "伊弗利特" + ], + ["インプ", "imp", "features", "littledemon", "임프", "小魔鬼"], + ["ヴァンパイア", "vampire", "뱀파이어", "吸血鬼"], + [ + "ウロボロス", + "ouroboros", + "oroboros", + "levitan", + "dragon", + "우로보로스", + "吞尾蛇" + ], + ["エルフ", "elf", "archer", "엘프", "妖精"], + ["オーガ", "ogre", "primitiveman", "auger", "오거", "食人魔"], + ["オーク", "orc", "pigsoldier", "ork", "오크", "獸人戰士"], + ["オーディン", "odin", "dangeroussoldier", "오딘", "奧丁"], + ["オクトパス", "octopus", "옥토퍼스", "八爪章魚"], + ["ガーゴイル", "gargoyle", "가고일", "石像獸"], + ["カーバンクル", "carbuncle", "rubyfox", "carbunkle", "카방클", "紅寶石"], + [ + "カーミラ", + "carmilla", + "cojurer", + "murdererwoman", + "carmira", + "카미라", + "卡美拉" + ], + ["カッパ", "kappa", "morloc", "raincoat", "카트바", "河童"], + [ + "カトブレパス", + "catoblepas", + "curvebull", + "demonhorsething", + "카토부레바트", + "卡托布里帕斯" + ], + ["ガルーダ", "garuda", "flyingwarrior", "가루다", "揭路荼"], + ["キメラ", "chimera", "키메라", "吐火獸"], + ["グール", "ghoul", "zombie", "구울", "食屍鬼"], + ["クラーケン", "kraken", "seabowl", "seamonster", "크라켄", "克拉坎"], + ["クラブ", "crab", "cancer", "크랩", "巨蟹"], + ["グリフォン", "griffon", "griphon", "griffin", "그리폰", "葛里芬"], + ["グレル", "grell", "robot", "jellyfish", "구렐", "毛面蟲"], + ["クロウラー", "crawler", "worm", "crowera", "쿠로우라", "爬蟲"], + ["ゲイザー", "gazer", "deathgaze", "eye", "eyestalk", "게이져", "看守者"], + [ + "ケツアルクアトル", + "quetzalcoatl", + "quezal", + "flyingworm", + "birdsnake", + "케이시아르크 아토르", + "羽蛇神" + ], + [ + "ケットシー", + "cait sith", + "caitsith", + "eremite", + "ketsea", + "캐트시", + "貓靈" + ], + ["ケルベロス", "cerberus", "cerebus", "켈베로스", "地獄犬"], + ["ケンタウロス", "centaur", "켄타우르스", "半人馬"], + ["ゴーゴン", "gorgon", "infernalbull", "bull", "고곤", "戈耳戈"], + ["ゴースト", "ghost", "witchphantom", "고스트", "鬼魂"], + ["ゴーレム", "golem", "골렘", "高崙"], + [ + "コカトリス", + "cockatorice", + "cockatrice", + "rooster", + "코카트리스", + "雞蛇獸" + ], + ["ゴブリン", "goblin", "littledemon", "imp", "고블린", "小妖精"], + ["コボルト", "kobold", "wolf", "코볼트", "地精"], + ["サイクロプス", "cyclops", "giant", "사이클롭스", "獨眼巨人"], + ["サタナエル", "satan", "darkangel", "사타나엘", "撒旦尼爾"], + ["サハギン", "sahagin", "triton", "merman", "사하킨", "半魚人撒哈奇"], + ["サラマンダー", "salamander", "burninglizard", "사라멘더", "火蜥蜴"], + ["シャーク", "shark", "샤크", "鯊魚"], + ["ジャイアント", "giant", "cannibal", "giant-2", "자이언트", "巨人"], + [ + "ジャック・オー・ランタン", + "jack-o-lantern", + "jack", + "pumpkin", + "jackolantern", + "잭-오-랜턴", + "燈籠怪" + ], + ["シャドウ", "shadow", "lostshadow", "셰도우", "幻影"], + ["シルフ", "sylph", "fairy", "실프", "風之精靈-絲芙"], + ["スキュラ", "scylla", "medusa", "gorgon", "스쿨라", "斯庫拉"], + ["スケルトン", "skeleton", "스켈톤", "骷髏"], + ["スコーピオン", "scorpion", "cancer", "스콜피온", "天蠍"], + ["スネーク", "snake", "dangeroussnake", "스테이크", "蛇"], + ["スピリッツ", "spirit", "laughingspirit", "스피릿츠", "靈魂"], + ["スフィンクス", "sphinx", "sphynx", "스핑크스", "斯芬克司"], + [ + "スペクター", + "spectre", + "specter", + "magicskull", + "lostsoul", + "스펙터", + "幽靈" + ], + ["スライム", "slime", "슬라임", "史萊姆"], + ["セイレーン", "siren", "flyingmermaid", "merangel", "세이렌", "賽倫女妖"], + [ + "セラフィム", + "seraphim", + "seraph", + "sephiroth", + "angel", + "세라핌", + "撒拉弗" + ], + [ + "センチピード", + "centipede", + "multileg", + "catterpillar", + "센티피드", + "百腳蜈蚣" + ], + ["ソーサラー", "sorcerer", "magician", "wizard", "소서러", "巫師"], + ["ゾンビ", "zombie", "poisonzombie", "좀비", "殭屍"], + ["ダークエルフ", "dark elf", "darkelf", "fencer", "다크 엘프", "黑暗妖精"], + [ + "ダークナイト", + "dark knight", + "darkknight", + "knight", + "다크 나이트", + "暗黑騎士" + ], + ["タイタン", "titan", "타이탄", "泰坦巨人"], + ["タランチュラ", "tarantula", "spider", "타란튤라", "狼蛛"], + ["ティアマット", "tiamat", "many-domeddragon", "데어 매트", "塔亞瑪特邪龍"], + ["デーモン", "demon", "horngoat", "daemon", "데몬", "惡魔"], + ["トード", "toad", "frog", "토드", "蟾蜍"], + ["ドラゴン", "dragon", "bluedragon", "dragon-2", "드래곤", "龍"], + [ + "ドラゴンナイト", + "dragon knight", + "dragonknight", + "dragonwarrior", + "드래곤 나이트", + "龍騎士" + ], + ["トレント", "treant", "dendrid", "tree", "트렌트", "樹精"], + ["トロール", "troll", "orc", "트롤", "山怪"], + ["ネクロマンサー", "necromancer", "necromant", "네크로맨서", "死靈法師"], + [ + "ネペンテス", + "nepenthes", + "nepenthe", + "multirootflower", + "killerplant", + "네벤테스", + "豬籠草怪" + ], + ["バーサーカー", "berserker", "barbarian", "버저커", "狂暴戰士"], + ["ハーピー", "harpy", "하피", "鳥人哈比"], + ["バジリスク", "basilisk", "horned", "earwig", "바실리크스", "蛇怪"], + ["バット", "bat", "배트", "蝙蝠"], + ["バハムート", "bahamut", "dragon", "lizarddemon", "바하무트", "巴哈姆特"], + ["パラサイト", "parasite", "all-knowing", "페러사이트", "寄生蟲"], + ["ビッグフット", "big foot", "bigfoot", "yeti", "빅풋", "大腳怪"], + ["ヒュドラ", "hydra", "many-domeduglycreature", "히드라", "九頭蛇"], + ["フェニックス", "phoenix", "피닉스", "鳳凰鳥"], + ["フェンリル", "fenrir", "realwolf", "wolf", "펜릴", "大天狼"], + ["ベヒーモス", "behemoth", "infernaldog", "배히모스", "巨獸"], + ["ホーネット", "hornet", "bee", "bea", "호넷", "大黃蜂"], + [ + "マンティコア", + "manticore", + "crossedbeast", + "monster", + "만티코어", + "蒙特寇爾" + ], + ["マンティス", "mantis", "insect", "preyingmantis", "만티스", "螳螂怪"], + ["マンモス", "mammoth", "mamont", "맘모스", "長毛象"], + ["ミイラ男", "mummy", "미이라 男", "木乃伊"], + [ + "ミドガルズオルム", + "midgardsormr", + "midgard-serp", + "creepingcreature", + "monster-2", + "미드갈즈 오르무", + "米嘉巨蟒" + ], + ["ミノタウロス", "minotaur", "미노타우르스", "邁諾陶"], + ["ミミック", "mimic", "ogrechest", "미믹", "變形怪"], + ["メデューサ", "medusa", "hypermedusa", "메두사", "梅杜莎"], + ["ユニコーン", "unicorn", "유니콘", "獨角獸"], + [ + "ラクシュミ", + "lakshmi", + "rakashimi", + "prophet", + "mystic", + "라쿠슈미", + "吉祥天" + ], + ["ラミア", "lamia", "mermaid", "gorgon-2", "라미아", "拉米亞"], + ["リヴァイアサン", "leviathan", "levitanwithahorn", "리바이어던", "大海獸"], + ["リザード", "lizard", "monitorlizard", "리자드", "蜥蜴"], + ["リザードマン", "lizardman", "lizardsoldier", "리자드맨", "蜥蜴人"], + ["リッチ", "lich", "powerfulnecromant", "리치", "妖巫"], + ["リリス", "lilith", "lillith", "multihandswarrior", "리리스", "莉莉斯"], + ["レイス", "wraith", "phantom", "spectre", "레이스", "死靈"], + ["レモラ", "remora", "sharpfish", "flyingfish", "레모라", "怪魚雷莫拉"], + ["ワーウルフ", "werewolf", "werwolf", "워울프", "狼人"], + ["ワーム", "worm", "toothworm", "웜", "蟲"], + ["ワイト", "wight", "seizedsoldier", "yto", "와이트", "屍妖"], + ["ワイバーン", "wyvern", "browndragon", "yburning", "와이번", "飛龍"], + [ + "九尾の狐", + "kyuubi", + "ninetail", + "magicfox", + "foxofninetails", + "구미호", + "九尾狐狸" + ], + ["朱雀", "suzaku", "redsparrow", "vainbird", "주작"], + ["玄武", "genbu", "blackturtle", "turtlesnake", "monster-3", "현무"], + ["白虎", "byakko", "whitetiger", "polartiger", "백호"], + ["馬", "horse", "nightmare", "darkhorse", "말"], + ["鬼", "oni", "ogre", "도깨비(鬼)", "惡鬼"], + ["麒麟", "kirin", "ki-rin", "fairytalehorse", "giraffe", "기린"], + ["龍", "ryuu", "bluedragon", "longdragon", "청룡", "青龍"] + ], + "movie": [["babbit"]], + "music": [ + ["jアイテム", "items", "item"], + ["jギャグ1", "gag1"], + ["jギャグ2", "gag2"], + ["jファンファーレ1", "fanfare1"], + ["jファンファーレ2", "fanfare2"], + ["jファンファーレ3", "fanfare3"], + ["jファンファーレ4", "fanfare4"], + ["jファンファーレ5", "fanfare5"], + ["jファンファーレ6", "fanfare6"], + ["j宿1", "inn1"], + ["j宿2", "inn2"], + ["j戦闘終了1", "battleend1"], + ["j戦闘終了2", "battleend2"], + ["j戦闘終了3", "battleend3"], + ["j戦闘終了4", "battleend4"], + ["j疑惑", "suspicion", "doubt"], + ["j謎", "mystery", "riddle"], + ["se地震", "earthquake", "seearthquake"], + ["se大雨", "rain2", "serain2"], + ["se時計", "clock", "seclock"], + ["se海", "sea", "sesea"], + ["se雨", "rain1", "serain"], + ["エンディング1", "ending1"], + ["エンディング2", "ending2"], + ["エンディング3", "ending3"], + ["オープニング1", "opening1"], + ["オープニング2", "opening2"], + ["オープニング3", "opening3"], + ["ゲームオーバー1", "gameover1"], + ["ゲームオーバー2", "gameover2"], + ["ゲームオーバー3", "gameover3"], + ["ゴーストタウン1", "ghosttown1", "gosttown1"], + ["ゴーストタウン2", "ghosttown2", "gosttown2"], + ["ダンジョン1", "dungeon1"], + ["ダンジョン2", "dungeon2"], + ["ダンジョン3", "dungeon3"], + ["ダンジョン4", "dungeon4"], + ["ダンジョン5", "dungeon5"], + ["ピンチ", "crisis"], + ["フィールド1", "field1"], + ["フィールド2", "field2"], + ["フィールド3", "field3"], + ["フィールド4", "field4"], + ["ボス1", "boss1"], + ["ボス2", "boss2"], + ["ボス3", "boss3"], + ["ボス4", "boss4"], + ["乗り物1", "vehicle1", "ride1"], + ["乗り物2", "vehicle2", "ride2"], + ["乗り物3", "vehicle3", "ride3"], + ["別れ1", "farewell1"], + ["別れ2", "farewell2"], + ["勇者1", "hero1"], + ["勇者2", "hero2"], + ["動物", "animal"], + ["勝利", "victory", "get"], + ["城1", "castle1"], + ["城2", "castle2"], + ["城3", "castle3"], + ["塔1", "tower1"], + ["塔2", "tower2"], + ["塔3", "tower3"], + ["妖精1", "fairy1"], + ["妖精2", "fairy2"], + ["安らぎ1", "peace1"], + ["安らぎ2", "peace2"], + ["安らぎ3", "peace3"], + ["店1", "store1", "shop1"], + ["店2", "store2", "shop2"], + ["店3", "store3", "shop3"], + ["怒り", "anger"], + ["悲しみ", "sorrow", "sad"], + ["戦闘1", "battle1"], + ["戦闘2", "battle2"], + ["戦闘3", "battle3"], + ["探索", "search"], + ["敗北", "defeat", "lose"], + ["教会", "church"], + ["明るい市場", "marketplace", "fiesta"], + ["村1", "village1"], + ["村2", "village2"], + ["村3", "village3"], + ["泥棒", "thief"], + ["活気", "energy"], + ["町1", "town1"], + ["町2", "town2"], + ["町3", "town3"], + ["神秘1", "mystery1"], + ["神秘2", "mystery2"], + ["神秘3", "mystery3"], + ["秘宝", "treasure"], + ["船1", "ship1"], + ["船2", "ship2"], + ["船3", "ship3"], + ["試練", "trial"], + ["闇市", "blackmarket", "dark"], + ["魔王", "devil"], + ["dungeon6"], + ["dungeon7"], + ["farewell3"], + ["farewell4"], + ["inn3"], + ["mystery4"], + ["night"], + ["pirate1"], + ["pirate2"], + ["pirate3"], + ["pirate4"], + ["ride4"], + ["shop4"], + ["snow"], + ["casino"], + ["castle4"], + ["castle5"], + ["done"], + ["fairy3"], + ["friends1"], + ["meanwhile1"], + ["meanwhile2"], + ["morning1"], + ["shop5"], + ["sorrow"], + ["spirit"], + ["town4"], + ["town5"], + ["village4"], + ["village5"], + [ + "2003いやしの泉", + "2003healing spring", + "spring", + "2003happiness", + "2003spring", + "2003회복의 샘", + "2003治癒之泉" + ], + [ + "2003カジノ三昧", + "2003casino indulgence", + "casino", + "2003research", + "2003casino", + "2003카지노", + "2003賭場" + ], + [ + "2003コロシアム", + "2003colosseum", + "arena", + "2003piracy", + "2003colloseum", + "2003콜로세움", + "2003競技場" + ], + [ + "2003サイバーシティ", + "2003cyber city", + "bustling-city", + "2003joyfulnews", + "2003rhinobarcity", + "2003사이버 시티", + "2003異次元城市" + ], + [ + "2003スノータウン", + "2003snow town", + "snowtown", + "2003lullaby", + "2003snowtown", + "2003스노우 타운", + "2003下雪的城鎮" + ], + [ + "2003パニック", + "2003panic", + "panic", + "2003warning", + "2003패닉", + "2003恐慌" + ], + [ + "2003マキシマム・バトル", + "2003maximum battle", + "fierce-battle", + "2003pursuit", + "2003maximumsbattle", + "2003맥시멈·전투", + "2003極限戰鬥" + ], + [ + "2003一獲千金の夢", + "2003dream of striking it rich", + "jackpot", + "2003silentpleasure", + "2003dream-catchlotsofmoney", + "2003일획천금의 꿈", + "2003一獲千金之夢" + ], + [ + "2003冒険者たち", + "2003adventurers", + "adventure", + "2003farewell", + "2003venturepeople", + "2003여행자 서고", + "2003冒險者們" + ], + [ + "2003勇者の凱旋", + "2003hero's return", + "triumph", + "2003bigpalace", + "2003triumphalreturn", + "2003용사의 개선", + "2003勇者的凱旋" + ], + [ + "2003古城", + "2003ancient city", + "ancient-castle", + "2003glory", + "2003oldcastle", + "2003고성" + ], + [ + "2003地下迷宮", + "2003subterranean maze", + "labyrinth", + "2003fast", + "2003undergroundlabyrinth", + "2003지하미궁" + ], + [ + "2003夢幻の森", + "2003dream forest", + "forest", + "2003worldsound", + "2003forestoffantasy", + "2003몽환의 숲", + "2003夢幻之森" + ], + [ + "2003夢見心地", + "2003dreaminess", + "moonlight", + "2003serenity", + "2003mooniness", + "2003꿈을 꾸는 기분", + "2003夢幻心境" + ], + [ + "2003大混戦", + "2003free for all", + "armyclash", + "2003gangster", + "2003largeconfoundcombat", + "2003대혼전", + "2003大混戰" + ], + [ + "2003大空への翼", + "2003wings to the sky", + "airborne", + "2003fanfares", + "2003wingtoskies", + "2003대공의 날개", + "2003空中的羽翼" + ], + [ + "2003大聖堂", + "2003cathedral", + "hallowed-halls", + "2003grief", + "2003largesaintlyhall", + "2003대성당" + ], + [ + "2003妖精の森", + "2003fairy forest", + "fairies", + "2003workingmusic", + "2003fairyforest", + "2003요정의 숲", + "2003妖精之森" + ], + [ + "2003孤独な旅立ち", + "2003lonesome journey", + "lonewolf", + "2003memoirs", + "2003lonelyjourney", + "2003고독한 여행", + "2003孤獨的旅行" + ], + [ + "2003小さな兵隊のマーチ", + "2003little army's march", + "march", + "2003parade", + "2003marchofsmallsoldiers", + "2003작은 군대의 행진곡", + "2003小軍隊進行曲" + ], + [ + "2003山あいの村", + "2003village in the valley", + "mtn-village", + "2003eaststyle", + "2003villageofravines", + "2003산골짜기 마을", + "2003山中部落" + ], + [ + "2003帝国~エンパイア", + "2003empire", + "empire", + "2003andagainpleasure", + "2003enpire", + "2003제국", + "2003帝國" + ], + [ + "2003幼少の記憶", + "2003young memories", + "memories", + "2003fairytale", + "2003memoryofinfancy", + "2003유소의 기억", + "2003年少記憶" + ], + [ + "2003忍び寄る闇", + "2003creeping darkness", + "foreboding", + "2003threat", + "2003approachingdarkness", + "2003살며시 다가옴", + "2003悄然黑夜" + ], + [ + "2003悠久の時の流れに", + "2003in the eternal flow of time", + "eternal", + "2003calm", + "2003theeternalflow", + "2003유구의 흐름", + "2003悠久時光的流轉" + ], + [ + "2003悲しみ", + "2003sorrow", + "sadness", + "2003calm2", + "2003슬퍼하고", + "2003悲傷" + ], + [ + "2003戦いの幕開け", + "2003beginning of a war", + "intro", + "2003dangerousfog", + "2003fightopening", + "2003싸움의 개막", + "2003戰爭的序幕" + ], + ["2003教会", "2003church", "church1", "2003교회", "2003教會"], + [ + "2003日だまりの村", + "2003sunnyvillage", + "village4", + "2003farmer", + "2003villageball", + "2003양지의 마을", + "2003日光村落" + ], + [ + "2003暗黒の祭壇", + "2003darkaltar", + "evil-temple", + "2003globalfrustration", + "2003altarofdarkness", + "2003암흑의 제단", + "2003暗黑祭壇" + ], + [ + "2003未開の集落", + "2003village of savages", + "strangetown", + "2003witchsong", + "2003unexploredvillage", + "2003미개한 취락", + "2003未開化部落" + ], + [ + "2003極東の地", + "2003far eastern land", + "far-east", + "2003flight", + "2003fareasternarea", + "2003극동의 땅", + "2003極東之地" + ], + [ + "2003機械要塞", + "2003machine fortress", + "mecha-base", + "2003sadend", + "2003machinefortress", + "2003기계 요새" + ], + [ + "2003氷のラビリンス", + "2003ice labyrinth", + "icecave", + "2003drops", + "2003icelabyrinth", + "2003얼음의 래비런스", + "2003冰之迷宮" + ], + [ + "2003決戦の地", + "2003final battleground", + "showdown", + "2003battlefield", + "2003areaofdecisivebattles", + "2003결전의 땅", + "2003決戰之地" + ], + [ + "2003無人の館", + "2003deserted mansion", + "haunted", + "2003silentcrying", + "2003unmannedmansion", + "2003무인 저택", + "2003無人別館" + ], + [ + "2003王宮のパーティー", + "2003palace party", + "royal-ball", + "2003dance", + "2003courtparty", + "2003왕궁의 파티", + "2003皇宮盛會" + ], + [ + "2003異次元回廊", + "2003otherworldly corridor", + "dimension", + "2003fastbattle", + "2003strangedimensionalcorridor", + "2003차원의 길", + "2003異次元迴廊" + ], + [ + "2003祝福ワルツ", + "2003waltz of blessings", + "waltz", + "2003waltz", + "2003blessingwaltz", + "2003축복 왈츠", + "2003祝福的華爾滋" + ], + [ + "2003穏やかな風", + "2003steady breeze", + "interlude", + "2003summer", + "2003calmwind", + "2003온화한 바람", + "2003徐徐微風" + ], + ["2003緊迫", "2003tension", "tension", "2003terribledream", "2003긴박"], + [ + "2003繰り返される戦い", + "2003repeated wars", + "endless-fight", + "2003fastbattle2", + "2003thefightwhichisrepeated", + "2003반복된 싸움", + "2003反覆的戰鬥" + ], + [ + "2003街の賑わい", + "2003city bustle", + "town-square", + "2003pleasure", + "2003towncrowd", + "2003거리의 북적임", + "2003熱鬧街道" + ], + [ + "2003街の酒場", + "2003tavern", + "bar", + "2003travel", + "2003townbar", + "2003거리의 술집", + "2003街上的酒館" + ], + [ + "2003記憶の彼方に", + "2003deep memory", + "memories2", + "2003morning", + "2003onthefarsideofmemories", + "2003기억의 저쪽에", + "2003記憶中的彼方" + ], + [ + "2003遺跡探索", + "2003exploring ruins", + "ruins", + "2003infiniteidea", + "2003ruinssearch", + "2003유적 탐색" + ], + [ + "2003邪神との戦い", + "2003battle with an evil god", + "godslayer", + "2003chaos", + "2003wickedgodfight", + "2003사신과의 싸움", + "2003與邪神一戰" + ], + [ + "2003静寂", + "2003silence", + "calm", + "2003underwater", + "2003calmness", + "2003정적", + "2003靜寂" + ], + ["j2003ホルン", "j2003horn", "horns", "2003win", "j2003호른", "j2003號角"], + ["jアイテム", "jitem", "item", "j아이템", "j物品"], + ["jギャグ1", "jjoke 1", "mischief1", "jgag1", "j개그1", "j鬧劇1"], + ["jギャグ2", "jjoke 2", "mischief2", "jgag2", "j개그2", "j鬧劇2"], + [ + "jファンファーレ1", + "jfanfare 1", + "fanfare1", + "jfanfare1", + "j팡파르1", + "j吹奏樂1" + ], + [ + "jファンファーレ2", + "jfanfare 2", + "fanfare2", + "jfanfare2", + "j팡파르2", + "j吹奏樂2" + ], + [ + "jファンファーレ3", + "jfanfare 3", + "fanfare3", + "jfanfare3", + "j팡파르3", + "j吹奏樂3" + ], + [ + "jファンファーレ4", + "jfanfare 4", + "fanfare4", + "jfanfare4", + "j팡파르4", + "j吹奏樂4" + ], + [ + "jファンファーレ5", + "jfanfare 5", + "fanfare5", + "jfanfare5", + "j팡파르5", + "j吹奏樂5" + ], + [ + "jファンファーレ6", + "jfanfare 6", + "fanfare6", + "jfanfare6", + "j팡파르6", + "j吹奏樂6" + ], + ["j宿1", "jinn 1", "inn1", "jinn1", "j숙소1", "j住宿1"], + ["j宿2", "jinn 2", "inn2", "jinn2", "j숙소2", "j住宿2"], + [ + "j戦闘終了1", + "jend of battle 1", + "victory1", + "jaggressiveend1", + "j전투종료1", + "j戰鬥結束1" + ], + [ + "j戦闘終了2", + "jend of battle 2", + "victory2", + "jaggressiveend2", + "j전투종료2", + "j戰鬥結束2" + ], + [ + "j戦闘終了3", + "jend of battle 3", + "victory3", + "jaggressiveend3", + "j전투종료3", + "j戰鬥結束3" + ], + [ + "j戦闘終了4", + "jend of battle 4", + "victory4", + "jaggressiveend4", + "j전투종료4", + "j戰鬥結束4" + ], + ["j疑惑", "jdoubt", "surprise", "j의혹"], + ["j謎", "jmystery", "riddle", "jpuzzle", "j수수께기"], + [ + "se2003アラーム", + "se2003alarm", + "se-alarm", + "se2003ding", + "se2003알람", + "se2003警報" + ], + [ + "se2003ジャングル", + "se2003jungle", + "se-jungle", + "se2003정글", + "se2003叢林" + ], + [ + "se2003雑踏", + "se2003bustle", + "se-crowd", + "se2003people", + "se2003혼잡", + "se2003雜沓" + ], + ["se2003風", "se2003wind", "se-gale", "se2003winter", "se2003바람"], + ["se2003鳥", "se2003bird", "se-bird", "se2003birds", "se2003새"], + ["se地震", "seearthquake", "se-quake", "se지진"], + ["se大雨", "sedownpour", "se-torrent", "serain2", "seheavyrain", "se호우"], + ["se時計", "seclock", "se-clock", "se시계", "se鐘"], + ["se海", "sesea", "se-ocean", "se바다"], + ["se雨", "serain", "se-rain", "serainy", "se비"], + ["エンディング1", "ending 1", "ending1", "끝1", "結束1"], + ["エンディング2", "ending 2", "ending2", "끝2", "結束2"], + ["エンディング3", "ending 3", "ending3", "끝3", "結束3"], + ["オープニング1", "opening 1", "opening1", "오프닝1", "開始1"], + ["オープニング2", "opening 2", "opening2", "오프닝2", "開始2"], + ["オープニング3", "opening 3", "opening3", "오프닝3", "開始3"], + ["ゲームオーバー1", "game over 1", "gameover1", "게임오버1", "遊戲結束1"], + ["ゲームオーバー2", "game over 2", "gameover2", "게임오버2", "遊戲結束2"], + ["ゲームオーバー3", "game over 3", "gameover3", "게임오버3", "遊戲結束3"], + ["ゴーストタウン1", "ghost town 1", "ghost-town1", "고스트 타운1", "鬼城1"], + ["ゴーストタウン2", "ghost town 2", "ghost-town2", "고스트 타운2", "鬼城2"], + ["ダンジョン1", "dungeon 1", "dungeon1", "지하감옥1", "迷宮1"], + ["ダンジョン2", "dungeon 2", "dungeon2", "지하감옥2", "迷宮2"], + ["ダンジョン3", "dungeon 3", "dungeon3", "지하감옥3", "迷宮3"], + ["ダンジョン4", "dungeon 4", "dungeon4", "지하감옥4", "迷宮4"], + ["ダンジョン5", "dungeon 5", "dungeon5", "지하감옥5", "迷宮5"], + ["ピンチ", "in a pinch", "tightspot", "핀치", "危機"], + ["フィールド1", "field 1", "field1", "필드1", "原野1"], + ["フィールド2", "field 2", "field2", "필드2", "原野2"], + ["フィールド3", "field 3", "field3", "필드3", "原野3"], + ["フィールド4", "field 4", "field4", "필드4", "原野4"], + ["ボス1", "boss 1", "boss1", "보스1"], + ["ボス2", "boss 2", "boss2", "보스2"], + ["ボス3", "boss 3", "boss3", "보스3"], + ["ボス4", "boss 4", "boss4", "보스4"], + ["乗り物1", "vehicle 1", "vehicle1", "차량1", "交通工具1"], + ["乗り物2", "vehicle 2", "vehicle2", "차량2", "交通工具2"], + ["乗り物3", "vehicle 3", "vehicle3", "차량3", "交通工具3"], + ["別れ1", "parting 1", "parting1", "이별1", "別離1"], + ["別れ2", "parting 2", "parting2", "이별2", "別離2"], + ["勇者1", "hero 1", "hero1", "용사1"], + ["勇者2", "hero 2", "hero2", "용사2"], + ["動物", "animal", "동물"], + ["勝利", "victory", "success", "승리"], + ["城1", "castle 1", "castle1", "성1"], + ["城2", "castle 2", "castle2", "성2"], + ["城3", "castle 3", "castle3", "성3"], + ["塔1", "tower 1", "tower1", "탑1"], + ["塔2", "tower 2", "tower2", "탑2"], + ["塔3", "tower 3", "tower3", "탑3"], + ["妖精1", "fairy 1", "fairy1", "요정1"], + ["妖精2", "fairy 2", "fairy2", "요정2"], + ["安らぎ1", "repose 1", "solace1", "편안해짐1", "安樂1"], + ["安らぎ2", "repose 2", "solace2", "편안해짐2", "安樂2"], + ["安らぎ3", "repose 3", "solace3", "편안해짐3", "安樂3"], + ["店1", "shop 1", "shop1", "점1"], + ["店2", "shop 2", "shop2", "점2"], + ["店3", "shop 3", "shop3", "점3"], + ["怒り", "wrath", "malice", "화내다", "怒"], + ["悲しみ", "sorrow", "sad", "슬퍼하다", "悲傷"], + ["戦闘1", "battle 1", "battle1", "전투1", "戰鬥1"], + ["戦闘2", "battle 2", "battle2", "전투2", "戰鬥2"], + ["戦闘3", "battle 3", "battle3", "전투3", "戰鬥3"], + ["探索", "exploration", "explore", "탐색"], + ["敗北", "defeat", "패배"], + ["教会", "church", "church2", "교회", "教會"], + ["明るい市場", "lively market", "bazaar", "밝은 시장", "明亮的市集"], + ["村1", "village 1", "village1", "촌1"], + ["村2", "village 2", "village2", "촌2"], + ["村3", "village 3", "village3", "촌3"], + ["泥棒", "thief", "도둑"], + ["活気", "liveliness", "strength", "활기", "朝氣"], + ["町1", "town 1", "town1", "밭두둑1", "街1"], + ["町2", "town 2", "town2", "밭두둑2", "街2"], + ["町3", "town 3", "town3", "밭두둑3", "街3"], + ["神秘1", "mystery 1", "mystery1", "신비1"], + ["神秘2", "mystery 2", "mystery2", "신비2"], + ["神秘3", "mystery 3", "mystery3", "신비3"], + ["秘宝", "secret treasure", "treasure", "바보", "神秘寶藏"], + ["船1", "ship 1", "boat1", "배1"], + ["船2", "ship 2", "boat2", "배2"], + ["船3", "ship 3", "boat3", "배3"], + ["試練", "trial", "ordeal", "시련", "試煉"], + ["闇市", "black market", "eviltown", "암시장", "黑市"], + ["魔王", "demon lord", "demonic", "마왕"] + ], + "panorama": [ + ["夕焼け1", "sunset1", "dawn1"], + ["夕焼け2", "sunset2", "dawn2"], + ["夜明け1", "dawn1", "evening1"], + ["夜明け2", "dawn2", "evening2"], + ["夜空1", "night sky1", "night1"], + ["夜空2", "night sky2", "night2"], + ["宇宙", "cosmos1", "galaxy"], + ["惑星1", "planet1"], + ["惑星2", "planet2"], + ["惑星3", "planet3"], + ["異空間", "dimension rift", "weird"], + ["空1", "sky1", "morning1"], + ["空2", "sky2", "morning2"], + ["beach1"], + ["夕焼け1", "sunset1", "dusk1", "dawn1", "eveningglow1", "석양1", "夕陽1"], + ["夕焼け2", "sunset2", "dusk2", "dawn2", "eveningglow2", "석양2", "夕陽2"], + ["夜明け1", "dawn1", "evening1", "새벽1", "黎明1"], + ["夜明け2", "dawn2", "evening2", "새벽2", "黎明2"], + ["夜空1", "night sky1", "night1", "nightempty1", "밤하늘1"], + ["夜空2", "night sky2", "night2", "nightempty2", "밤하늘2"], + ["宇宙", "cosmos1", "space", "galaxy", "우주"], + ["惑星1", "planet1", "planetary1", "혹성1"], + ["惑星2", "planet2", "planetary2", "혹성2"], + ["惑星3", "planet3", "planetary3", "혹성3"], + [ + "異空間", + "dimension rift", + "strange", + "weird", + "strangespacial", + "이공간", + "異次元空間" + ], + ["空1", "sky1", "morning1", "empty1", "하늘1", "天空1"], + ["空2", "sky2", "morning2", "empty2", "하늘2", "天空2"] + ], + "picture": [["cloud"]], + "sound": [ + ["アイテム1", "item1"], + ["アイテム2", "item2"], + ["あたり1", "chime1", "success1"], + ["あたり2", "chime2", "success2"], + ["アップ", "buff", "up"], + ["イヌ", "dog"], + ["ウシ", "cow"], + ["ウマ", "horse"], + ["おたけび", "roar"], + ["カーソル1", "cursor1"], + ["カーソル2", "cursor2"], + ["ガシャン", "glassshatter", "glass"], + ["キャンセル1", "cancel1", "cansel1"], + ["キャンセル2", "cancel2", "cansel2"], + ["しびれ1", "paralyze1", "numbness1"], + ["しびれ2", "paralyze2", "numbness2"], + ["しびれ3", "paralyze3", "numbness3"], + ["ジャンプ1", "jump1"], + ["ジャンプ2", "jump2"], + ["ショット1", "shot1"], + ["ショット2", "shot2"], + ["ショット3", "shot3"], + ["スイッチ1", "switch1"], + ["スイッチ2", "switch2"], + ["ダウン", "debuff", "down"], + ["ダメージ1", "damage1"], + ["ダメージ2", "damage2"], + ["テレポート1", "teleport1"], + ["テレポート2", "teleport2"], + ["トラ", "tiger"], + ["にらみ", "glare", "power"], + ["にわとり", "chicken"], + ["ネコ", "cat"], + ["ノック", "knock"], + ["はずれ1", "buzzer1", "failure1"], + ["はずれ2", "buzzer2", "failure2"], + ["バリア", "barrier"], + ["ひつじ", "sheep"], + ["ブザー1", "buzzer3", "buzzer1"], + ["ブザー2", "buzzer4", "buzzer2"], + ["フラッシュ1", "flash1"], + ["フラッシュ2", "flash2"], + ["フラッシュ3", "flash3"], + ["ブレス", "breath", "breast"], + ["モンスター1", "monster1"], + ["モンスター2", "monster2"], + ["ライオン", "lion"], + ["冷気1", "ice1", "cold1"], + ["冷気2", "ice2", "cold2"], + ["冷気3", "ice3", "cold3"], + ["冷気4", "ice4", "cold4"], + ["冷気5", "ice5", "cold5"], + ["冷気6", "ice6", "cold6"], + ["冷気7", "ice7", "cold7"], + ["冷気8", "ice8", "cold8"], + ["冷気9", "ice9", "cold9"], + ["冷気10", "ice10", "cold10"], + ["冷気11", "ice11", "cold11"], + ["剣1", "sword1"], + ["剣2", "sword2"], + ["剣3", "sword3"], + ["吸収1", "absorb1", "absorption1"], + ["吸収2", "absorb2", "absorption2"], + ["噛む", "bite"], + ["回復1", "recovery1"], + ["回復2", "recovery2"], + ["回復3", "recovery3"], + ["回復4", "recovery4"], + ["回復5", "recovery5"], + ["回復6", "recovery6"], + ["回復7", "recovery7"], + ["回復8", "recovery8"], + ["回避1", "evade1", "evasion1"], + ["回避2", "evade2", "evasion2"], + ["地震1", "earthquake1"], + ["地震2", "earthquake2"], + ["壁1", "barrier1"], + ["壁2", "barrier2"], + ["大地1", "earth1"], + ["大地2", "earth2"], + ["大地3", "earth3"], + ["大地4", "earth4"], + ["大地5", "earth5"], + ["大地6", "earth6"], + ["大地7", "earth7"], + ["大地8", "earth8"], + ["大地9", "earth9"], + ["大地10", "earth10"], + ["巻き付き", "ensnare", "refer book"], + ["弓1", "bow1"], + ["弓2", "bow2"], + ["戦闘1", "combat1", "fight1"], + ["戦闘2", "combat2", "fight2"], + ["打撃1", "blow1"], + ["打撃2", "blow2"], + ["打撃3", "blow3"], + ["打撃4", "blow4"], + ["打撃5", "blow5"], + ["打撃6", "blow6"], + ["打撃7", "blow7"], + ["攻撃1", "attack1"], + ["攻撃2", "attack2"], + ["斬る1", "slash1", "kill1"], + ["斬る2", "slash2", "kill2"], + ["斬る3", "slash3", "kill3"], + ["斬る4", "slash4", "kill4"], + ["斬る5", "slash5", "kill5"], + ["斬る6", "slash6"], + ["斬る7", "slash7", "kill7"], + ["斬る8", "slash8", "kill8"], + ["斬る9", "slash9", "kill9"], + ["斬る10", "slash10", "kill10"], + ["斬る11", "slash11", "kill11"], + ["時計", "clock"], + ["暗闇", "blind", "darkness"], + ["暗黒1", "darkness1", "dark1"], + ["暗黒2", "darkness2", "dark2"], + ["暗黒3", "darkness3", "dark3"], + ["暗黒4", "darkness4", "dark4"], + ["暗黒5", "darkness5", "dark5"], + ["暗黒6", "darkness6", "kill6"], + ["歌", "song"], + ["毒", "poison"], + ["水1", "water1"], + ["水2", "water2"], + ["水3", "water3"], + ["水4", "water4"], + ["水5", "water5"], + ["水6", "water6"], + ["決定1", "decision1"], + ["決定2", "decision2"], + ["沈黙", "silence"], + ["海1", "sea1"], + ["海2", "sea2"], + ["消滅1", "collapse1", "annihilation1"], + ["消滅2", "collapse2", "annihilation2"], + ["混乱", "confusion", "chaos"], + ["炎1", "fire1", "flame1"], + ["炎2", "fire2", "flame2"], + ["炎3", "fire3", "flame3"], + ["炎4", "fire4", "flame4"], + ["炎5", "fire5", "flame5"], + ["炎6", "fire6", "flame6"], + ["炎7", "fire7", "flame7"], + ["炎8", "fire8", "flame8"], + ["爆発1", "explosion1"], + ["爆発2", "explosion2"], + ["爆発3", "explosion3"], + ["爆発4", "explosion4"], + ["爆発5", "explosion5"], + ["爆発6", "explosion6"], + ["爆発7", "explosion7"], + ["睡眠", "sleep"], + ["砂けむり", "sandstorm", "sand storm"], + ["神聖1", "holy1"], + ["神聖2", "holy2"], + ["神聖3", "holy3"], + ["神聖4", "holy4"], + ["神聖5", "holy5"], + ["神聖6", "holy6"], + ["神聖7", "holy7"], + ["神聖8", "holy8"], + ["神聖9", "holy9"], + ["移動", "move", "movement"], + ["花粉", "pollen"], + ["落ちる1", "fall1"], + ["落ちる2", "fall2"], + ["蘇生1", "raise1", "rebirth1"], + ["蘇生2", "raise2", "rebirth2"], + ["蘇生3", "raise3", "rebirth3"], + ["逃走", "escape"], + ["鍵", "key"], + ["鐘", "bell"], + ["閉める1", "close1"], + ["閉める2", "close2"], + ["開ける1", "open1"], + ["開ける2", "open2"], + ["雨1", "rain1"], + ["雨2", "rain2"], + ["雷1", "thunder1"], + ["雷2", "thunder2"], + ["雷3", "thunder3"], + ["雷4", "thunder4"], + ["雷5", "thunder5"], + ["雷6", "thunder6"], + ["雷7", "thunder7"], + ["雷8", "thunder8"], + ["雷9", "thunder9"], + ["雷10", "thunder10"], + ["霧1", "fog1"], + ["霧2", "fog2"], + ["音波1", "wave1", "sonic1"], + ["音波2", "wave2", "sonic2"], + ["風1", "wind1"], + ["風2", "wind2"], + ["風3", "wind3"], + ["風4", "wind4"], + ["風5", "wind5"], + ["風6", "wind6"], + ["風7", "wind7"], + ["風8", "wind8"], + ["風9", "wind9"], + ["風10", "wind10"], + ["風11", "wind11"], + ["魔法1", "magic1", "wall1"], + ["魔法2", "magic2", "wall2"], + ["anvil"], + ["camera"], + ["clap"], + ["comp"], + ["doorbell"], + ["drip"], + ["flush"], + ["glassbreak"], + ["gong"], + ["kiss"], + ["phone"], + ["pop"], + ["sneezing"], + ["snoring"], + ["ugh"], + ["whistle"], + ["アイテム1", "item1", "아이템1", "物品1"], + ["アイテム2", "item2", "아이템2", "物品2"], + ["あたり1", "chime1", "jingle1", "around1", "즈음하고1", "碰撞1"], + ["あたり2", "chime2", "jingle2", "around2", "즈음하고2", "碰撞2"], + ["アップ", "buff", "increase", "upgrade", "업", "上昇"], + ["イヌ", "dog", "강아지", "狗"], + ["ウシ", "cow", "소", "牛"], + ["ウマ", "horse", "발판", "馬"], + ["おたけび", "roar", "우렁찬 외침", "吶喊"], + ["カーソル1", "cursor1", "커서1", "游標1"], + ["カーソル2", "cursor2", "커서2", "游標2"], + ["ガシャン", "glassshatter", "shatter", "glass", "가샨", "裂開"], + ["キャンセル1", "cancel1", "취소1", "取消1"], + ["キャンセル2", "cancel2", "취소2", "取消2"], + ["しびれ1", "paralyze1", "마비1", "麻痺1"], + ["しびれ2", "paralyze2", "마비2", "麻痺2"], + ["しびれ3", "paralyze3", "마비3", "麻痺3"], + ["ジャンプ1", "jump1", "점프1", "跳躍1"], + ["ジャンプ2", "jump2", "점프2", "跳躍2"], + ["ショット1", "shot1", "laser1", "쇼트1", "發射1"], + ["ショット2", "shot2", "laser2", "쇼트2", "發射2"], + ["ショット3", "shot3", "laser3", "쇼트3", "發射3"], + ["スイッチ1", "switch1", "스위치1", "開關1"], + ["スイッチ2", "switch2", "스위치2", "開關2"], + ["ダウン", "debuff", "decrease", "downgrade", "다운", "下降"], + ["ダメージ1", "damage1", "damege1", "데미지1", "傷害1"], + ["ダメージ2", "damage2", "damege2", "데미지2", "傷害2"], + ["テレポート1", "teleport1", "텔레포트1", "傳送1"], + ["テレポート2", "teleport2", "텔레포트2", "傳送2"], + ["トラ", "tiger", "트럭", "虎"], + ["にらみ", "glare", "gaze", "노려봄", "凝視"], + ["にわとり", "chicken", "닭", "雞"], + ["ネコ", "cat", "고양이", "貓"], + ["ノック", "knock", "노크", "敲"], + ["はずれ1", "buzzer1", "failure1", "end1", "잘라내기1", "磨擦1"], + ["はずれ2", "buzzer2", "failure2", "end2", "잘라내기2", "磨擦2"], + ["バリア", "barrier", "배리어", "護罩"], + ["ひつじ", "sheep", "양", "羊"], + ["ブザー1", "buzzer3", "buzzer1", "버저1", "汽笛1"], + ["ブザー2", "buzzer4", "buzzer2", "버저2", "汽笛2"], + ["フラッシュ1", "flash1", "flashlight1", "플래시1", "閃爍1"], + ["フラッシュ2", "flash2", "flashlight2", "플래시2", "閃爍2"], + ["フラッシュ3", "flash3", "flashlight3", "플래시3", "閃爍3"], + ["ブレス", "breath", "호흡", "氣息"], + ["モンスター1", "monster1", "몬스터1", "怪物1"], + ["モンスター2", "monster2", "몬스터2", "怪物2"], + ["ライオン", "lion", "사자", "獅"], + ["冷気1", "ice1", "ice01", "냉기1", "冰1"], + ["冷気2", "ice2", "ice04", "냉기2", "冰2"], + ["冷気3", "ice3", "ice05", "냉기3", "冰3"], + ["冷気4", "ice4", "ice06", "냉기4", "冰4"], + ["冷気5", "ice5", "ice07", "냉기5", "冰5"], + ["冷気6", "ice6", "ice08", "냉기6", "冰6"], + ["冷気7", "ice7", "ice09", "냉기7", "冰7"], + ["冷気8", "ice8", "ice10", "냉기8", "冰8"], + ["冷気9", "ice9", "ice11", "냉기9", "冰9"], + ["冷気10", "ice10", "ice02", "냉기10", "冰10"], + ["冷気11", "ice11", "ice03", "냉기11", "冰11"], + ["剣1", "sword1", "검1", "劍1"], + ["剣2", "sword2", "검2", "劍2"], + ["剣3", "sword3", "검3", "劍3"], + ["吸収1", "absorb1", "absorption1", "흡수1", "吸收1"], + ["吸収2", "absorb2", "absorption2", "흡수2", "吸收2"], + ["噛む", "bite", "bloodsuck", "물다", "咬"], + ["回復1", "recovery1", "heal1", "회복1"], + ["回復2", "recovery2", "heal2", "회복2"], + ["回復3", "recovery3", "heal3", "회복3"], + ["回復4", "recovery4", "heal4", "회복4"], + ["回復5", "recovery5", "heal5", "회복5"], + ["回復6", "recovery6", "heal6", "회복6"], + ["回復7", "recovery7", "heal7", "회복7"], + ["回復8", "recovery8", "heal8", "회복8"], + ["回避1", "evade1", "evasion1", "회피1", "迴避1"], + ["回避2", "evade2", "evasion2", "회피2", "迴避2"], + ["地震1", "earthquake1", "quake1", "지진1"], + ["地震2", "earthquake2", "quake2", "지진2"], + ["壁1", "barrier1", "bump1", "wall1", "벽1"], + ["壁2", "barrier2", "bump2", "wall2", "벽2"], + ["大地1", "earth1", "earth01", "대지1"], + ["大地2", "earth2", "earth02", "대지2"], + ["大地3", "earth3", "earth03", "대지3"], + ["大地4", "earth4", "earth04", "대지4"], + ["大地5", "earth5", "earth05", "대지5"], + ["大地6", "earth6", "earth06", "대지6"], + ["大地7", "earth7", "earth07", "대지7"], + ["大地8", "earth8", "earth08", "대지8"], + ["大地9", "earth9", "earth09", "대지9"], + ["大地10", "earth10", "대지10"], + ["巻き付き", "ensnare", "vortex", "달라붙기", "捲襲"], + ["弓1", "bow1", "활1"], + ["弓2", "bow2", "활2"], + ["戦闘1", "combat1", "battle1", "전투1", "戰鬥1"], + ["戦闘2", "combat2", "battle2", "전투2", "戰鬥2"], + ["打撃1", "blow1", "punch1", "타격1", "打擊1"], + ["打撃2", "blow2", "punch2", "타격2", "打擊2"], + ["打撃3", "blow3", "punch3", "타격3", "打擊3"], + ["打撃4", "blow4", "punch4", "타격4", "打擊4"], + ["打撃5", "blow5", "punch5", "타격5", "打擊5"], + ["打撃6", "blow6", "punch6", "타격6", "打擊6"], + ["打撃7", "blow7", "punch7", "타격7", "打擊7"], + ["攻撃1", "attack1", "공격1", "攻擊1"], + ["攻撃2", "attack2", "공격2", "攻擊2"], + ["斬る1", "slash1", "strike01", "cut1", "벤다1", "斬1"], + ["斬る10", "slash10", "strike10", "cut10", "벤다10", "斬10"], + ["斬る11", "slash11", "strike11", "cut11", "벤다11", "斬11"], + ["斬る2", "slash2", "strike02", "cut2", "벤다2", "斬2"], + ["斬る3", "slash3", "strike03", "cut3", "벤다3", "斬3"], + ["斬る4", "slash4", "strike04", "cut4", "벤다4", "斬4"], + ["斬る5", "slash5", "strike05", "cut5", "벤다5", "斬5"], + ["斬る6", "slash6", "strike06", "cut6", "벤다6", "斬6"], + ["斬る7", "slash7", "strike07", "cut7", "벤다7", "斬7"], + ["斬る8", "slash8", "strike08", "cut8", "벤다8", "斬8"], + ["斬る9", "slash9", "strike09", "cut9", "벤다9", "斬9"], + ["時計", "clock", "시계", "時鐘"], + ["暗闇", "blind", "night", "darkness", "안보임", "黑暗"], + ["暗黒1", "darkness1", "dark1", "암흑1", "暗黑1"], + ["暗黒2", "darkness2", "dark2", "암흑2", "暗黑2"], + ["暗黒3", "darkness3", "dark3", "암흑3", "暗黑3"], + ["暗黒4", "darkness4", "dark4", "암흑4", "暗黑4"], + ["暗黒5", "darkness5", "dark5", "암흑5", "暗黑5"], + ["暗黒6", "darkness6", "dark6", "암흑6", "暗黑6"], + ["歌", "song", "노래"], + ["毒", "poison", "독"], + ["水1", "water1", "물1"], + ["水2", "water2", "물2"], + ["水3", "water3", "물3"], + ["水4", "water4", "물4"], + ["水5", "water5", "물5"], + ["水6", "water6", "물6"], + ["決定1", "decision1", "choice1", "결정1"], + ["決定2", "decision2", "choice2", "결정2"], + ["沈黙", "silence", "침묵", "沉默"], + ["海1", "sea1", "바다1"], + ["海2", "sea2", "바다2"], + ["消滅1", "collapse1", "vanish1", "disappearance1", "소멸1"], + ["消滅2", "collapse2", "vanish2", "disappearance2", "소멸2"], + ["混乱", "confusion", "confuse", "caos", "혼란", "混亂"], + ["炎1", "fire1", "flame1", "화염1"], + ["炎2", "fire2", "flame2", "화염2"], + ["炎3", "fire3", "flame3", "화염3"], + ["炎4", "fire4", "flame4", "화염4"], + ["炎5", "fire5", "flame5", "화염5"], + ["炎6", "fire6", "flame6", "화염6"], + ["炎7", "fire7", "flame7", "화염7"], + ["炎8", "fire8", "flame8", "화염8"], + ["爆発1", "explosion1", "explode1", "폭발1", "爆發1"], + ["爆発2", "explosion2", "explode2", "폭발2", "爆發2"], + ["爆発3", "explosion3", "explode3", "폭발3", "爆發3"], + ["爆発4", "explosion4", "explode4", "폭발4", "爆發4"], + ["爆発5", "explosion5", "explode5", "폭발5", "爆發5"], + ["爆発6", "explosion6", "explode6", "폭발6", "爆發6"], + ["爆発7", "explosion7", "explode7", "폭발7", "爆發7"], + ["睡眠", "sleep", "수면"], + ["砂けむり", "sandstorm", "sandmind", "모래 연기", "沙塵暴"], + ["神聖1", "holy1", "신성1"], + ["神聖2", "holy2", "신성2"], + ["神聖3", "holy3", "신성3"], + ["神聖4", "holy4", "신성4"], + ["神聖5", "holy5", "신성5"], + ["神聖6", "holy6", "신성6"], + ["神聖7", "holy7", "신성7"], + ["神聖8", "holy8", "신성8"], + ["神聖9", "holy9", "신성9"], + ["移動", "move", "footstep", "run", "이동"], + ["花粉", "pollen", "꽃가루"], + ["落ちる1", "fall1", "떨어진다1", "落下1"], + ["落ちる2", "fall2", "떨어진다2", "落下2"], + ["蘇生1", "raise1", "revive1", "revival1", "소생1", "復活1"], + ["蘇生2", "raise2", "revive2", "revival2", "소생2", "復活2"], + ["蘇生3", "raise3", "revive3", "revival3", "소생3", "復活3"], + ["逃走", "escape", "flee", "도주"], + ["鍵", "key", "자물쇠", "鑰匙"], + ["鐘", "bell", "gong", "종"], + ["閉める1", "close1", "닫는다1", "關閉1"], + ["閉める2", "close2", "닫는다2", "關閉2"], + ["開ける1", "open1", "열다1", "開啟1"], + ["開ける2", "open2", "열다2", "開啟2"], + ["雨1", "rain1", "비1"], + ["雨2", "rain2", "비2"], + ["雷1", "thunder1", "bolt01", "전격1"], + ["雷2", "thunder2", "bolt02", "전격2"], + ["雷3", "thunder3", "bolt03", "전격3"], + ["雷4", "thunder4", "bolt04", "전격4"], + ["雷5", "thunder5", "bolt05", "전격5"], + ["雷6", "thunder6", "bolt06", "전격6"], + ["雷7", "thunder7", "bolt07", "전격7"], + ["雷8", "thunder8", "bolt08", "전격8"], + ["雷9", "thunder9", "bolt09", "전격9"], + ["雷10", "thunder10", "bolt10", "전격10"], + ["霧1", "fog1", "안개1"], + ["霧2", "fog2", "안개2"], + ["音波1", "wave1", "sound wave1", "음파1"], + ["音波2", "wave2", "sound wave2", "음파2"], + ["風1", "wind1", "wind01", "바람1"], + ["風2", "wind2", "wind02", "바람2"], + ["風3", "wind3", "wind03", "바람3"], + ["風4", "wind4", "wind04", "바람4"], + ["風5", "wind5", "wind05", "바람5"], + ["風6", "wind6", "wind06", "바람6"], + ["風7", "wind7", "wind07", "바람7"], + ["風8", "wind8", "wind08", "바람8"], + ["風9", "wind9", "wind09", "바람9"], + ["風10", "wind10", "바람10"], + ["風11", "wind11", "바람11"], + ["魔法1", "magic1", "마법1"], + ["魔法2", "magic2", "마법2"] + ], + "system": [ + ["システム", "system"], + ["royal"], + ["blue"], + ["bof2sys"], + ["bubbles"], + ["don_system"], + ["ff2"], + ["ff3"], + ["incomsys"], + ["lightbluesystem"], + ["lines"], + ["lufia2"], + ["mint"], + ["ogre_battle"], + ["purple"], + ["red_future"], + ["redmenu3"], + ["sf2sys"], + ["shoddy"], + ["system01"], + ["system02"], + ["system03"], + ["windows01"], + ["windows02"], + ["システム", "system", "system1", "시스템", "系統"], + ["システムa", "systema", "system2", "시스템a", "系統a"], + ["システムb", "systemb", "system3", "시스템b", "系統b"], + ["システムc", "systemc", "system4", "시스템c", "系統c"] + ], + "system2": [ + ["システム2a", "system2a", "systeméqa", "시스템2a", "系統2a"], + ["システム2b", "system2b", "systeméqb", "시스템2b", "系統2b"], + ["システム2c", "system2c", "systeméqc", "시스템2c", "系統2c"] + ], + "title": [ + ["タイトル1", "title1"], + ["タイトル2", "title2"], + ["タイトル3", "title3"], + ["タイトル4", "title4"], + ["タイトル1", "title1", "타이틀1", "標題1"], + ["タイトル2", "title2", "타이틀2", "標題2"], + ["タイトル3", "title3", "타이틀3", "標題3"], + ["タイトル4", "title4", "타이틀4", "標題4"] + ] +} diff --git a/backend/models/rom.py b/backend/models/rom.py index 38f8e36f9..8647b12b2 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -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)) diff --git a/backend/tests/endpoints/roms/test_easyrpg.py b/backend/tests/endpoints/roms/test_easyrpg.py new file mode 100644 index 000000000..0b060ec42 --- /dev/null +++ b/backend/tests/endpoints/roms/test_easyrpg.py @@ -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, + ) diff --git a/backend/tests/endpoints/roms/test_rom_list_loads.py b/backend/tests/endpoints/roms/test_rom_list_loads.py index 64ecacc0c..a25018373 100644 --- a/backend/tests/endpoints/roms/test_rom_list_loads.py +++ b/backend/tests/endpoints/roms/test_rom_list_loads.py @@ -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, diff --git a/backend/tests/endpoints/test_heartbeat.py b/backend/tests/endpoints/test_heartbeat.py index ddb9f722f..6468e0f9e 100644 --- a/backend/tests/endpoints/test_heartbeat.py +++ b/backend/tests/endpoints/test_heartbeat.py @@ -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"] diff --git a/backend/tests/handler/easyrpg/__init__.py b/backend/tests/handler/easyrpg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/handler/easyrpg/test_easyrpg.py b/backend/tests/handler/easyrpg/test_easyrpg.py new file mode 100644 index 000000000..55ca98196 --- /dev/null +++ b/backend/tests/handler/easyrpg/test_easyrpg.py @@ -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) diff --git a/backend/tools/generate_easyrpg_rtp_table.py b/backend/tools/generate_easyrpg_rtp_table.py new file mode 100644 index 000000000..aad8d5fd5 --- /dev/null +++ b/backend/tools/generate_easyrpg_rtp_table.py @@ -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 /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() diff --git a/docker/Dockerfile b/docker/Dockerfile index 74e66b978..ce01fa000 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/nginx/templates/default.conf.template b/docker/nginx/templates/default.conf.template index d0d3e3831..be3ac30ef 100644 --- a/docker/nginx/templates/default.conf.template +++ b/docker/nginx/templates/default.conf.template @@ -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/-/ +# 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/(?[0-9]+)-[0-9]+/(?[^?]*) /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"; diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index d33bec8d4..43196bd2c 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -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 diff --git a/docs/FRONTEND_ARCHITECTURE.md b/docs/FRONTEND_ARCHITECTURE.md index d66da8465..be7326cc1 100644 --- a/docs/FRONTEND_ARCHITECTURE.md +++ b/docs/FRONTEND_ARCHITECTURE.md @@ -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 } diff --git a/entrypoint.sh b/entrypoint.sh index e70294330..fb72b489e 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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..." diff --git a/env.template b/env.template index 1f2edf8f2..7d14b5513 100644 --- a/env.template +++ b/env.template @@ -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 diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 05a0c6bde..e3f8a3f15 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -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; diff --git a/frontend/src/__generated__/models/EmulationDict.ts b/frontend/src/__generated__/models/EmulationDict.ts index ae956cd97..21c5399e8 100644 --- a/frontend/src/__generated__/models/EmulationDict.ts +++ b/frontend/src/__generated__/models/EmulationDict.ts @@ -7,5 +7,6 @@ export type EmulationDict = { DISABLE_RUFFLE_RS: boolean; DISABLE_JSDOS: boolean; DISABLE_PICO8: boolean; + DISABLE_EASYRPG: boolean; }; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 0eef0a6d5..f2b0c3796 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -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; diff --git a/frontend/src/locales/bg_BG/platform.json b/frontend/src/locales/bg_BG/platform.json index 85ba1c5fa..af42ee698 100644 --- a/frontend/src/locales/bg_BG/platform.json +++ b/frontend/src/locales/bg_BG/platform.json @@ -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", diff --git a/frontend/src/locales/bg_BG/play.json b/frontend/src/locales/bg_BG/play.json index 444f0ad6a..fd9ec600d 100644 --- a/frontend/src/locales/bg_BG/play.json +++ b/frontend/src/locales/bg_BG/play.json @@ -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": "Възможно е да нямате права за стрийминг или сесията ви да е изтекла. Опитайте да влезете отново.", diff --git a/frontend/src/locales/cs_CZ/platform.json b/frontend/src/locales/cs_CZ/platform.json index 0398d4298..843ea2c8e 100644 --- a/frontend/src/locales/cs_CZ/platform.json +++ b/frontend/src/locales/cs_CZ/platform.json @@ -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", diff --git a/frontend/src/locales/cs_CZ/play.json b/frontend/src/locales/cs_CZ/play.json index d9095b8db..ec327ba7a 100644 --- a/frontend/src/locales/cs_CZ/play.json +++ b/frontend/src/locales/cs_CZ/play.json @@ -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.", diff --git a/frontend/src/locales/de_DE/platform.json b/frontend/src/locales/de_DE/platform.json index 6bf7d0862..ed8908ab6 100644 --- a/frontend/src/locales/de_DE/platform.json +++ b/frontend/src/locales/de_DE/platform.json @@ -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", diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json index 70a137063..f88c42fb5 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -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.", diff --git a/frontend/src/locales/en_GB/platform.json b/frontend/src/locales/en_GB/platform.json index 4fd2eb3b2..d3925cf07 100644 --- a/frontend/src/locales/en_GB/platform.json +++ b/frontend/src/locales/en_GB/platform.json @@ -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", diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json index 495d22cdc..8e8eda708 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -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.", diff --git a/frontend/src/locales/en_US/platform.json b/frontend/src/locales/en_US/platform.json index aff01af80..bc23d2621 100644 --- a/frontend/src/locales/en_US/platform.json +++ b/frontend/src/locales/en_US/platform.json @@ -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", diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json index 74bebb6d4..d3b88f750 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -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.", diff --git a/frontend/src/locales/es_ES/platform.json b/frontend/src/locales/es_ES/platform.json index 4edead5c5..51213cdba 100644 --- a/frontend/src/locales/es_ES/platform.json +++ b/frontend/src/locales/es_ES/platform.json @@ -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", diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json index b5ef0f806..10c009f8f 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -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.", diff --git a/frontend/src/locales/fr_FR/platform.json b/frontend/src/locales/fr_FR/platform.json index aa41c7db7..dc82407f4 100644 --- a/frontend/src/locales/fr_FR/platform.json +++ b/frontend/src/locales/fr_FR/platform.json @@ -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", diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json index 1216653ba..8bde94321 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -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.", diff --git a/frontend/src/locales/hu_HU/platform.json b/frontend/src/locales/hu_HU/platform.json index 76edc8b0a..cc5e9e73f 100644 --- a/frontend/src/locales/hu_HU/platform.json +++ b/frontend/src/locales/hu_HU/platform.json @@ -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", diff --git a/frontend/src/locales/hu_HU/play.json b/frontend/src/locales/hu_HU/play.json index 99515b434..84b9b7320 100644 --- a/frontend/src/locales/hu_HU/play.json +++ b/frontend/src/locales/hu_HU/play.json @@ -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.", diff --git a/frontend/src/locales/it_IT/platform.json b/frontend/src/locales/it_IT/platform.json index 98b8bcc33..b188ec250 100644 --- a/frontend/src/locales/it_IT/platform.json +++ b/frontend/src/locales/it_IT/platform.json @@ -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", diff --git a/frontend/src/locales/it_IT/play.json b/frontend/src/locales/it_IT/play.json index 1102ae666..a0f92c5cf 100644 --- a/frontend/src/locales/it_IT/play.json +++ b/frontend/src/locales/it_IT/play.json @@ -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.", diff --git a/frontend/src/locales/ja_JP/platform.json b/frontend/src/locales/ja_JP/platform.json index 7b6e7563a..8c9de7e06 100644 --- a/frontend/src/locales/ja_JP/platform.json +++ b/frontend/src/locales/ja_JP/platform.json @@ -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 によりブラウザでプレイ可能", diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json index 4d11c12c4..30e801413 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -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": "ストリーミングの権限がないか、ログインセッションの有効期限が切れている可能性があります。もう一度サインインしてみてください。", diff --git a/frontend/src/locales/ko_KR/platform.json b/frontend/src/locales/ko_KR/platform.json index a1b697e2e..73aae4348 100644 --- a/frontend/src/locales/ko_KR/platform.json +++ b/frontend/src/locales/ko_KR/platform.json @@ -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을 통해 브라우저에서 플레이 가능", diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json index 6041e16fb..430b04528 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -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": "스트리밍 권한이 없거나 로그인 세션이 만료되었을 수 있습니다. 다시 로그인해 보세요.", diff --git a/frontend/src/locales/pl_PL/platform.json b/frontend/src/locales/pl_PL/platform.json index 1a6457e87..72c642019 100644 --- a/frontend/src/locales/pl_PL/platform.json +++ b/frontend/src/locales/pl_PL/platform.json @@ -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", diff --git a/frontend/src/locales/pl_PL/play.json b/frontend/src/locales/pl_PL/play.json index 48dca6c7f..448cefcdc 100644 --- a/frontend/src/locales/pl_PL/play.json +++ b/frontend/src/locales/pl_PL/play.json @@ -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.", diff --git a/frontend/src/locales/pt_BR/platform.json b/frontend/src/locales/pt_BR/platform.json index 1d0bdd8bf..2e8959402 100644 --- a/frontend/src/locales/pt_BR/platform.json +++ b/frontend/src/locales/pt_BR/platform.json @@ -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", diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json index 402fd8333..088966c7b 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -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.", diff --git a/frontend/src/locales/ro_RO/platform.json b/frontend/src/locales/ro_RO/platform.json index 3b1a8f88f..12d30d25a 100644 --- a/frontend/src/locales/ro_RO/platform.json +++ b/frontend/src/locales/ro_RO/platform.json @@ -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", diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json index 6ad138084..270e4514f 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -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.", diff --git a/frontend/src/locales/ru_RU/platform.json b/frontend/src/locales/ru_RU/platform.json index 2296e5d6b..6756b0063 100644 --- a/frontend/src/locales/ru_RU/platform.json +++ b/frontend/src/locales/ru_RU/platform.json @@ -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", diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json index 939959590..c1494eaf1 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -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": "Возможно, у вас нет прав на стриминг или ваша сессия истекла. Попробуйте войти снова.", diff --git a/frontend/src/locales/tr_TR/platform.json b/frontend/src/locales/tr_TR/platform.json index e94bf5af0..bd7516acd 100644 --- a/frontend/src/locales/tr_TR/platform.json +++ b/frontend/src/locales/tr_TR/platform.json @@ -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", diff --git a/frontend/src/locales/tr_TR/play.json b/frontend/src/locales/tr_TR/play.json index 020e27ea7..2ddeb928d 100644 --- a/frontend/src/locales/tr_TR/play.json +++ b/frontend/src/locales/tr_TR/play.json @@ -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.", diff --git a/frontend/src/locales/zh_CN/platform.json b/frontend/src/locales/zh_CN/platform.json index d2f6969d8..67a5e69ca 100644 --- a/frontend/src/locales/zh_CN/platform.json +++ b/frontend/src/locales/zh_CN/platform.json @@ -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 在浏览器中游玩", diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json index 9eac7a946..2bf2a2d1f 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -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": "您可能没有串流权限,或者登录会话已过期。请尝试重新登录。", diff --git a/frontend/src/locales/zh_TW/platform.json b/frontend/src/locales/zh_TW/platform.json index ae60f200b..679b5b79d 100644 --- a/frontend/src/locales/zh_TW/platform.json +++ b/frontend/src/locales/zh_TW/platform.json @@ -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 在瀏覽器中遊玩", diff --git a/frontend/src/locales/zh_TW/play.json b/frontend/src/locales/zh_TW/play.json index 57bbecc47..a4bd97656 100644 --- a/frontend/src/locales/zh_TW/play.json +++ b/frontend/src/locales/zh_TW/play.json @@ -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": "您可能沒有串流權限,或登入工作階段已過期。請嘗試重新登入。", diff --git a/frontend/src/plugins/routeNames.ts b/frontend/src/plugins/routeNames.ts index 8d08f5045..f50b63dec 100644 --- a/frontend/src/plugins/routeNames.ts +++ b/frontend/src/plugins/routeNames.ts @@ -15,6 +15,7 @@ export const ROUTES = { EMULATORJS: "emulatorjs", JSDOS: "jsdos", PICO8: "pico8", + EASYRPG: "easyrpg", RUFFLE: "ruffle", STREAM: "stream", STREAM_DESKTOP: "stream-desktop", diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index 9161bcce1..de58bafcd 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -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, diff --git a/frontend/src/stores/heartbeat.ts b/frontend/src/stores/heartbeat.ts index c6f0a5954..d545dab0a 100644 --- a/frontend/src/stores/heartbeat.ts +++ b/frontend/src/stores/heartbeat.ts @@ -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, diff --git a/frontend/src/utils/index.test.ts b/frontend/src/utils/index.test.ts index b91debfc7..3ff5e2f8c 100644 --- a/frontend/src/utils/index.test.ts +++ b/frontend/src/utils/index.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 0a62b3598..b0788b542 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -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"; diff --git a/frontend/src/v2/components/Gallery/listRowFixture.ts b/frontend/src/v2/components/Gallery/listRowFixture.ts index 906b7fbb1..e060d4429 100644 --- a/frontend/src/v2/components/Gallery/listRowFixture.ts +++ b/frontend/src/v2/components/Gallery/listRowFixture.ts @@ -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", diff --git a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue index 879c1eef3..45520b8af 100644 --- a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue +++ b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue @@ -159,6 +159,7 @@ const syntheticRom = computed(() => ({ has_simple_single_file: false, has_nested_single_file: false, has_multiple_files: false, + is_easyrpg_game: false, full_path: "", created_at: "", updated_at: "", diff --git a/frontend/src/v2/composables/useCanPlay/index.test.ts b/frontend/src/v2/composables/useCanPlay/index.test.ts index 20b16fcf7..451473756 100644 --- a/frontend/src/v2/composables/useCanPlay/index.test.ts +++ b/frontend/src/v2/composables/useCanPlay/index.test.ts @@ -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()); diff --git a/frontend/src/v2/composables/useCanPlay/index.ts b/frontend/src/v2/composables/useCanPlay/index.ts index 86b0efec1..1ab4ea6d3 100644 --- a/frontend/src/v2/composables/useCanPlay/index.ts +++ b/frontend/src/v2/composables/useCanPlay/index.ts @@ -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; canPlayJsDos: ComputedRef; canPlayPico8: ComputedRef; + canPlayEasyRpg: ComputedRef; canPlayRuffle: ComputedRef; canPlayStream: ComputedRef; canPlayNative: ComputedRef; @@ -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, diff --git a/frontend/src/v2/composables/useGalleryProvenance/index.ts b/frontend/src/v2/composables/useGalleryProvenance/index.ts index 1c8206727..3aeb3edb0 100644 --- a/frontend/src/v2/composables/useGalleryProvenance/index.ts +++ b/frontend/src/v2/composables/useGalleryProvenance/index.ts @@ -30,6 +30,7 @@ const CHAIN_ROUTES: ReadonlySet = new Set([ ROUTES.ROM, ROUTES.EMULATORJS, ROUTES.PICO8, + ROUTES.EASYRPG, ROUTES.RUFFLE, ROUTES.STREAM, ]); diff --git a/frontend/src/v2/composables/useGameActions/index.test.ts b/frontend/src/v2/composables/useGameActions/index.test.ts index 2ce0f2bb6..ad47fc7cc 100644 --- a/frontend/src/v2/composables/useGameActions/index.test.ts +++ b/frontend/src/v2/composables/useGameActions/index.test.ts @@ -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"); }); diff --git a/frontend/src/v2/composables/useGameActions/index.ts b/frontend/src/v2/composables/useGameActions/index.ts index 28892f0e3..4eed356b3 100644 --- a/frontend/src/v2/composables/useGameActions/index.ts +++ b/frontend/src/v2/composables/useGameActions/index.ts @@ -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. diff --git a/frontend/src/v2/composables/usePlatformPlayable/index.test.ts b/frontend/src/v2/composables/usePlatformPlayable/index.test.ts index 2da568cd4..7d3253e33 100644 --- a/frontend/src/v2/composables/usePlatformPlayable/index.test.ts +++ b/frontend/src/v2/composables/usePlatformPlayable/index.test.ts @@ -12,6 +12,7 @@ const ruffleSlugs = new Set(); const dosboxSlugs = new Set(); const jsDosSlugs = new Set(); const pico8Slugs = new Set(); +const easyRpgSlugs = new Set(); const streamContainers = new Map(); 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", ); diff --git a/frontend/src/v2/composables/usePlatformPlayable/index.ts b/frontend/src/v2/composables/usePlatformPlayable/index.ts index 631ee3e57..a2281ca68 100644 --- a/frontend/src/v2/composables/usePlatformPlayable/index.ts +++ b/frontend/src/v2/composables/usePlatformPlayable/index.ts @@ -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: diff --git a/frontend/src/v2/composables/useViewTransition/index.ts b/frontend/src/v2/composables/useViewTransition/index.ts index 4ce8a19e2..588019bba 100644 --- a/frontend/src/v2/composables/useViewTransition/index.ts +++ b/frontend/src/v2/composables/useViewTransition/index.ts @@ -34,6 +34,7 @@ const MORPH_ROM_ROUTES: ReadonlySet = new Set([ ROUTES.EMULATORJS, ROUTES.JSDOS, ROUTES.PICO8, + ROUTES.EASYRPG, ROUTES.RUFFLE, ]); diff --git a/frontend/src/v2/router/routes.ts b/frontend/src/v2/router/routes.ts index 879175b3b..849c728af 100644 --- a/frontend/src/v2/router/routes.ts +++ b/frontend/src/v2/router/routes.ts @@ -43,6 +43,7 @@ export const v2RouteComponents: Partial> = { [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"), diff --git a/frontend/src/v2/views/Player/EasyRpg.test.ts b/frontend/src/v2/views/Player/EasyRpg.test.ts new file mode 100644 index 000000000..fe5d357f4 --- /dev/null +++ b/frontend/src/v2/views/Player/EasyRpg.test.ts @@ -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: "
" }, +})); + +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: "", + }, + RCard: { template: "
" }, + RSpinner: true, + RSwitch: true, + }, + }, + }); +} + +async function play(): Promise { + 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(); + }); +}); diff --git a/frontend/src/v2/views/Player/EasyRpg.vue b/frontend/src/v2/views/Player/EasyRpg.vue new file mode 100644 index 000000000..fa6b5db4a --- /dev/null +++ b/frontend/src/v2/views/Player/EasyRpg.vue @@ -0,0 +1,169 @@ + + +