├── .dockerignore ├── .github ├── funding.yml └── workflows │ └── docker.yml ├── npps4 ├── version.py ├── alembic │ ├── README │ ├── script.py.mako │ ├── versions │ │ ├── 2024_07_07_1728-eaefd67367c8_wal.py │ │ ├── 2025_08_30_1237-deea55d98599_fixes_table.py │ │ ├── 2024_04_15_0040-a005ea560d9c_.py │ │ ├── 2025_05_03_1834-d701309ff89e_.py │ │ ├── 2024_04_17_1553-0a5db1c03ed0_.py │ │ ├── 2024_08_17_1431-3c0c34f09192_.py │ │ ├── 2025_10_11_1832-b3d6a058fa62_.py │ │ ├── 2025_05_04_1307-6a6186e47091_.py │ │ ├── 2024_05_12_1109-1d73d9b010d0_.py │ │ ├── 2024_08_18_1341-f8b44a48b0ef_.py │ │ ├── 2024_04_05_1441-930ed8d00ec1_.py │ │ ├── 2024_08_01_2248-3f827cd6aef5_.py │ │ ├── 2024_04_05_1443-397436128a36_.py │ │ ├── 2024_03_12_1858-5039725fabc6_.py │ │ └── 2024_04_04_1044-58d5edd4f818_.py │ └── env.py ├── run │ ├── webui.py │ └── app.py ├── script_dummy.py ├── webui │ ├── endpoints │ │ ├── __init__.py │ │ ├── index.py │ │ ├── list_users.py │ │ └── unlock_backgrounds.py │ ├── template.py │ ├── __init__.py │ └── static │ │ ├── index.html │ │ ├── list_users.html │ │ └── unlock_backgrounds.html ├── game │ ├── models.py │ ├── ad.py │ ├── gdpr.py │ ├── costume.py │ ├── livese.py │ ├── liveicon.py │ ├── museum.py │ ├── navigation.py │ ├── announce.py │ ├── personalnotice.py │ ├── challenge.py │ ├── multiunit.py │ ├── eventscenario.py │ ├── tos.py │ ├── marathon.py │ ├── __init__.py │ ├── award.py │ ├── background.py │ ├── event.py │ ├── item.py │ ├── notice.py │ ├── banner.py │ ├── tutorial.py │ └── album.py ├── uvicorn_worker.py ├── setup.py ├── webview │ ├── tos.py │ ├── announce.py │ ├── static.py │ ├── secretbox.py │ ├── __init__.py │ └── serialcode.py ├── release_key.py ├── system │ ├── ad_model.py │ ├── friend.py │ ├── class_system.py │ ├── scenario_model.py │ ├── tos.py │ ├── item_model.py │ ├── tutorial.py │ ├── live_model.py │ ├── handover.py │ ├── removable_skill.py │ ├── award.py │ ├── background.py │ ├── core.py │ ├── common.py │ ├── subscenario.py │ ├── museum.py │ └── secretbox_model.py ├── app │ └── webui.py ├── scriptutils │ ├── boot.py │ └── user.py ├── download │ ├── dltype.py │ ├── none.py │ └── download.py ├── idol │ ├── __init__.py │ └── cache.py ├── idoltype.py ├── script.py ├── evloop.py ├── errhand.py ├── serialcode │ └── func.py ├── other.py ├── db │ ├── __init__.py │ ├── subscenario.py │ ├── common.py │ ├── museum.py │ └── effort.py ├── __init__.py ├── sif2export.py ├── leader_skill.py └── config │ └── cfgtype.py ├── requirements-docker.txt ├── requirements-perf.txt ├── static ├── img │ └── help │ │ ├── bg02.png │ │ ├── bg03.png │ │ ├── tab_on.png │ │ ├── tab_off.png │ │ ├── bg01_maint.png │ │ └── bug_trans.png ├── css1.3 │ └── regulation.css └── js │ ├── common │ └── tab.js │ └── button.js ├── pyinstaller_bootstrap.py ├── .gitignore ├── run_npps4.sh ├── scripts ├── reformat_server_data.py ├── migrations │ ├── 5_send_subunit_rewards.py │ ├── 4_update_achievement_reset_type.py │ ├── 8_achievement_fix_1.py │ ├── 6_send_subunit_rewards_take2.py │ ├── 1_update_incentive_unit_info.py │ ├── 2_populate_normal_live_unlock.py │ └── 7_give_loveca_to_cleared_songs.py ├── README.md ├── update_server_data_json_schema.py ├── list_users.py ├── generate_hashed_serialcode.py ├── delete_user.py ├── cleanup_orphan_user.py ├── decrypt_serial_code.py ├── generate_passcode.py ├── unlock_all_background.py ├── unlock_all_subscenario.py ├── bootstrap_docker.py ├── add_exchange_point.py ├── encrypt_serial_code_action.py ├── export_account.py ├── export_all_user.py ├── list_ach_live_unlock.py ├── import_account.py ├── import_all_user.py ├── list_ach_scenario_unlock.py └── inspect_export_data.py ├── run_npps4.bat ├── requirements.txt ├── DBBREAKAGE.md ├── templates ├── helper_achievement_list.html ├── static │ └── 13.html ├── secretbox_detail.html ├── helper_achievement_info.html ├── maintenance.html ├── error.html ├── convert.html └── serialcode.html ├── util ├── leaderskill_db_map.txt ├── sis_db_map.txt └── unit_removable_skill_m.md ├── setup.py ├── default_server_key.pem ├── LICENSE.md ├── npps4.spec ├── Dockerfile ├── docker-compose.example.yml ├── make_server_key.py └── external ├── badwords.py └── login_bonus.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | **/*.pyc 3 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | ko_fi: npad93 2 | -------------------------------------------------------------------------------- /npps4/version.py: -------------------------------------------------------------------------------- 1 | NPPS4_VERSION = (0, 0, 1) 2 | -------------------------------------------------------------------------------- /npps4/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /npps4/run/webui.py: -------------------------------------------------------------------------------- 1 | from ..webui import webui 2 | 3 | main = webui.app 4 | -------------------------------------------------------------------------------- /requirements-docker.txt: -------------------------------------------------------------------------------- 1 | psycopg[binary] 2 | gunicorn 3 | uvicorn-worker 4 | -------------------------------------------------------------------------------- /requirements-perf.txt: -------------------------------------------------------------------------------- 1 | uvloop; sys_platform != "win32" 2 | winloop; sys_platform == "win32" 3 | -------------------------------------------------------------------------------- /npps4/script_dummy.py: -------------------------------------------------------------------------------- 1 | # This file is only used to indicate that NPPS4 is loaded through npps4.script. 2 | -------------------------------------------------------------------------------- /static/img/help/bg02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkEnergyProcessor/NPPS4/HEAD/static/img/help/bg02.png -------------------------------------------------------------------------------- /static/img/help/bg03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkEnergyProcessor/NPPS4/HEAD/static/img/help/bg03.png -------------------------------------------------------------------------------- /static/img/help/tab_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkEnergyProcessor/NPPS4/HEAD/static/img/help/tab_on.png -------------------------------------------------------------------------------- /pyinstaller_bootstrap.py: -------------------------------------------------------------------------------- 1 | import npps4.__main__ 2 | 3 | if __name__ == "__main__": 4 | npps4.__main__.main() 5 | -------------------------------------------------------------------------------- /static/img/help/tab_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkEnergyProcessor/NPPS4/HEAD/static/img/help/tab_off.png -------------------------------------------------------------------------------- /npps4/webui/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | from . import index 2 | from . import list_users 3 | from . import unlock_backgrounds 4 | -------------------------------------------------------------------------------- /static/img/help/bg01_maint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkEnergyProcessor/NPPS4/HEAD/static/img/help/bg01_maint.png -------------------------------------------------------------------------------- /static/img/help/bug_trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkEnergyProcessor/NPPS4/HEAD/static/img/help/bug_trans.png -------------------------------------------------------------------------------- /npps4/game/models.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | 4 | class UserData(pydantic.BaseModel): 5 | user_id: int 6 | name: str 7 | level: int 8 | -------------------------------------------------------------------------------- /npps4/uvicorn_worker.py: -------------------------------------------------------------------------------- 1 | import uvicorn.workers 2 | 3 | 4 | class Worker(uvicorn.workers.UvicornWorker): 5 | CONFIG_KWARGS = {"loop": "npps4.evloop:new_event_loop"} 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | /build 3 | /dist 4 | /data 5 | /npps4.egg-info 6 | /util/sampledata 7 | /venv 8 | 9 | *.bin 10 | *.pyc 11 | /*.ipynb 12 | /config.toml 13 | /maintenance.txt 14 | -------------------------------------------------------------------------------- /npps4/setup.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | from .config import config 4 | 5 | lock = multiprocessing.Lock() 6 | 7 | 8 | def initialize(): 9 | pass 10 | 11 | 12 | with lock: 13 | initialize() 14 | -------------------------------------------------------------------------------- /npps4/webui/template.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import fastapi.templating 4 | 5 | template_dir = os.path.join(os.path.dirname(__file__), "static") 6 | 7 | template = fastapi.templating.Jinja2Templates(template_dir) 8 | -------------------------------------------------------------------------------- /npps4/webview/tos.py: -------------------------------------------------------------------------------- 1 | from ..app import app 2 | 3 | 4 | @app.webview.get("/tos/read") 5 | def tos_read(tos_id: int = 1): 6 | # TODO: TOS ID > 1? 7 | with open("templates/tos_read.html", "rb") as f: 8 | return f.read() 9 | -------------------------------------------------------------------------------- /npps4/game/ad.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from .. import util 3 | 4 | 5 | @idol.register("ad", "changeAd") 6 | async def ad_adchange(context: idol.SchoolIdolUserParams) -> None: 7 | # TODO 8 | util.stub("ad", "changeAd", context.raw_request_data) 9 | -------------------------------------------------------------------------------- /npps4/release_key.py: -------------------------------------------------------------------------------- 1 | _RELEASE_KEYS: dict[int, str] = {} 2 | 3 | get = _RELEASE_KEYS.get 4 | update = _RELEASE_KEYS.update 5 | 6 | 7 | def formatted(): 8 | global _RELEASE_KEYS 9 | return [{"id": k, "key": v} for k, v in _RELEASE_KEYS.items()] 10 | -------------------------------------------------------------------------------- /npps4/system/ad_model.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from . import common 4 | 5 | 6 | class AdInfo(pydantic.BaseModel): 7 | ad_id: int = 0 8 | term_id: int = 0 9 | reward_list: list[pydantic.SerializeAsAny[common.AnyItem]] = pydantic.Field(default_factory=list) 10 | -------------------------------------------------------------------------------- /npps4/app/webui.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | import fastapi.responses 3 | 4 | from .. import version 5 | 6 | app = fastapi.FastAPI( 7 | title="NPPS4 WebUI", 8 | version="%d.%d.%d" % version.NPPS4_VERSION, 9 | default_response_class=fastapi.responses.HTMLResponse, 10 | ) 11 | -------------------------------------------------------------------------------- /run_npps4.sh: -------------------------------------------------------------------------------- 1 | if [ -z "$VIRTUAL_ENV" ]; then 2 | if [ ! -d "venv" ]; then 3 | echo "Cannot find virtual environment. Is it installed?" 4 | exit 1 5 | fi 6 | PYTHON="venv/bin/python" 7 | else 8 | PYTHON="$VIRTUAL_ENV/bin/python" 9 | fi 10 | 11 | "$PYTHON" main.py "$@" 12 | -------------------------------------------------------------------------------- /npps4/webview/announce.py: -------------------------------------------------------------------------------- 1 | import fastapi.responses 2 | 3 | from .. import util 4 | from ..app import app 5 | 6 | 7 | @app.webview.get("/announce/index") 8 | def announce_index(): 9 | util.stub("announce", "index") 10 | return fastapi.responses.RedirectResponse("/main.php/api", 302) 11 | -------------------------------------------------------------------------------- /npps4/webui/endpoints/index.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | 3 | from .. import template 4 | from ...app import webui 5 | 6 | 7 | @webui.app.get("/") 8 | @webui.app.get("/index.html") 9 | def index(request: fastapi.Request): 10 | return template.template.TemplateResponse("index.html", {"request": request}) 11 | -------------------------------------------------------------------------------- /scripts/reformat_server_data.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | import npps4.data.schema 3 | 4 | 5 | async def run_script(args: list[str]): 6 | npps4.data.update() 7 | 8 | 9 | if __name__ == "__main__": 10 | import npps4.scriptutils.boot 11 | 12 | npps4.scriptutils.boot.start(run_script) 13 | -------------------------------------------------------------------------------- /scripts/migrations/5_send_subunit_rewards.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | 7 | revision = "5_send_subunit_rewards" 8 | prev_revision = "4_update_achievement_reset_type" 9 | 10 | 11 | async def main(context: npps4.idol.BasicSchoolIdolContext): 12 | pass 13 | -------------------------------------------------------------------------------- /npps4/webui/__init__.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import fastapi.staticfiles 4 | 5 | from . import endpoints 6 | from ..app import webui 7 | 8 | current_dir = os.path.dirname(__file__) 9 | 10 | webui.app.mount( 11 | "/static", fastapi.staticfiles.StaticFiles(directory=os.path.join(current_dir, "static")), "static_file" 12 | ) 13 | -------------------------------------------------------------------------------- /run_npps4.bat: -------------------------------------------------------------------------------- 1 | setlocal enabledelayedexpansion 2 | 3 | if "%VIRTUAL_ENV%"=="" ( 4 | IF NOT EXIST "venv" ( 5 | echo Cannot find virtual environment. Is it installed? 6 | exit /b 1 7 | ) 8 | set PYTHON=venv\Scripts\python 9 | ) else ( 10 | set PYTHON=%VIRTUAL_ENV%\Scripts\python 11 | ) 12 | 13 | "%PYTHON%" main.py %* 14 | endlocal 15 | -------------------------------------------------------------------------------- /npps4/system/friend.py: -------------------------------------------------------------------------------- 1 | from .. import const 2 | from .. import idol 3 | from ..db import main 4 | 5 | 6 | async def get_friend_status(context: idol.BasicSchoolIdolContext, /, current_user: main.User, target_user: main.User): 7 | # TODO 8 | if current_user.id == target_user.id: 9 | return const.FRIEND_STATUS.FRIEND 10 | return const.FRIEND_STATUS.OTHER 11 | -------------------------------------------------------------------------------- /npps4/scriptutils/boot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections.abc 3 | import sys 4 | 5 | from .. import script_dummy # type: ignore[reportUnusedImport] 6 | from .. import evloop 7 | 8 | from typing import Any, Callable 9 | 10 | 11 | def start(entry: Callable[[list[str]], collections.abc.Coroutine[Any, Any, None]]): 12 | asyncio.run(entry(sys.argv[1:]), loop_factory=evloop.new_event_loop) 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiosqlite<0.22 # https://github.com/sqlalchemy/sqlalchemy/issues/13039 2 | alembic[tz] 3 | fastapi 4 | honkypy @ https://github.com/DarkEnergyProcessor/honky-py/releases/download/0.2.0/honkypy-0.2.0-py3-none-any.whl 5 | httpx 6 | itsdangerous 7 | jinja2 8 | pycryptodomex 9 | pydantic>=2 10 | pydantic-settings 11 | python-multipart 12 | sqlalchemy[asyncio]>=2 13 | uvicorn[standard] 14 | -------------------------------------------------------------------------------- /DBBREAKAGE.md: -------------------------------------------------------------------------------- 1 | Database Breakage 2 | ===== 3 | 4 | Database breakage is when NPPS4 has some changes in its database such that keeping user data is not possible. 5 | 6 | If you want to update NPPS4, make sure to check out this page for possible database breakage. Latest commit that 7 | introduce breakage will be listed on top. 8 | 9 | Breakage List 10 | ----- 11 | 12 | (none yet as of this file exists) 13 | -------------------------------------------------------------------------------- /npps4/webui/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |

NPPS4 WebUI

11 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /templates/helper_achievement_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Achievement Explorer

10 |
11 |
    12 | {% for i in items %} 13 |
  1. {{ i[1]|e }}
  2. 14 | {% endfor %} 15 |
16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /npps4/game/gdpr.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from .. import util 3 | from ..system import common 4 | 5 | 6 | class GDPRGetResponse(common.TimestampMixin): 7 | enable_gdpr: bool 8 | is_eea: bool 9 | 10 | 11 | @idol.register("gdpr", "get") 12 | async def gdpr_get(context: idol.SchoolIdolUserParams) -> GDPRGetResponse: 13 | # TODO 14 | util.stub("gdpr", "get", context.raw_request_data) 15 | return GDPRGetResponse(enable_gdpr=True, is_eea=False) 16 | -------------------------------------------------------------------------------- /npps4/game/costume.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from .. import util 3 | 4 | import pydantic 5 | 6 | 7 | class CustomeListResponse(pydantic.BaseModel): 8 | costume_list: list 9 | 10 | 11 | @idol.register("costume", "costumeList") 12 | async def costume_costumelist(context: idol.SchoolIdolUserParams) -> CustomeListResponse: 13 | # TODO 14 | util.stub("costume", "costumeList", context.raw_request_data) 15 | return CustomeListResponse(costume_list=[]) 16 | -------------------------------------------------------------------------------- /npps4/game/livese.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from .. import util 3 | 4 | import pydantic 5 | 6 | 7 | class LiveSEInfoResponse(pydantic.BaseModel): 8 | live_se_list: list[int] 9 | 10 | 11 | @idol.register("livese", "liveseInfo") 12 | async def livese_liveseinfo(context: idol.SchoolIdolUserParams) -> LiveSEInfoResponse: 13 | # TODO 14 | util.stub("livese", "liveseInfo", context.raw_request_data) 15 | return LiveSEInfoResponse(live_se_list=[1, 2, 3, 4, 99]) 16 | -------------------------------------------------------------------------------- /npps4/download/dltype.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | 4 | class Checksum(pydantic.BaseModel): 5 | md5: str = "d41d8cd98f00b204e9800998ecf8427e" 6 | sha256: str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 7 | 8 | 9 | class BaseInfo(pydantic.BaseModel): 10 | url: str 11 | size: int 12 | checksums: Checksum 13 | 14 | 15 | class UpdateInfo(BaseInfo): 16 | version: str 17 | 18 | 19 | class BatchInfo(BaseInfo): 20 | packageId: int 21 | -------------------------------------------------------------------------------- /npps4/game/liveicon.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from .. import util 3 | 4 | import pydantic 5 | 6 | 7 | class LiveIconInfoResponse(pydantic.BaseModel): 8 | live_notes_icon_list: list[int] 9 | 10 | 11 | @idol.register("liveicon", "liveiconInfo") 12 | async def liveicon_liveiconinfo(context: idol.SchoolIdolUserParams) -> LiveIconInfoResponse: 13 | # TODO 14 | util.stub("liveicon", "liveiconInfo", context.raw_request_data) 15 | return LiveIconInfoResponse(live_notes_icon_list=[1, 2, 3]) 16 | -------------------------------------------------------------------------------- /npps4/idol/__init__.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | 3 | from . import error 4 | from .core import register 5 | from .session import BasicSchoolIdolContext, SchoolIdolParams, SchoolIdolAuthParams, SchoolIdolUserParams 6 | from ..idoltype import Language, PlatformType, XMCVerifyMode 7 | 8 | 9 | def create_basic_context(request: fastapi.Request): 10 | try: 11 | lang = Language(request.headers.get("LANG", "en")) 12 | except ValueError: 13 | lang = Language.en 14 | return BasicSchoolIdolContext(lang) 15 | -------------------------------------------------------------------------------- /npps4/game/museum.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from ..system import museum 5 | from ..system import user 6 | 7 | 8 | class MuseumInfoResponse(pydantic.BaseModel): 9 | museum_info: museum.MuseumInfoData 10 | 11 | 12 | @idol.register("museum", "info") 13 | async def museum_info(context: idol.SchoolIdolUserParams) -> MuseumInfoResponse: 14 | current_user = await user.get_current(context) 15 | return MuseumInfoResponse(museum_info=await museum.get_museum_info_data(context, current_user)) 16 | -------------------------------------------------------------------------------- /npps4/webui/static/list_users.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |

Users

11 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /npps4/webview/static.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import fastapi 4 | import fastapi.responses 5 | 6 | from ..app import app 7 | 8 | from typing import Annotated 9 | 10 | 11 | @app.webview.get("/static/index") 12 | async def static_index(id: Annotated[int, fastapi.Query()]): 13 | path = f"templates/static/{id}.html" 14 | if os.path.isfile(path): 15 | return fastapi.responses.FileResponse(path, media_type="text/html") 16 | 17 | return fastapi.responses.JSONResponse({"detail": "not found", "id": id}, 404) 18 | -------------------------------------------------------------------------------- /npps4/game/navigation.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from .. import util 5 | 6 | 7 | class NavigationSpecialCutInResponse(pydantic.BaseModel): 8 | special_cutin_list: list # TODO 9 | 10 | 11 | @idol.register("navigation", "specialCutin") 12 | async def navigation_specialcutin(context: idol.SchoolIdolUserParams) -> NavigationSpecialCutInResponse: 13 | # TODO 14 | util.stub("navigation", "specialCutin", context.raw_request_data) 15 | return NavigationSpecialCutInResponse(special_cutin_list=[]) 16 | -------------------------------------------------------------------------------- /npps4/system/class_system.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import util 4 | 5 | 6 | class ClassRankInfoData(pydantic.BaseModel): # TODO 7 | before_class_rank_id: int = 1 8 | after_class_rank_id: int = 1 9 | rank_up_date: str = util.timestamp_to_datetime(86400) 10 | 11 | 12 | class ClassSystemData(pydantic.BaseModel): # TODO 13 | rank_info: ClassRankInfoData = pydantic.Field(default_factory=ClassRankInfoData) 14 | complete_flag: bool = False 15 | is_opened: bool = False 16 | is_visible: bool = False 17 | -------------------------------------------------------------------------------- /templates/static/13.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | Scripts 2 | ===== 3 | 4 | Scripts are code that performs automated tasks to NPPS4 itself. 5 | 6 | To run the scripts directly like ordinary Python scripts, run this once in virtual environment: 7 | ``` 8 | pip install -e . 9 | ``` 10 | 11 | Then you can run them like ordinary scripts (e.g. `python script.py ...`) 12 | 13 | If you don't want to do `pip install -e .`, then to run script, you must run it like this: 14 | ``` 15 | python -m npps4.script script.py ... 16 | ``` 17 | 18 | Will run `script.py` in the working directory. 19 | -------------------------------------------------------------------------------- /npps4/system/scenario_model.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from . import item_model 4 | from .. import const 5 | 6 | 7 | class AdditionalScenarioStatus(pydantic.BaseModel): 8 | scenario_id: int 9 | status: int = 1 10 | 11 | 12 | class ScenarioItem(item_model.Item): 13 | add_type: const.ADD_TYPE = const.ADD_TYPE.SCENARIO 14 | amount: int = 1 15 | 16 | @pydantic.computed_field 17 | @property 18 | def additional_scenario_status(self) -> AdditionalScenarioStatus: 19 | return AdditionalScenarioStatus(scenario_id=self.item_id) 20 | -------------------------------------------------------------------------------- /util/leaderskill_db_map.txt: -------------------------------------------------------------------------------- 1 | SELECT * FROM 'unit_leader_skill_m' 2 | SELECT * FROM 'unit_leader_skill_extra_m' 3 | 4 | Note: Leader skill affects all teams! 5 | 6 | Leader skill calc: 7 | 8 | * leader_skill_effect_type 9 | 1 = +Smile 10 | 2 = +Pure 11 | 3 = +Cool 12 | 112 = +Pure by Smile 13 | 113 = +Cool by Smile 14 | 121 = +Smile by Pure 15 | 123 = +Cool by Pure 16 | 131 = +Smile by Cool 17 | 132 = +Pure by Cool 18 | 19 | Extra leader skill calc: 20 | 21 | * member_tag_id = member_tag_id in member_tag_m 22 | * leader_skill_effect_type = same as above 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import setuptools # type: ignore 3 | 4 | import npps4.version 5 | 6 | with open(os.path.join(os.path.dirname(__file__), "requirements.txt"), "r", encoding="utf-8") as f: 7 | setuptools.setup( 8 | name="npps4", 9 | version="%d.%d.%d" % npps4.version.NPPS4_VERSION, 10 | description="Null-Pointer Private Server", 11 | author="Miku AuahDark", 12 | packages=["npps4"], 13 | install_requires=["wheel", *map(str.strip, f)], 14 | entry_points={"console_scripts": ["npps4_script=npps4.script:entry"]}, 15 | ) 16 | -------------------------------------------------------------------------------- /scripts/update_server_data_json_schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import npps4.script_dummy # isort:skip 4 | import npps4.data.schema 5 | 6 | 7 | async def run_script(args: list[str]): 8 | schema = npps4.data.schema.SerializedServerData.model_json_schema() 9 | schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" 10 | with open("npps4/server_data_schema.json", "w", encoding="utf-8", newline="") as f: 11 | json.dump(schema, f, indent="\t") 12 | 13 | 14 | if __name__ == "__main__": 15 | import npps4.scriptutils.boot 16 | 17 | npps4.scriptutils.boot.start(run_script) 18 | -------------------------------------------------------------------------------- /npps4/system/tos.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | 3 | from .. import idol 4 | from ..db import main 5 | 6 | 7 | async def is_agreed(context: idol.SchoolIdolParams, user: main.User, tos_id: int): 8 | q = sqlalchemy.select(main.TOSAgree).where(main.TOSAgree.user_id == user.id, main.TOSAgree.tos_id == tos_id) 9 | result = await context.db.main.execute(q) 10 | return result.scalar() is not None 11 | 12 | 13 | async def agree(context: idol.BasicSchoolIdolContext, user: main.User, tos_id: int): 14 | agree = main.TOSAgree(user_id=user.id, tos_id=tos_id) 15 | context.db.main.add(agree) 16 | await context.db.main.flush() 17 | -------------------------------------------------------------------------------- /npps4/game/announce.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from ..system import common 3 | from ..system import reward 4 | from ..system import user 5 | 6 | 7 | class AnnounceStateResponse(common.TimestampMixin): 8 | present_cnt: int 9 | has_unread_announce: bool 10 | 11 | 12 | @idol.register("announce", "checkState") 13 | async def announce_checkstate(context: idol.SchoolIdolUserParams) -> AnnounceStateResponse: 14 | current_user = await user.get_current(context) 15 | return AnnounceStateResponse( 16 | present_cnt=await reward.count_presentbox(context, current_user), 17 | has_unread_announce=False, # TODO 18 | ) 19 | -------------------------------------------------------------------------------- /scripts/list_users.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python -m npps4.script 2 | import sqlalchemy 3 | 4 | import npps4.db.main 5 | import npps4.idol 6 | 7 | 8 | async def run_script(args: list[str]): 9 | context = npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) 10 | async with context: 11 | q = sqlalchemy.select(npps4.db.main.User) 12 | result = await context.db.main.execute(q) 13 | for user in result.scalars(): 14 | print(f"{user.id}|{user.name}|{user.invite_code}") 15 | 16 | 17 | if __name__ == "__main__": 18 | import npps4.scriptutils.boot 19 | 20 | npps4.scriptutils.boot.start(run_script) 21 | -------------------------------------------------------------------------------- /npps4/webui/endpoints/list_users.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | import sqlalchemy 3 | 4 | from .. import template 5 | from ... import idol 6 | from ...app import webui 7 | from ...db import main 8 | 9 | 10 | @webui.app.get("/list_users.html") 11 | async def list_users(request: fastapi.Request): 12 | async with idol.BasicSchoolIdolContext(lang=idol.Language.en) as context: 13 | q = sqlalchemy.select(main.User) 14 | result = await context.db.main.execute(q) 15 | users = [{"name": user.name, "id": user.id} for user in result.scalars()] 16 | 17 | return template.template.TemplateResponse(request, "list_users.html", {"users": users}) 18 | -------------------------------------------------------------------------------- /npps4/game/personalnotice.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from .. import util 5 | 6 | 7 | class PersonalNoticeGetResponse(pydantic.BaseModel): 8 | has_notice: bool 9 | notice_id: int 10 | type: int 11 | title: str 12 | contents: str 13 | 14 | 15 | @idol.register("personalnotice", "get") 16 | async def personalnotice_get(context: idol.SchoolIdolUserParams) -> PersonalNoticeGetResponse: 17 | # https://github.com/DarkEnergyProcessor/NPPS/blob/v3.1.x/modules/personalnotice/get.php 18 | # TODO 19 | util.stub("personalnotice", "get", context.raw_request_data) 20 | return PersonalNoticeGetResponse(has_notice=False, notice_id=0, type=0, title="", contents="") 21 | -------------------------------------------------------------------------------- /scripts/generate_hashed_serialcode.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import secrets 5 | import sys 6 | 7 | 8 | def main(args: list[str]): 9 | sysrand = secrets.SystemRandom() 10 | for serial_code in args: 11 | salt = sysrand.randbytes(16) 12 | serial_code_bytes = serial_code.encode("utf-8") 13 | digest = hashlib.sha256(salt + serial_code_bytes, usedforsecurity=False) 14 | result = json.dumps({"hash": digest.hexdigest(), "salt": str(base64.urlsafe_b64encode(salt), "utf-8")}) 15 | print(f"{serial_code}: {result}") 16 | 17 | 18 | async def run_script(args: list[str]): 19 | main(args) 20 | 21 | 22 | if __name__ == "__main__": 23 | main(sys.argv[1:]) 24 | -------------------------------------------------------------------------------- /npps4/idoltype.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | import pydantic 4 | 5 | 6 | class Language(str, enum.Enum): 7 | en = "en" 8 | jp = "jp" 9 | 10 | 11 | class PlatformType(enum.IntEnum): 12 | iOS = 1 13 | Android = 2 14 | 15 | 16 | class XMCVerifyMode(enum.IntEnum): 17 | NONE = 0 18 | SHARED = 1 19 | CROSS = 2 20 | 21 | 22 | class ReleaseInfoData(pydantic.BaseModel): 23 | id: int 24 | key: str 25 | 26 | 27 | class ResponseData[_S: pydantic.BaseModel](pydantic.BaseModel): 28 | response_data: _S 29 | release_info: list[ReleaseInfoData] = pydantic.Field(default_factory=list) 30 | status_code: int = 200 31 | 32 | 33 | class ErrorResponse(pydantic.BaseModel): 34 | error_code: int 35 | detail: str | None 36 | -------------------------------------------------------------------------------- /npps4/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_07_07_1728-eaefd67367c8_wal.py: -------------------------------------------------------------------------------- 1 | """wal 2 | 3 | Revision ID: eaefd67367c8 4 | Revises: 1d73d9b010d0 5 | Create Date: 2024-07-07 17:28:38.375308 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "eaefd67367c8" 16 | down_revision: Union[str, None] = "1d73d9b010d0" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | if op.get_bind().engine.name.startswith("sqlite"): 23 | op.execute("PRAGMA journal_mode=WAL;") 24 | 25 | 26 | def downgrade() -> None: 27 | if op.get_bind().engine.name.startswith("sqlite"): 28 | op.execute("PRAGMA journal_mode=DELETE;") 29 | -------------------------------------------------------------------------------- /npps4/script.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | 4 | from . import evloop 5 | from . import script_dummy 6 | from .config import config 7 | 8 | from typing import Protocol 9 | 10 | 11 | class Script(Protocol): 12 | async def run_script(self, args: list[str]): ... 13 | 14 | 15 | def load_script(path: str): 16 | return config.load_module_from_file(path, "npps4_script_run") 17 | 18 | 19 | async def main(): 20 | parser = argparse.ArgumentParser() 21 | parser.add_argument("script", type=load_script, help="Script to run.") 22 | 23 | args, unk_args = parser.parse_known_args() 24 | script: Script = args.script 25 | await script.run_script(unk_args) 26 | 27 | 28 | def entry(): 29 | asyncio.run(main(), loop_factory=evloop.new_event_loop) 30 | 31 | 32 | if __name__ == "__main__": 33 | entry() 34 | -------------------------------------------------------------------------------- /templates/secretbox_detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 14 | 15 | 16 | 17 |

Secretbox #{{ secretbox_id }} - ({{ secretbox_name }})

18 |
19 |

Rates

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% for i in rates %} 30 | 31 | 32 | 33 | 34 | 35 | {% endfor %} 36 | 37 |
RarityWeightPercentage
{{ i[0] }}{{ i[1] }}{{ "%.2f" % (i[2] * 100) }}%
38 | 40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /scripts/delete_user.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import argparse 4 | 5 | import npps4.idol 6 | import npps4.system.user 7 | import npps4.scriptutils.user 8 | 9 | 10 | async def run_script(arg: list[str]): 11 | parser = argparse.ArgumentParser(__file__, formatter_class=argparse.ArgumentDefaultsHelpFormatter) 12 | group = parser.add_mutually_exclusive_group(required=True) 13 | npps4.scriptutils.user.register_args(group) 14 | args = parser.parse_args(arg) 15 | 16 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 17 | target_user = await npps4.scriptutils.user.from_args(context, args) 18 | await npps4.system.user.delete_user(context, target_user.id) 19 | 20 | 21 | if __name__ == "__main__": 22 | import npps4.scriptutils.boot 23 | 24 | npps4.scriptutils.boot.start(run_script) 25 | -------------------------------------------------------------------------------- /npps4/system/item_model.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import const 4 | 5 | from typing import Any 6 | 7 | 8 | class ItemExtraData(pydantic.BaseModel): 9 | model_config = pydantic.ConfigDict(frozen=True) 10 | 11 | 12 | class BaseItem(pydantic.BaseModel): 13 | model_config = pydantic.ConfigDict(frozen=True) 14 | 15 | add_type: const.ADD_TYPE 16 | item_id: int 17 | amount: int = 1 18 | extra_data: dict[str, Any] | None = None 19 | 20 | 21 | class Item(pydantic.BaseModel): 22 | model_config = pydantic.ConfigDict(extra="allow") 23 | 24 | add_type: const.ADD_TYPE 25 | item_id: int 26 | amount: int = 1 27 | item_category_id: int = 0 28 | reward_box_flag: bool = False 29 | comment: str = "" 30 | # rarity: int = 6 # For effort. TODO 31 | 32 | def get_extra_data(self) -> ItemExtraData | None: 33 | return None 34 | -------------------------------------------------------------------------------- /npps4/evloop.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from . import util 5 | 6 | _loop = None 7 | 8 | match sys.platform: 9 | case "win32": 10 | try: 11 | import winloop # type: ignore 12 | 13 | util.log("Using winloop.new_event_loop") 14 | _loop = winloop.new_event_loop 15 | except ImportError: 16 | util.log("Using asyncio.SelectorEventLoop") 17 | _loop = asyncio.SelectorEventLoop 18 | case _: 19 | try: 20 | import uvloop # type: ignore 21 | 22 | util.log("Using uvloop.new_event_loop") 23 | _loop = uvloop.new_event_loop 24 | except ImportError: 25 | pass 26 | 27 | 28 | if _loop is None: 29 | util.log("Using default asyncio.new_event_loop") 30 | _loop = asyncio.new_event_loop 31 | 32 | 33 | def new_event_loop(): 34 | assert _loop is not None 35 | return _loop() 36 | -------------------------------------------------------------------------------- /scripts/cleanup_orphan_user.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | import npps4.db.main 7 | import npps4.system.user 8 | import npps4.scriptutils.user 9 | 10 | 11 | async def run_script(arg: list[str]): 12 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 13 | q = sqlalchemy.select(npps4.db.main.User.id).where( 14 | npps4.db.main.User.key == None, npps4.db.main.User.passwd == None, npps4.db.main.User.transfer_sha1 == None 15 | ) 16 | user_ids = list((await context.db.main.execute(q)).scalars()) 17 | print("Deleting users:", ", ".join(map(str, user_ids))) 18 | for user_id in user_ids: 19 | await npps4.system.user.delete_user(context, user_id) 20 | 21 | 22 | if __name__ == "__main__": 23 | import npps4.scriptutils.boot 24 | 25 | npps4.scriptutils.boot.start(run_script) 26 | -------------------------------------------------------------------------------- /npps4/errhand.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import pickle 4 | 5 | from .config import config 6 | 7 | ERROR_DIR = os.path.join(config.get_data_directory(), "errors") 8 | os.makedirs(ERROR_DIR, exist_ok=True) 9 | 10 | for file in os.scandir(ERROR_DIR): 11 | if file.is_file(): 12 | os.remove(file) 13 | 14 | 15 | def save_error(token: str, tb: list[str]): 16 | key = hashlib.sha1(token.encode("UTF-8"), usedforsecurity=False).hexdigest() 17 | 18 | with open(os.path.join(ERROR_DIR, key + ".pickle"), "wb") as f: 19 | pickle.dump(tb, f) 20 | 21 | 22 | def load_error(token: str) -> list[str] | None: 23 | key = hashlib.sha1(token.encode("UTF-8"), usedforsecurity=False).hexdigest() 24 | path = os.path.join(ERROR_DIR, key + ".pickle") 25 | 26 | if os.path.isfile(path): 27 | with open(path, "rb") as f: 28 | pickle_data = f.read() 29 | os.remove(path) 30 | return pickle.loads(pickle_data) 31 | return None 32 | -------------------------------------------------------------------------------- /scripts/decrypt_serial_code.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import base64 4 | 5 | import npps4.data 6 | import npps4.data.schema 7 | import npps4.util 8 | 9 | 10 | async def run_script(args: list[str]): 11 | input_code = args[0] 12 | server_data = npps4.data.get() 13 | 14 | for serial_code in server_data.serial_codes: 15 | if serial_code.check_serial_code(input_code): 16 | if isinstance(serial_code.serial_code, str): 17 | raise Exception("cannot encrypt action without secure serial code") 18 | 19 | print(input_code) 20 | print() 21 | input_data = serial_code.get_action(input_code).model_dump_json(exclude_defaults=True) 22 | print(input_data) 23 | return 24 | 25 | raise Exception("cannot find such serial code") 26 | 27 | 28 | if __name__ == "__main__": 29 | import npps4.scriptutils.boot 30 | 31 | npps4.scriptutils.boot.start(run_script) 32 | -------------------------------------------------------------------------------- /npps4/serialcode/func.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | 3 | from .. import const 4 | from .. import idol 5 | from ..db import main 6 | from ..system import advanced 7 | from ..system import item_model 8 | from ..system import reward 9 | from ..system import unit 10 | 11 | 12 | async def give_all_supporter_units(context: idol.BasicSchoolIdolContext, user: main.User, /): 13 | q = sqlalchemy.select(unit.unit.Unit.unit_id).where( 14 | unit.unit.Unit.disable_rank_up > 0, unit.unit.Unit.disable_rank_up < 5 15 | ) 16 | result = await context.db.unit.execute(q) 17 | for unit_id in result.scalars(): 18 | item_data = await advanced.deserialize_item_data( 19 | context, item_model.BaseItem(add_type=const.ADD_TYPE.UNIT, item_id=unit_id, amount=100) 20 | ) 21 | await reward.add_item( 22 | context, user, item_data, "追いかける, ショー・ヘーレーション!", "Oikakeru, Snow Halation!" 23 | ) 24 | 25 | return "Given all supporter members (100x quantity each)." 26 | -------------------------------------------------------------------------------- /default_server_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAMTRE13rTjtp4GKv 3 | NVvrUOiVq1hhaloRQg+6A4iShscNz2pVHmKWIwvILQStc7WyIsHA35TfNoNU5rm6 4 | FduJLu5irlxynEKibFOdm1yf9v6YoOlhYtoLQa/tSiJPyuk2o3dWSjrjm6zP04Vz 5 | hVyKKOwcZE9eaaag/qrKR8j+9trzAgMBAAECgYEAk5I8VjhfgTbisruyY4huMuY+ 6 | AleQeZXlFYugqJ9NBSU6tvy5eqwd/PCLqK0xTDQT0Xj/a01uP0zCbtGzH9edYnnK 7 | 8QlbVZEE2dPuQwFyITvxVCJErSRIeqAW6txQwualvtOAq/Pj/QsHItQeuqzzIZvR 8 | 84W9rYA3Qelw39jWiOECQQDmetghQXHUzkIIOP01c2WL63YMduNiZLccOj7FGHyv 9 | j5lp1Z+tZvyCRgZjeg7jqIqAyI1SHhJcO/0FdNB29jWDAkEA2pwE9FVbM9nEfx9p 10 | okMxHZMs6LDjckhy4eb41n66EnMbvaTF3a1wkTcsE2rJuKneVskEp+HYyYqoeN4c 11 | 5tk50QJARDRiNSUqzHDlNY230NN/X3Kkkne0Pm/TiDTsUmM2srVqDtm60RPC8cJL 12 | LbD3KwO7SPUQbRadFFJkQ/MXpbyihQJARi6wqIB+tzbCjs1W7HEF46jMUif33UjF 13 | GSE94h7tPd8WmNu9al20Neqwi8tM16wxZUtD42HuZ0XMsIEeZj53AQJBAIl4+8WP 14 | z0VVd1HNnKZh6cs927VzM8Nze/hA4H29yKw93KXWwrIsegnUW5H+xL1FNBe8sGMn 15 | ReU2OXhCB8BL3qY= 16 | -----END PRIVATE KEY----- 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The zlib/libpng License 2 | ======================= 3 | 4 | Copyright (c) 2024 Dark Energy Processor 5 | 6 | This software is provided 'as-is', without any express or implied warranty. In 7 | no event will the authors be held liable for any damages arising from the use of 8 | this software. 9 | 10 | Permission is granted to anyone to use this software for any purpose, including 11 | commercial applications, and to alter it and redistribute it freely, subject to 12 | the following restrictions: 13 | 14 | 1. The origin of this software must not be misrepresented; you must not claim 15 | that you wrote the original software. If you use this software in a product, 16 | an acknowledgment in the product documentation would be appreciated but is 17 | not required. 18 | 19 | 2. Altered source versions must be plainly marked as such, and must not be 20 | misrepresented as being the original software. 21 | 22 | 3. This notice may not be removed or altered from any source distribution. 23 | -------------------------------------------------------------------------------- /npps4/game/challenge.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from .. import util 5 | 6 | 7 | class ChallengeInfoDataBaseInfo(pydantic.BaseModel): 8 | event_id: int 9 | max_round: int 10 | event_point: int 11 | asset_bgm_id: int 12 | total_event_point: int 13 | 14 | 15 | class ChallengeInfoDataStatus(pydantic.BaseModel): 16 | should_retire: bool 17 | should_proceed: bool 18 | should_finalize: bool 19 | 20 | 21 | class ChallengeInfoData(pydantic.BaseModel): 22 | base_info: ChallengeInfoDataBaseInfo 23 | challenge_status: ChallengeInfoDataStatus 24 | 25 | 26 | class ChallengeInfoResponse(pydantic.RootModel): 27 | root: ChallengeInfoData | idol.core.DummyModel 28 | 29 | 30 | @idol.register("challenge", "challengeInfo") 31 | async def challenge_challengeinfo(context: idol.SchoolIdolUserParams) -> ChallengeInfoResponse: 32 | # TODO 33 | util.stub("challenge", "challengeInfo", context.raw_request_data) 34 | return ChallengeInfoResponse(idol.core.DummyModel()) 35 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2025_08_30_1237-deea55d98599_fixes_table.py: -------------------------------------------------------------------------------- 1 | """Fixes table 2 | 3 | Revision ID: deea55d98599 4 | Revises: 6a6186e47091 5 | Create Date: 2025-08-30 12:37:34.686999 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "deea55d98599" 17 | down_revision: Union[str, None] = "6a6186e47091" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | "migration_fixes", sa.Column("revision", sa.Text(), nullable=False), sa.PrimaryKeyConstraint("revision") 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade() -> None: 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table("migration_fixes") 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /npps4/webui/static/unlock_backgrounds.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |

Unlocked backgrounds

11 | 12 | {% for bg in unlocked_backgrounds %} 13 | 14 | {% endfor %} 15 |
{{ bg.description_en.replace('\\n','\n') }}
16 | 17 |

Locked backgrounds

18 |
19 | 20 | 21 | 22 | {% for bg in locked_backgrounds %} 23 | 24 | 25 | 26 | 27 | {% endfor %} 28 |
{{ bg.description_en.replace('\\n','\n') }}
29 |
30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /npps4/other.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import fastapi 4 | 5 | from .app import app 6 | from .config import config 7 | 8 | 9 | @app.core.get( 10 | "/server_info/{filehash}", 11 | responses={200: {"content": {"application/zip": {}}}}, 12 | response_class=fastapi.responses.Response, 13 | ) 14 | async def server_info(filehash: str): 15 | """ 16 | Get zip archive containing new server_info.json for this private server. 17 | """ 18 | sanitized_hash = "".join(filter(str.isalnum, filehash)) 19 | zipfile = config.get_data_directory() + "/server_info/" + sanitized_hash + ".zip" 20 | 21 | if os.path.isfile(zipfile): 22 | with open(zipfile, "rb") as f: 23 | return fastapi.responses.Response( 24 | f.read(), 25 | 200, 26 | { 27 | "Content-Disposition": f'attachment; filename="{sanitized_hash}.zip"', 28 | }, 29 | "application/zip", 30 | ) 31 | else: 32 | raise fastapi.exceptions.HTTPException(404, "Not found") 33 | -------------------------------------------------------------------------------- /npps4.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | import importlib.util 3 | 4 | a = Analysis( 5 | ['pyinstaller_bootstrap.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[ 9 | ("alembic.ini", "."), 10 | ("npps4/alembic", "npps4/alembic"), 11 | ], 12 | hiddenimports=["aiosqlite", "psycopg", "winloop._noop"], 13 | hookspath=[], 14 | hooksconfig={}, 15 | runtime_hooks=[], 16 | excludes=[], 17 | noarchive=False, 18 | optimize=0, 19 | ) 20 | pyz = PYZ(a.pure) 21 | 22 | exe = EXE( 23 | pyz, 24 | a.scripts, 25 | [], 26 | exclude_binaries=True, 27 | name='npps4', 28 | debug=False, 29 | bootloader_ignore_signals=False, 30 | strip=False, 31 | upx=True, 32 | console=True, 33 | disable_windowed_traceback=False, 34 | argv_emulation=False, 35 | target_arch=None, 36 | codesign_identity=None, 37 | entitlements_file=None, 38 | ) 39 | coll = COLLECT( 40 | exe, 41 | a.binaries, 42 | a.datas, 43 | strip=False, 44 | upx=True, 45 | upx_exclude=[], 46 | name='npps4', 47 | ) 48 | -------------------------------------------------------------------------------- /npps4/game/multiunit.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from .. import util 3 | 4 | import pydantic 5 | 6 | 7 | class MultiUnitSccenarioChapterList(pydantic.BaseModel): 8 | multi_unit_scenario_id: int 9 | status: int 10 | chapter: int 11 | 12 | 13 | class MultiUnitSccenarioInfo(pydantic.BaseModel): 14 | multi_unit_id: int 15 | status: int 16 | open_date: str 17 | chapter_list: list[MultiUnitSccenarioChapterList] 18 | multi_unit_scenario_btn_asset: str # "assets/image/scenario/banner/aqoth_21.png" 19 | multi_unit_scenario_se_btn_asset: str # "assets/image/scenario/banner/aqoth_21se.png" 20 | 21 | 22 | class MultiUnitScenarioResponse(pydantic.BaseModel): 23 | multi_unit_scenario_status_list: list[MultiUnitSccenarioInfo] 24 | 25 | 26 | @idol.register("multiunit", "multiunitscenarioStatus") 27 | async def multiunit_multiunitscenariostatus(context: idol.SchoolIdolUserParams) -> MultiUnitScenarioResponse: 28 | # TODO 29 | util.stub("multiunit", "multiunitscenarioStatus", context.raw_request_data) 30 | return MultiUnitScenarioResponse(multi_unit_scenario_status_list=[]) 31 | -------------------------------------------------------------------------------- /scripts/generate_passcode.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import npps4.idol 4 | import npps4.system.handover 5 | import npps4.scriptutils.user 6 | 7 | 8 | async def run_script(arg: list[str]): 9 | parser = argparse.ArgumentParser(__file__) 10 | group = parser.add_mutually_exclusive_group(required=True) 11 | npps4.scriptutils.user.register_args(group) 12 | parser.add_argument("passcode", nargs="?", default=None) 13 | args = parser.parse_args(arg) 14 | 15 | transfer_code = args.passcode or npps4.system.handover.generate_transfer_code() 16 | 17 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 18 | target_user = await npps4.scriptutils.user.from_args(context, args) 19 | target_user.transfer_sha1 = npps4.system.handover.generate_passcode_sha1(target_user.invite_code, transfer_code) 20 | 21 | print("Transfer ID:", target_user.invite_code) 22 | print("Transfer Passcode:", transfer_code) 23 | 24 | 25 | if __name__ == "__main__": 26 | import npps4.scriptutils.boot 27 | 28 | npps4.scriptutils.boot.start(run_script) 29 | -------------------------------------------------------------------------------- /npps4/game/eventscenario.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from .. import util 3 | 4 | import pydantic 5 | 6 | 7 | class EventScenarioChapterList(pydantic.BaseModel): 8 | event_scenario_id: int 9 | amount: int 10 | status: int 11 | chapter: int 12 | item_id: int 13 | cost_type: int 14 | is_reward: bool 15 | open_flash_flag: int 16 | 17 | 18 | class EventScenarioInfo(pydantic.BaseModel): 19 | event_id: int 20 | open_date: str 21 | chapter_list: list[EventScenarioChapterList] 22 | event_scenario_btn_asset: str # "assets/image/ui/eventscenario/156_se_ba_t.png" 23 | event_scenario_se_btn_asset: str # "assets/image/ui/eventscenario/156_se_ba_tse.png" 24 | 25 | 26 | class EventScenarioStatusResponse(pydantic.BaseModel): 27 | event_scenario_list: list[EventScenarioInfo] 28 | 29 | 30 | @idol.register("eventscenario", "status") 31 | async def eventscenario_status(context: idol.SchoolIdolUserParams) -> EventScenarioStatusResponse: 32 | # TODO 33 | util.stub("eventscenario", "status", context.raw_request_data) 34 | return EventScenarioStatusResponse(event_scenario_list=[]) 35 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_04_15_0040-a005ea560d9c_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: a005ea560d9c 4 | Revises: 397436128a36 5 | Create Date: 2024-04-15 00:40:29.741599 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "a005ea560d9c" 17 | down_revision: Union[str, None] = "397436128a36" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | with op.batch_alter_table("user", schema=None) as batch_op: 25 | batch_op.add_column(sa.Column("locked", sa.Boolean(), nullable=False, server_default=sa.sql.false())) 26 | 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade() -> None: 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | with op.batch_alter_table("user", schema=None) as batch_op: 33 | batch_op.drop_column("locked") 34 | 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | # check=skip=SecretsUsedInArgOrEnv 3 | 4 | ARG PYTHON_VERSION=3.14 5 | FROM python:${PYTHON_VERSION}-slim-trixie 6 | 7 | WORKDIR /NPPS4 8 | RUN mkdir data 9 | VOLUME data 10 | 11 | COPY ./requirements.txt requirements.txt 12 | COPY ./requirements-perf.txt requirements-perf.txt 13 | COPY ./requirements-docker.txt requirements-docker.txt 14 | RUN python -m pip install --root-user-action=ignore --no-cache-dir -U pip 15 | RUN pip install --root-user-action=ignore --no-cache-dir -r requirements.txt -r requirements-perf.txt -r requirements-docker.txt 16 | 17 | # Least modified file first 18 | ARG PRIVATE_KEY_FILE=default_server_key.pem 19 | COPY ${PRIVATE_KEY_FILE} default_server_key.pem 20 | COPY ./beatmaps beatmaps 21 | COPY ./external external 22 | COPY ./alembic.ini alembic.ini 23 | COPY ./LICENSE.md LICENSE.md 24 | COPY ./static static 25 | COPY ./templates templates 26 | COPY ./util util 27 | 28 | COPY ./npps4 npps4 29 | COPY ./scripts scripts 30 | COPY ./main.py main.py 31 | COPY ./config.sample.toml config.sample.toml 32 | 33 | EXPOSE 51376/tcp 34 | 35 | ENTRYPOINT ["python", "scripts/bootstrap_docker.py"] 36 | -------------------------------------------------------------------------------- /npps4/game/tos.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from ..system import common 3 | from ..system import tos 4 | from ..system import user 5 | 6 | import pydantic 7 | 8 | 9 | class TOSCheckResponse(common.TimestampMixin): 10 | tos_id: int 11 | tos_type: int 12 | is_agreed: bool 13 | 14 | 15 | class TOSAgreeRequest(pydantic.BaseModel): 16 | tos_id: int 17 | 18 | 19 | @idol.register("tos", "tosCheck") 20 | async def tos_toscheck(context: idol.SchoolIdolUserParams) -> TOSCheckResponse: 21 | current_user = await user.get_current(context) 22 | agree = await tos.is_agreed(context, current_user, 1) 23 | return TOSCheckResponse(tos_id=1, tos_type=1, is_agreed=agree) 24 | 25 | 26 | @idol.register("tos", "tosAgree", batchable=False) 27 | async def tos_tosagree(context: idol.SchoolIdolUserParams, request: TOSAgreeRequest) -> None: 28 | if request.tos_id == 1: 29 | current_user = await user.get_current(context) 30 | if not await tos.is_agreed(context, current_user, 1): 31 | await tos.agree(context, current_user, 1) 32 | return 33 | 34 | raise idol.error.IdolError(detail="Invalid ToS agreement") 35 | -------------------------------------------------------------------------------- /npps4/system/tutorial.py: -------------------------------------------------------------------------------- 1 | from . import unit 2 | from . import user 3 | from .. import idol 4 | from ..db import main 5 | from ..db import game_mater 6 | 7 | 8 | async def phase1(context: idol.BasicSchoolIdolContext, u: main.User): 9 | u.tutorial_state = 1 10 | 11 | 12 | async def phase2(context: idol.BasicSchoolIdolContext, u: main.User): 13 | u.tutorial_state = 2 14 | 15 | 16 | async def phase3(context: idol.BasicSchoolIdolContext, u: main.User): 17 | # G +36400 + 600 18 | u.game_coin = u.game_coin + game_mater.GAME_SETTING.initial_game_coin + 600 19 | # Friend Points +5 20 | u.social_point = u.social_point + game_mater.GAME_SETTING.live_social_point_for_others 21 | # Add EXP 22 | await user.add_exp(context, u, 11) 23 | # Reine Saeki 24 | await unit.add_unit_simple(context, u, 13, True) 25 | # Akemi Kikuchi 26 | await unit.add_unit_simple(context, u, 9, True) 27 | # Bond calculation 28 | await unit.add_love_by_deck(context, u, u.active_deck_index, 34) 29 | u.tutorial_state = 3 30 | 31 | 32 | async def finalize(context: idol.BasicSchoolIdolContext, u: main.User): 33 | u.tutorial_state = -1 34 | -------------------------------------------------------------------------------- /docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | # Example docker-compose.yml to run NPPS4 with PostgreSQL 2 | 3 | services: 4 | postgresql: 5 | image: postgres:17-alpine 6 | environment: 7 | POSTGRES_USER: npps4 8 | POSTGRES_PASSWORD: npps4 9 | PGUSER: postgres 10 | volumes: 11 | - ./postgres_data:/var/lib/postgresql/data 12 | healthcheck: 13 | test: ["CMD-SHELL", "pg_isready", "-d", "npps4"] 14 | interval: 30s 15 | timeout: 60s 16 | retries: 5 17 | start_period: 80s 18 | npps4: 19 | build: 20 | context: . 21 | environment: 22 | NPPS4_CONFIG_DATABASE_URL: postgresql+psycopg://npps4:npps4@postgresql/npps4 # maps to database URL in config.toml 23 | NPPS4_CONFIG_DOWNLOAD_BACKEND: n4dlapi # Use NPPS4 DLAPI 24 | NPPS4_CONFIG_DOWNLOAD_N4DLAPI_SERVER: http://example.com/npps4-dlapi # Uses this server to provide game files. 25 | # ... and so on 26 | NPPS4_WORKER: 1 27 | ports: 28 | - 51376:51376 29 | volumes: 30 | - ./npps4_data:/NPPS4/data 31 | restart: unless-stopped 32 | depends_on: 33 | postgresql: 34 | condition: service_healthy 35 | -------------------------------------------------------------------------------- /static/css1.3/regulation.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | /* CSS Document */ 3 | 4 | /************************ 5 | regulation.css 6 | written:Yosuke Takada 7 | date:2013/07/23 8 | version:1.0 9 | *************************/ 10 | 11 | /* ========================================================== 12 | 13 | 利用規約 14 | 15 | ========================================================== */ 16 | 17 | .title ul{ 18 | width:95%; 19 | display:block; 20 | height:78px; 21 | line-height:78px; 22 | color:#FFF; 23 | } 24 | 25 | .title ul li{ 26 | background:url('../img/help/tab_off.png'); 27 | display:inline; 28 | height:79px; 29 | width:25%; 30 | float: left; 31 | background-size:100% auto; 32 | text-align:center; 33 | } 34 | 35 | /* タブ選択のクラス */ 36 | .active{ 37 | background:url('../img/help/tab_on.png') !important; 38 | background-position:0px 1px; 39 | } 40 | 41 | #wrapper_regu{ 42 | width:960px; 43 | position:relative; 44 | } 45 | 46 | /*コンテンツ部*/ 47 | .content_regu{ 48 | width:95%; 49 | background:url('../img/help/bg02.png') repeat-y; 50 | background-size:100% auto; 51 | padding:20px 0px; 52 | } 53 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2025_05_03_1834-d701309ff89e_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: d701309ff89e 4 | Revises: 475147102361 5 | Create Date: 2025-05-03 18:34:45.211213 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "d701309ff89e" 17 | down_revision: Union[str, None] = "475147102361" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | with op.batch_alter_table("album", schema=None) as batch_op: 25 | batch_op.create_index(batch_op.f("ix_album_favorite_point"), ["favorite_point"], unique=False) 26 | 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade() -> None: 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | with op.batch_alter_table("album", schema=None) as batch_op: 33 | batch_op.drop_index(batch_op.f("ix_album_favorite_point")) 34 | 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /templates/helper_achievement_info.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 | 14 | 15 |

Achievement #{{ achievement.id }}

16 |
17 |

Needs

18 | 23 |
24 |
25 |

Achievement Info

26 | 27 | 28 | 29 | 30 | 31 | {% for i in achievement.params %} 32 | 33 | 34 | 35 | 36 | {% endfor %} 37 |
ParameterValue
{{ i[0] }}{{ i[1] | safe }}
38 |
39 |
40 |

Reward Data

41 | 42 |
43 |
44 |

Opens

45 | 50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /scripts/unlock_all_background.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | import npps4.system.background 7 | import npps4.db.main 8 | import npps4.db.item 9 | import npps4.scriptutils.user 10 | 11 | 12 | async def run_script(arg: list[str]): 13 | parser = argparse.ArgumentParser(__file__) 14 | group = parser.add_mutually_exclusive_group(required=True) 15 | npps4.scriptutils.user.register_args(group) 16 | args = parser.parse_args(arg) 17 | 18 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 19 | target_user = await npps4.scriptutils.user.from_args(context, args) 20 | q = sqlalchemy.select(npps4.db.item.Background) 21 | result = await context.db.item.execute(q) 22 | for game_bg in result.scalars(): 23 | if not await npps4.system.background.has_background(context, target_user, game_bg.background_id): 24 | await npps4.system.background.unlock_background(context, target_user, game_bg.background_id) 25 | 26 | 27 | if __name__ == "__main__": 28 | import npps4.scriptutils.boot 29 | 30 | npps4.scriptutils.boot.start(run_script) 31 | -------------------------------------------------------------------------------- /scripts/migrations/4_update_achievement_reset_type.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | import npps4.db.main 7 | import npps4.db.achievement 8 | import npps4.system.achievement 9 | 10 | revision = "4_update_achievement_reset_type" 11 | prev_revision = "3_update_incentive_unit_extra_data" 12 | 13 | 14 | async def main(context: npps4.idol.BasicSchoolIdolContext): 15 | q = sqlalchemy.select(npps4.db.achievement.Achievement.reset_type).group_by( 16 | npps4.db.achievement.Achievement.reset_type 17 | ) 18 | reset_types = list((await context.db.achievement.execute(q)).scalars()) 19 | 20 | for reset_type in reset_types: 21 | q = sqlalchemy.select(npps4.db.achievement.Achievement.achievement_id).where( 22 | npps4.db.achievement.Achievement.reset_type == reset_type 23 | ) 24 | ach_ids = list((await context.db.achievement.execute(q)).scalars()) 25 | q = ( 26 | sqlalchemy.update(npps4.db.main.Achievement) 27 | .values(reset_type=reset_type) 28 | .where(npps4.db.main.Achievement.achievement_id.in_(ach_ids)) 29 | ) 30 | await context.db.main.execute(q) 31 | -------------------------------------------------------------------------------- /npps4/game/marathon.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from .. import util 5 | 6 | 7 | class MarathonInfoEventScenarioStatus(pydantic.BaseModel): 8 | title: str 9 | chapter: int 10 | is_reward: bool 11 | open_date: str 12 | title_font: str | None = None 13 | asset_bgm_id: int 14 | status_origin: int 15 | title_call_asset: str | None = None 16 | event_scenario_id: int 17 | open_total_event_point: int 18 | 19 | 20 | class MarathonInfoEventScenario(pydantic.BaseModel): 21 | progress: int 22 | event_scenario_status: list[MarathonInfoEventScenarioStatus] 23 | 24 | 25 | class MarathonInfoData(pydantic.BaseModel): 26 | event_id: int 27 | point_name: str 28 | event_point: int 29 | event_scenario: MarathonInfoEventScenario 30 | point_icon_asset: str 31 | total_event_point: int 32 | 33 | 34 | class MarathonInfoResponse(pydantic.RootModel): 35 | root: list[MarathonInfoData] 36 | 37 | 38 | @idol.register("marathon", "marathonInfo") 39 | async def marathon_marathoninfo(context: idol.SchoolIdolUserParams) -> MarathonInfoResponse: 40 | # TODO 41 | util.stub("marathon", "marathonInfo", context.raw_request_data) 42 | return MarathonInfoResponse([]) 43 | -------------------------------------------------------------------------------- /npps4/db/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | 4 | from . import common 5 | from .. import release_key 6 | from .. import util 7 | 8 | import sqlalchemy.ext.asyncio 9 | 10 | from typing import Any 11 | 12 | 13 | async def get_decrypted_row[_T: common.MaybeEncrypted]( 14 | session: sqlalchemy.ext.asyncio.AsyncSession, cls: type[_T], id: int 15 | ) -> _T | None: 16 | obj = await session.get(cls, id) 17 | return decrypt_row(session, obj) 18 | 19 | 20 | def decrypt_row[_T: common.MaybeEncrypted](session: sqlalchemy.ext.asyncio.AsyncSession, obj: _T | None) -> _T | None: 21 | if obj is not None and obj._encryption_release_id is not None: 22 | key = release_key.get(obj._encryption_release_id) 23 | 24 | if key is None: 25 | return None 26 | else: 27 | session.expunge(obj) 28 | 29 | # Decrypt row 30 | jsondata = util.decrypt_aes(base64.b64decode(key), base64.b64decode(obj.release_tag or "")) 31 | data: dict[str, Any] = json.loads(jsondata) 32 | for k, v in data.items(): 33 | setattr(obj, k, v) 34 | 35 | obj.release_tag = None 36 | obj._encryption_release_id = None 37 | 38 | return obj 39 | -------------------------------------------------------------------------------- /npps4/__init__.py: -------------------------------------------------------------------------------- 1 | # This license applies to all source files in this directory and subdirectories. 2 | # 3 | # Copyright (c) 2024 Dark Energy Processor 4 | # 5 | # This software is provided 'as-is', without any express or implied warranty. In 6 | # no event will the authors be held liable for any damages arising from the use of 7 | # this software. 8 | # 9 | # Permission is granted to anyone to use this software for any purpose, including 10 | # commercial applications, and to alter it and redistribute it freely, subject to 11 | # the following restrictions: 12 | # 13 | # 1. The origin of this software must not be misrepresented; you must not claim 14 | # that you wrote the original software. If you use this software in a product, 15 | # an acknowledgment in the product documentation would be appreciated but is 16 | # not required. 17 | # 2. Altered source versions must be plainly marked as such, and must not be 18 | # misrepresented as being the original software. 19 | # 3. This notice may not be removed or altered from any source distribution. 20 | 21 | import asyncio 22 | import os 23 | import sys 24 | 25 | if os.name == "nt" and sys.version_info < (3, 14): 26 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 27 | -------------------------------------------------------------------------------- /npps4/system/live_model.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from . import item_model 4 | from .. import const 5 | 6 | from typing import Literal 7 | 8 | 9 | class LiveNote(pydantic.BaseModel): 10 | timing_sec: float 11 | notes_attribute: int 12 | notes_level: int 13 | effect: int 14 | effect_value: float 15 | position: int 16 | speed: float = 1.0 # Higher = slower. Lower = faster. 17 | vanish: Literal[0, 1, 2] = 0 # 0 = Normal. 1 = Hidden. 2 = Sudden. 18 | 19 | 20 | class LiveInfo(pydantic.BaseModel): 21 | live_difficulty_id: int 22 | is_random: bool = False 23 | ac_flag: int = 0 24 | swing_flag: int = 0 25 | 26 | 27 | class LiveInfoWithNotes(LiveInfo): 28 | notes_list: list[LiveNote] 29 | 30 | 31 | class LiveStatus(pydantic.BaseModel): 32 | live_difficulty_id: int 33 | status: int 34 | hi_score: int 35 | hi_combo_count: int 36 | clear_cnt: int 37 | achieved_goal_id_list: list[int] 38 | 39 | 40 | class LiveItem(item_model.Item): 41 | add_type: const.ADD_TYPE = const.ADD_TYPE.LIVE 42 | additional_normal_live_status_list: list[LiveStatus] = pydantic.Field(default_factory=list) 43 | additional_training_live_status_list: list[LiveStatus] = pydantic.Field(default_factory=list) # TODO: Maybe 44 | -------------------------------------------------------------------------------- /npps4/sif2export.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | import pydantic 3 | 4 | from . import idol 5 | from . import idoltype 6 | from .app import app 7 | from .system import album 8 | from .system import award 9 | from .system import handover 10 | 11 | from typing import Annotated 12 | 13 | 14 | class EWExportUnit(pydantic.BaseModel): 15 | id: int 16 | idolized: bool 17 | signed: bool 18 | 19 | 20 | class EWExportResponse(pydantic.BaseModel): 21 | rank: int 22 | units: list[EWExportUnit] 23 | titles: list[int] 24 | 25 | 26 | @app.core.get("/ewexport") 27 | async def export_to_ew(sha1: Annotated[str, fastapi.Query()]): 28 | async with idol.BasicSchoolIdolContext(idoltype.Language.en) as context: 29 | target_user = await handover.find_user_by_passcode(context, sha1) 30 | if target_user is None: 31 | raise fastapi.HTTPException(404, "not found") 32 | 33 | all_album = await album.all(context, target_user) 34 | all_awards = await award.get_awards(context, target_user) 35 | 36 | return EWExportResponse( 37 | rank=target_user.level, 38 | units=[EWExportUnit(id=u.unit_id, idolized=u.rank_max_flag, signed=u.sign_flag) for u in all_album], 39 | titles=[t.award_id for t in all_awards], 40 | ) 41 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_04_17_1553-0a5db1c03ed0_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 0a5db1c03ed0 4 | Revises: a005ea560d9c 5 | Create Date: 2024-04-17 15:53:31.649498 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "0a5db1c03ed0" 17 | down_revision: Union[str, None] = "a005ea560d9c" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | with op.batch_alter_table("user", schema=None) as batch_op: 25 | batch_op.add_column(sa.Column("transfer_sha1", sa.Text(), nullable=True, server_default=sa.null())) 26 | batch_op.create_index(batch_op.f("ix_user_transfer_sha1"), ["transfer_sha1"], unique=False) 27 | 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | with op.batch_alter_table("user", schema=None) as batch_op: 34 | batch_op.drop_index(batch_op.f("ix_user_transfer_sha1")) 35 | batch_op.drop_column("transfer_sha1") 36 | 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /static/js/common/tab.js: -------------------------------------------------------------------------------- 1 | function Tab(tabs, options) { 2 | this.map = {}; 3 | 4 | Array.prototype.forEach.call(tabs, function (tab) { 5 | var targetId = Tab._getTargetId(tab); 6 | var target = document.getElementById(targetId); 7 | 8 | this.map[targetId] = {tab: tab, target: target}; 9 | 10 | Button.initialize(tab, function () { 11 | if (tab.classList.contains('disabled')) return; 12 | this.show(targetId); 13 | }.bind(this)); 14 | }, this); 15 | 16 | this.options = options || {}; 17 | 18 | var initial = this.map[this.options.initialTargetId] 19 | ? this.options.initialTargetId 20 | : Tab._getTargetId(tabs[0]); 21 | 22 | this.show(initial); 23 | } 24 | 25 | Tab._getTargetId = function (tab) { 26 | return tab.getAttribute('data-target-id'); 27 | }; 28 | 29 | Tab.prototype.show = function (targetId) { 30 | Object.keys(this.map).forEach(function (id) { 31 | var entry = this.map[id]; 32 | 33 | if (id === targetId) { 34 | entry.tab.classList.add('active'); 35 | entry.target.style.display = 'block'; 36 | } else { 37 | entry.tab.classList.remove('active'); 38 | entry.target.style.display = 'none'; 39 | } 40 | }, this); 41 | 42 | if (typeof this.options.onShow === 'function') { 43 | this.options.onShow(targetId); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /npps4/system/handover.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import itertools 3 | 4 | import sqlalchemy 5 | 6 | from .. import idol 7 | from .. import util 8 | from ..db import main 9 | 10 | VALID_CHARACTERS = "".join(map(chr, itertools.chain(range(ord("A"), ord("Z") + 1), range(ord("0"), ord("9") + 1)))) 11 | 12 | 13 | def _a_sha1(t): 14 | return hashlib.sha1(t.encode("utf-8")).hexdigest().upper() 15 | 16 | 17 | def generate_passcode_sha1(transfer_id: str, transfer_code: str): 18 | return _a_sha1(_a_sha1(transfer_id) + transfer_code) 19 | 20 | 21 | def generate_transfer_code(): 22 | return "".join(util.SYSRAND.choices(VALID_CHARACTERS, k=12)) 23 | 24 | 25 | def has_passcode_issued(user: main.User): 26 | return user.transfer_sha1 is not None 27 | 28 | 29 | async def find_user_by_passcode(context: idol.BasicSchoolIdolContext, /, sha1_code: str): 30 | q = sqlalchemy.select(main.User).where(main.User.transfer_sha1 == sha1_code) 31 | result = await context.db.main.execute(q) 32 | return result.scalar() 33 | 34 | 35 | def swap_credentials(source_user: main.User, target_user: main.User): 36 | # These handles if the source and target is same. 37 | key, passwd = source_user.key, source_user.passwd 38 | source_user.key, source_user.passwd = None, None 39 | target_user.key, target_user.passwd = key, passwd 40 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_08_17_1431-3c0c34f09192_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 3c0c34f09192 4 | Revises: 3f827cd6aef5 5 | Create Date: 2024-08-17 14:31:59.537269 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "3c0c34f09192" 17 | down_revision: Union[str, None] = "3f827cd6aef5" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | with op.batch_alter_table("session", schema=None) as batch_op: 25 | batch_op.create_index(batch_op.f("ix_session_last_accessed"), ["last_accessed"], unique=False) 26 | batch_op.create_index(batch_op.f("ix_session_user_id"), ["user_id"], unique=False) 27 | 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | with op.batch_alter_table("session", schema=None) as batch_op: 34 | batch_op.drop_index(batch_op.f("ix_session_user_id")) 35 | batch_op.drop_index(batch_op.f("ix_session_last_accessed")) 36 | 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /util/sis_db_map.txt: -------------------------------------------------------------------------------- 1 | SELECT 2 | unit_removable_skill_id as id, 3 | coalesce(name_en, name) as name, 4 | skill_type, level, size, 5 | coalesce(description_en, description) as description, 6 | effect_range, effect_type, effect_value, 7 | fixed_value_flag as flat, 8 | target_reference_type as tgt_ref, 9 | target_type as tgt, 10 | trigger_reference_type as tgr_ref, 11 | trigger_type as tgr, 12 | selling_price 13 | FROM 'unit_removable_skill_m' 14 | 15 | SIS calc: 16 | 17 | * fixed_value_flag 18 | 0 = Multiply 19 | 1 = Add 20 | 21 | * effect_type 22 | 1 = Smile 23 | 2 = Pure 24 | 3 = Cool 25 | 11 = Scorer boost 26 | 12 = Healer score boost 27 | 13 = Perfect timing score boost (Smile) 28 | 14 = Perfect timing score boost (Pure) 29 | 15 = Perfect timing score boost (Cool) 30 | 31 | * effect_range 32 | 1 = Current unit 33 | 2 = All units 34 | 35 | * target_reference_type 36 | 0 = None (any member) 37 | 1 = Member year 38 | * target_type 39 | 1 = 1st Year 40 | 2 = 2nd Year 41 | 3 = 3rd Year 42 | 2 = Single member (target_type = unit_type_id in unit_type_m) 43 | 3 = Member skill 44 | * target_type 45 | 1 = Smile 46 | 2 = Pure 47 | 3 = Cool 48 | 49 | * trigger_reference_type 50 | 0 = None (any?) 51 | 4 = Require full idol in team 52 | * trigger_type 53 | 4 = Myus 54 | 5 = Aqua 55 | -------------------------------------------------------------------------------- /npps4/game/__init__.py: -------------------------------------------------------------------------------- 1 | from ..config import config 2 | 3 | if not config.is_script_mode(): 4 | from . import achievement 5 | from . import ad 6 | from . import album 7 | from . import announce 8 | from . import award 9 | from . import background 10 | from . import banner 11 | from . import challenge 12 | from . import common 13 | from . import costume 14 | from . import download 15 | from . import event 16 | from . import eventscenario 17 | from . import exchange 18 | from . import friend 19 | from . import gdpr 20 | from . import handover 21 | from . import item 22 | from . import lbonus 23 | from . import live 24 | from . import liveicon 25 | from . import livese 26 | from . import login 27 | from . import marathon 28 | from . import multiunit 29 | from . import museum 30 | from . import navigation 31 | from . import notice 32 | from . import payment 33 | from . import personalnotice 34 | from . import profile 35 | from . import ranking 36 | from . import reward 37 | from . import scenario 38 | from . import secretbox 39 | from . import stamp 40 | from . import subscenario 41 | from . import tos 42 | from . import tutorial 43 | from . import unit 44 | from . import user 45 | from .. import sif2export # HACK 46 | -------------------------------------------------------------------------------- /npps4/webview/secretbox.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import dataclasses 3 | import html 4 | import json 5 | 6 | import fastapi 7 | import pydantic 8 | import sqlalchemy 9 | 10 | from .. import idol 11 | from .. import util 12 | from ..app import app 13 | from ..config import config 14 | from ..db import achievement 15 | from ..system import achievement as achievement_system 16 | from ..system import handover 17 | from ..system import lila 18 | from ..system import secretbox 19 | 20 | from typing import Annotated 21 | 22 | 23 | @app.webview.get("/secretbox/detail") 24 | async def secretbox_detail(request: fastapi.Request, secretbox_id: Annotated[int, fastapi.Query()]): 25 | async with idol.create_basic_context(request) as context: 26 | secretbox_data = secretbox.get_secretbox_data(secretbox_id) 27 | rate_count = sum(secretbox_data.rarity_rates) 28 | rate_data = [ 29 | (v[0], v[1], v[1] / rate_count) for v in zip(secretbox_data.rarity_names, secretbox_data.rarity_rates) 30 | ] 31 | return app.templates.TemplateResponse( 32 | request, 33 | "secretbox_detail.html", 34 | { 35 | "secretbox_id": secretbox_id, 36 | "secretbox_name": context.get_text(secretbox_data.name, secretbox_data.name_en), 37 | "rates": rate_data, 38 | }, 39 | ) 40 | -------------------------------------------------------------------------------- /make_server_key.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import Cryptodome.PublicKey.RSA 5 | 6 | 7 | def ask_confirm(prompt: str): 8 | while True: 9 | confirm = input(f"{prompt} [y/n] ") 10 | confirm_lower = confirm.lower() 11 | if confirm_lower == "y": 12 | return True 13 | elif confirm_lower == "n": 14 | return False 15 | else: 16 | print("Please type `y' or `n'!") 17 | 18 | 19 | def print_public_key(key: Cryptodome.PublicKey.RSA.RsaKey): 20 | print(str(key.public_key().export_key("PEM"), "UTF-8")) 21 | 22 | 23 | if os.path.exists("server_key.pem"): 24 | with open("server_key.pem", "r", encoding="UTF-8") as f: 25 | key = Cryptodome.PublicKey.RSA.import_key(f.read()) 26 | print_public_key(key) 27 | if "-p" in sys.argv: 28 | raise SystemExit(0) 29 | if not ask_confirm("WARNING: server_key.pem already exist. Overwrite?"): 30 | print("Key not overwritten") 31 | raise SystemExit(0) 32 | # Ask user again to make sure 33 | if not ask_confirm("WARNING WARNING: YOU HAVE BEEN WARNED! ARE YOU SURE? THE KEY WILL BE OVERWRITTEN!!!"): 34 | print("Key not overwritten") 35 | raise SystemExit(0) 36 | 37 | key = Cryptodome.PublicKey.RSA.generate(1024) 38 | with open("server_key.pem", "wb") as f: 39 | f.write(key.export_key("PEM")) 40 | 41 | print_public_key(key) 42 | -------------------------------------------------------------------------------- /npps4/webview/__init__.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import fastapi.responses 4 | import fastapi.templating 5 | 6 | from ..config import config 7 | 8 | if not config.is_script_mode(): 9 | from . import announce 10 | from . import helper 11 | from . import secretbox 12 | from . import serialcode 13 | from . import static 14 | from . import tos 15 | 16 | from .. import errhand 17 | from ..app import app 18 | 19 | 20 | @app.core.get("/resources/maintenace/maintenance.php", response_class=fastapi.responses.HTMLResponse) 21 | @app.core.get("/resources/maintenance/maintenance.php", response_class=fastapi.responses.HTMLResponse) 22 | async def maintenance_page(request: fastapi.Request): 23 | if config.is_maintenance(): 24 | with open("templates/maintenance.html", "rb") as f: 25 | return f.read() 26 | else: 27 | # Error? 28 | message = "No additional error message available" 29 | authorize = request.headers.get("authorize") 30 | if authorize is not None: 31 | authorize_decoded = dict(urllib.parse.parse_qsl(authorize)) 32 | token = authorize_decoded.get("token") 33 | if token: 34 | exc = errhand.load_error(token.replace(" ", "+")) 35 | if exc: 36 | message = "\n".join(exc) 37 | 38 | return app.templates.TemplateResponse(request, "error.html", {"error": message}) 39 | -------------------------------------------------------------------------------- /scripts/migrations/8_achievement_fix_1.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | import npps4.db.main 7 | import npps4.system.achievement 8 | import npps4.system.advanced 9 | import npps4.system.user 10 | 11 | revision = "8_achievement_fix_1" 12 | prev_revision = "7_give_loveca_to_cleared_songs" 13 | 14 | FIXES_ACHIEVEMENT_IDS = [503, *range(508, 588, 10)] 15 | 16 | 17 | async def main(context: npps4.idol.BasicSchoolIdolContext): 18 | q = sqlalchemy.select(npps4.db.main.Achievement).where( 19 | npps4.db.main.Achievement.achievement_id.in_(FIXES_ACHIEVEMENT_IDS), 20 | npps4.db.main.Achievement.is_accomplished == True, 21 | npps4.db.main.Achievement.is_reward_claimed == True, 22 | ) 23 | 24 | async for ach in (await context.db.main.stream(q)).scalars(): 25 | user = await npps4.system.user.get(context, ach.user_id) 26 | if user is None: 27 | continue 28 | 29 | rewards = await npps4.system.achievement.get_achievement_rewards(context, ach) 30 | for reward in rewards: 31 | result = await npps4.system.advanced.add_item(context, user, reward) 32 | print( 33 | f"Give reward ({reward.add_type.value, reward.item_id})x{reward.amount} to user {user.id} by ach {ach.achievement_id}", 34 | "successful" if result.success else "unsuccessful", 35 | ) 36 | -------------------------------------------------------------------------------- /scripts/unlock_all_subscenario.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | import npps4.system.subscenario 7 | import npps4.db.main 8 | import npps4.db.subscenario 9 | import npps4.scriptutils.user 10 | 11 | 12 | async def run_script(arg: list[str]): 13 | parser = argparse.ArgumentParser(__file__) 14 | group = parser.add_mutually_exclusive_group(required=True) 15 | npps4.scriptutils.user.register_args(group) 16 | parser.add_argument("--unread", action="store_true", help="Mark as unread instead of readed.") 17 | args = parser.parse_args(arg) 18 | 19 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 20 | target_user = await npps4.scriptutils.user.from_args(context, args) 21 | q = sqlalchemy.select(npps4.db.subscenario.SubScenario) 22 | result = await context.db.subscenario.execute(q) 23 | for game_subsc in result.scalars(): 24 | subsc = await npps4.system.subscenario.get(context, target_user, game_subsc.subscenario_id) 25 | if subsc is None: 26 | subsc = npps4.db.main.SubScenario(user_id=target_user.id, subscenario_id=game_subsc.subscenario_id) 27 | context.db.main.add(subsc) 28 | 29 | subsc.completed = not args.unread 30 | 31 | 32 | if __name__ == "__main__": 33 | import npps4.scriptutils.boot 34 | 35 | npps4.scriptutils.boot.start(run_script) 36 | -------------------------------------------------------------------------------- /scripts/bootstrap_docker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # This script serve as entrypoint when NPPS4 runs under Docker. 3 | # This is not regular NPPS4 script! 4 | 5 | import os 6 | import os.path 7 | import shutil 8 | import sys 9 | 10 | 11 | def main() -> int: 12 | curdir = os.path.dirname(__file__) 13 | rootdir = os.path.normpath(os.path.join(curdir, "..")) 14 | datadir = os.path.join(rootdir, "data") 15 | worker_count = max(int(os.environ.get("NPPS4_WORKER", "1")), 1) 16 | python = sys.executable or "python" 17 | 18 | # Setup paths 19 | server_data = os.path.join(datadir, "server_data.json") 20 | if not os.path.exists(server_data): 21 | shutil.copy(os.path.join(rootdir, "npps4", "server_data.json"), server_data) 22 | 23 | external_script = os.path.join(datadir, "external") 24 | if not os.path.exists(external_script): 25 | shutil.copytree(os.path.join(rootdir, "external"), external_script, dirs_exist_ok=True) 26 | 27 | config_toml = os.path.join(datadir, "config.toml") 28 | if os.path.exists(config_toml): 29 | os.environ["NPPS4_CONFIG"] = config_toml 30 | 31 | print("Using config.toml path in container:", config_toml, flush=True) 32 | 33 | os.execlp(python, python, "main.py", "-w", str(worker_count)) 34 | 35 | # In case os.exec* fails, this is executed 36 | return 1 37 | 38 | 39 | if __name__ == "__main__": 40 | import sys 41 | 42 | sys.exit(main()) 43 | -------------------------------------------------------------------------------- /scripts/migrations/6_send_subunit_rewards_take2.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | import npps4.db.main 7 | import npps4.system.achievement 8 | import npps4.system.advanced 9 | import npps4.system.user 10 | 11 | revision = "6_send_subunit_rewards_take2" 12 | prev_revision = "5_send_subunit_rewards" 13 | 14 | FIXES_ACHIEVEMENT_IDS = [10090010, *range(10090012, 10090019)] 15 | 16 | 17 | async def main(context: npps4.idol.BasicSchoolIdolContext): 18 | q = sqlalchemy.select(npps4.db.main.Achievement).where( 19 | npps4.db.main.Achievement.achievement_id.in_(FIXES_ACHIEVEMENT_IDS), 20 | npps4.db.main.Achievement.is_accomplished == True, 21 | npps4.db.main.Achievement.is_reward_claimed == True, 22 | ) 23 | 24 | async for ach in (await context.db.main.stream(q)).scalars(): 25 | user = await npps4.system.user.get(context, ach.user_id) 26 | if user is None: 27 | continue 28 | 29 | rewards = await npps4.system.achievement.get_achievement_rewards(context, ach) 30 | for reward in rewards: 31 | result = await npps4.system.advanced.add_item(context, user, reward) 32 | print( 33 | f"Give reward ({reward.add_type.value, reward.item_id})x{reward.amount} to user {user.id} by ach {ach.achievement_id}", 34 | "successful" if result.success else "unsuccessful", 35 | ) 36 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2025_10_11_1832-b3d6a058fa62_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: b3d6a058fa62 4 | Revises: deea55d98599 5 | Create Date: 2025-10-11 18:32:01.082342 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "b3d6a058fa62" 17 | down_revision: Union[str, None] = "deea55d98599" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | with op.batch_alter_table("user", schema=None) as batch_op: 25 | batch_op.alter_column( 26 | "game_coin", 27 | existing_type=sa.INTEGER(), 28 | type_=sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), 29 | existing_nullable=False, 30 | ) 31 | 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade() -> None: 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | with op.batch_alter_table("user", schema=None) as batch_op: 38 | batch_op.alter_column( 39 | "game_coin", 40 | existing_type=sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), 41 | type_=sa.INTEGER(), 42 | existing_nullable=False, 43 | ) 44 | 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /npps4/game/award.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from .. import util 5 | from ..idol import error 6 | from ..system import award 7 | from ..system import user 8 | 9 | 10 | class AwardInfo(pydantic.BaseModel): 11 | award_id: int 12 | is_set: bool 13 | insert_date: str 14 | 15 | 16 | class AwardInfoResponse(pydantic.BaseModel): 17 | award_info: list[AwardInfo] 18 | 19 | 20 | class AwardSetRequest(pydantic.BaseModel): 21 | award_id: int 22 | 23 | 24 | @idol.register("award", "awardInfo") 25 | async def award_awardinfo(context: idol.SchoolIdolUserParams) -> AwardInfoResponse: 26 | current_user = await user.get_current(context) 27 | awards = await award.get_awards(context, current_user) 28 | award_info = [ 29 | AwardInfo( 30 | award_id=aw.award_id, 31 | is_set=current_user.active_award == aw.award_id, 32 | insert_date=util.timestamp_to_datetime(aw.insert_date), 33 | ) 34 | for aw in awards 35 | ] 36 | 37 | return AwardInfoResponse(award_info=award_info) 38 | 39 | 40 | @idol.register("award", "set") 41 | async def award_set(context: idol.SchoolIdolUserParams, request: AwardSetRequest) -> None: 42 | current_user = await user.get_current(context) 43 | if await award.has_award(context, current_user, request.award_id): 44 | current_user.active_award = request.award_id 45 | return 46 | 47 | raise error.IdolError(detail="No such award") 48 | -------------------------------------------------------------------------------- /scripts/migrations/1_update_incentive_unit_info.py: -------------------------------------------------------------------------------- 1 | import json 2 | import npps4.script_dummy # isort:skip 3 | 4 | import sqlalchemy 5 | 6 | import npps4.const 7 | import npps4.db.main 8 | import npps4.idol 9 | import npps4.system.unit 10 | 11 | revision = "1_update_incentive_unit_info" 12 | prev_revision = None 13 | 14 | 15 | async def main(context: npps4.idol.BasicSchoolIdolContext): 16 | unit_cache_attribute: dict[int, tuple[int, int]] = {} 17 | 18 | q = sqlalchemy.select(npps4.db.main.Incentive).where( 19 | npps4.db.main.Incentive.add_type == int(npps4.const.ADD_TYPE.UNIT) 20 | ) 21 | with await context.db.main.execute(q) as result: 22 | for row in result.scalars(): 23 | hitit = False 24 | if row.item_id not in unit_cache_attribute: 25 | unit_info = await npps4.system.unit.get_unit_info(context, row.item_id) 26 | if unit_info is not None: 27 | unit_cache_attribute[row.item_id] = (unit_info.rarity, unit_info.attribute_id) 28 | 29 | row.unit_rarity, row.unit_attribute = unit_cache_attribute[row.item_id] 30 | if row.extra_data is not None: 31 | extra_data = json.loads(row.extra_data) 32 | if "is_support_member" in extra_data and (not extra_data["is_support_member"]): 33 | extra_data["attribute"] = row.unit_attribute 34 | row.extra_data = json.dumps(extra_data) 35 | 36 | if hitit: 37 | await context.db.main.flush() 38 | -------------------------------------------------------------------------------- /npps4/system/removable_skill.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from . import unit 4 | from . import unit_model 5 | from .. import idol 6 | from ..db import main 7 | 8 | 9 | def apply_stats( 10 | effect_type: int, effect_value: float, fixed: bool, smile: int, pure: int, cool: int 11 | ) -> tuple[int, int, int]: 12 | match effect_type: 13 | case 1: 14 | if fixed: 15 | return int(effect_value), 0, 0 16 | else: 17 | return math.floor(smile * effect_value / 100.0), 0, 0 18 | case 2: 19 | if fixed: 20 | return 0, int(effect_value), 0 21 | else: 22 | return 0, math.floor(pure * effect_value / 100.0), 0 23 | case 3: 24 | if fixed: 25 | return 0, 0, int(effect_value) 26 | else: 27 | return 0, 0, math.floor(cool * effect_value / 100.0) 28 | 29 | return (0, 0, 0) 30 | 31 | 32 | async def can_apply( 33 | context: idol.BasicSchoolIdolContext, trigger_reference_type: int, trigger_type: int, player_units: list[main.Unit] 34 | ): 35 | match trigger_reference_type: 36 | case 0: 37 | return True 38 | case 4: 39 | for unit_data in player_units: 40 | unit_info = await unit.get_unit_info(context, unit_data.unit_id) 41 | 42 | if not await unit.unit_type_has_tag(context, unit_info.unit_type_id, trigger_type): 43 | return False 44 | 45 | return True 46 | case _: 47 | return False 48 | -------------------------------------------------------------------------------- /templates/maintenance.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Maintenance 8 | 9 | 12 | 13 | 14 | 15 |
16 |
17 | 18 |
19 | 20 |
21 |
22 | The server is currently under (unscheduled) maintenance :) 23 |
24 |
25 |
26 | 29 |
30 | 31 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /npps4/game/background.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from .. import util 5 | from ..idol import error 6 | from ..system import background 7 | from ..system import user 8 | 9 | 10 | class BackgroundInfo(pydantic.BaseModel): 11 | background_id: int 12 | is_set: bool 13 | insert_date: str 14 | 15 | 16 | class BackgroundInfoResponse(pydantic.BaseModel): 17 | background_info: list[BackgroundInfo] 18 | 19 | 20 | class BackgroundSetRequest(pydantic.BaseModel): 21 | background_id: int 22 | 23 | 24 | @idol.register("background", "backgroundInfo") 25 | async def background_backgroundinfo(context: idol.SchoolIdolUserParams) -> BackgroundInfoResponse: 26 | current_user = await user.get_current(context) 27 | backgrounds = await background.get_backgrounds(context, current_user) 28 | background_info = [ 29 | BackgroundInfo( 30 | background_id=bg.background_id, 31 | is_set=current_user.active_background == bg.background_id, 32 | insert_date=util.timestamp_to_datetime(bg.insert_date), 33 | ) 34 | for bg in backgrounds 35 | ] 36 | 37 | return BackgroundInfoResponse(background_info=background_info) 38 | 39 | 40 | @idol.register("background", "set") 41 | async def background_set(context: idol.SchoolIdolUserParams, request: BackgroundSetRequest) -> None: 42 | current_user = await user.get_current(context) 43 | if await background.has_background(context, current_user, request.background_id): 44 | current_user.active_background = request.background_id 45 | return 46 | 47 | raise error.IdolError(detail="No such background") 48 | -------------------------------------------------------------------------------- /npps4/system/award.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | 3 | from .. import idol 4 | from ..db import main 5 | 6 | 7 | async def has_award(context: idol.BasicSchoolIdolContext, user: main.User, award_id: int): 8 | q = sqlalchemy.select(main.Award).where(main.Award.user_id == user.id, main.Award.award_id == award_id).limit(1) 9 | result = await context.db.main.execute(q) 10 | return result.scalar() is not None 11 | 12 | 13 | async def unlock_award(context: idol.BasicSchoolIdolContext, user: main.User, award_id: int, set_active: bool = False): 14 | has_bg = await has_award(context, user, award_id) 15 | if has_bg: 16 | return False 17 | 18 | bg = main.Award(user_id=user.id, award_id=award_id) 19 | context.db.main.add(bg) 20 | await context.db.main.flush() 21 | 22 | if set_active: 23 | await set_award_active(context, user, award_id) 24 | 25 | return True 26 | 27 | 28 | async def get_awards(context: idol.BasicSchoolIdolContext, user: main.User): 29 | q = sqlalchemy.select(main.Award).where(main.Award.user_id == user.id) 30 | result = await context.db.main.execute(q) 31 | return result.scalars().all() 32 | 33 | 34 | async def set_award_active(context: idol.BasicSchoolIdolContext, user: main.User, award_id: int): 35 | has_bg = await has_award(context, user, award_id) 36 | if not has_bg: 37 | return False 38 | 39 | user.active_award = award_id 40 | await context.db.main.flush() 41 | return True 42 | 43 | 44 | async def init(context: idol.BasicSchoolIdolContext, user: main.User): 45 | await unlock_award(context, user, 1, True) 46 | await unlock_award(context, user, 23) 47 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Maintenance 8 | 9 | 13 | 14 | 15 | 16 |
17 |
18 | 19 |
20 | 21 |
22 |
23 | Oops! 24 |
25 |
{{ error }}
26 |
27 |
28 | 31 |
32 | 33 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /npps4/game/event.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | import pydantic 4 | 5 | from .. import idol 6 | from .. import util 7 | from ..system import common 8 | 9 | 10 | class EventBonus(pydantic.BaseModel): 11 | limited_bonus_type: int 12 | limited_bonus_value: int 13 | 14 | 15 | class EventCampaignList(pydantic.BaseModel): 16 | fixed_message: str = "" 17 | live_limited_bonuses: list[EventBonus] 18 | 19 | 20 | class EventBannerType(enum.IntEnum): 21 | EVENT = 0 22 | DUEL = 7 23 | ARENA = 14 24 | CONCERT = 15 25 | CLASS_COMPETITION = 16 26 | CLASS = 17 27 | 28 | 29 | class Event(pydantic.BaseModel): 30 | banner_type: EventBannerType 31 | asset_path: str 32 | start_date: str 33 | end_date: str 34 | is_locked: bool 35 | is_new: bool = True 36 | description: str = "" 37 | target_id: int | None = None 38 | # campaign_list: EventCampaignList | None = None 39 | 40 | 41 | class EventTargetList(pydantic.BaseModel): 42 | position: int 43 | is_displayable: bool = True 44 | event_list: list[Event] 45 | 46 | 47 | class EventListResponse(common.TimestampMixin): 48 | target_list: list[EventTargetList] 49 | 50 | 51 | @idol.register("event", "eventList") 52 | async def event_eventlist(context: idol.SchoolIdolUserParams) -> EventListResponse: 53 | util.stub("event", "eventList") 54 | raise idol.error.by_code(idol.error.ERROR_CODE_EVENT_NO_EVENT_DATA) 55 | # https://github.com/YumeMichi/honoka-chan/blob/6778972c1ff54a8a038ea07b676e6acdbb211f96/handler/event.go#L15 56 | # return EventListResponse(target_list=[EventTargetList(position=i, is_displayable=False) for i in range(1, 7)]) 57 | -------------------------------------------------------------------------------- /npps4/game/item.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from ..system import common 5 | from ..system import item 6 | from ..system import user 7 | 8 | from typing import Annotated, Any 9 | 10 | 11 | def empty_list_is_empty_dict(value: Any): 12 | if isinstance(value, list) and len(value) == 0: 13 | return {} 14 | return value 15 | 16 | 17 | class ItemListReinforceInfoUnitReinforceItem(pydantic.BaseModel): 18 | unit_reinforce_item_id: int 19 | reinforce_type: int 20 | addition_value: int 21 | target_unit_ids: list[int] 22 | 23 | 24 | class ItemListReinforceInfo(pydantic.BaseModel): 25 | event_id: int 26 | item_list: list[ItemListReinforceInfoUnitReinforceItem] 27 | available_unit_list: list[dict] # Additionally has mark_id (int) and sub_evaluation (int) 28 | 29 | 30 | class ItemListResponse(pydantic.BaseModel): 31 | general_item_list: list[common.ItemCount] 32 | buff_item_list: list[common.ItemCount] 33 | reinforce_item_list: list[common.ItemCount] 34 | reinforce_info: Annotated[dict[str, ItemListReinforceInfo], pydantic.BeforeValidator(empty_list_is_empty_dict)] = ( 35 | pydantic.Field(default_factory=dict) 36 | ) 37 | 38 | 39 | @idol.register("item", "list") 40 | async def item_list(context: idol.SchoolIdolUserParams) -> ItemListResponse: 41 | current_user = await user.get_current(context=context) 42 | general_item_list, buff_item_list, reinforce_item_list = await item.get_item_list(context, current_user) 43 | return ItemListResponse( 44 | general_item_list=general_item_list, buff_item_list=buff_item_list, reinforce_item_list=reinforce_item_list 45 | ) 46 | -------------------------------------------------------------------------------- /npps4/db/subscenario.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | import sqlalchemy.ext.asyncio 3 | import sqlalchemy.orm 4 | import sqlalchemy.pool 5 | 6 | from . import common 7 | from ..download import download 8 | 9 | 10 | class SubScenario(common.GameDBBase, common.MaybeEncrypted): 11 | """```sql 12 | CREATE TABLE `subscenario_m` ( 13 | `subscenario_id` INTEGER NOT NULL, 14 | `unit_id` INTEGER NOT NULL, 15 | `title` TEXT NOT NULL, 16 | `title_en` TEXT, 17 | `asset_bgm_id` INTEGER NOT NULL, 18 | `scenario_char_asset_id` INTEGER, 19 | `release_tag` TEXT, `_encryption_release_id` INTEGER NULL, 20 | PRIMARY KEY (`subscenario_id`) 21 | ) 22 | ```""" 23 | 24 | __tablename__ = "subscenario_m" 25 | subscenario_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column(primary_key=True) 26 | unit_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 27 | title: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 28 | title_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 29 | asset_bgm_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 30 | scenario_char_asset_id: sqlalchemy.orm.Mapped[int | None] = sqlalchemy.orm.mapped_column() 31 | 32 | 33 | engine = sqlalchemy.ext.asyncio.create_async_engine( 34 | f"sqlite+aiosqlite:///file:{download.get_db_path('subscenario')}?mode=ro&uri=true", 35 | poolclass=sqlalchemy.pool.NullPool, 36 | connect_args={"check_same_thread": False}, 37 | ) 38 | sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(engine) 39 | 40 | 41 | def get_sessionmaker(): 42 | global sessionmaker 43 | return sessionmaker 44 | -------------------------------------------------------------------------------- /static/js/button.js: -------------------------------------------------------------------------------- 1 | var Button = function() { 2 | }; 3 | 4 | Button.TAPPED = 'tapped'; 5 | 6 | Button.initialize = function(element, onTap) { 7 | var touchInfo = null; 8 | 9 | if (typeof onTap === 'function') { 10 | onTap = onTap.bind(element); 11 | } else { 12 | onTap = function() {}; 13 | } 14 | 15 | function d2(x1, y1, x2, y2) { 16 | var dx = x2 - x1, dy = y2 - y1; 17 | return dx * dx + dy * dy; 18 | } 19 | 20 | function startTap(touch) { 21 | touchInfo = {startX: touch.clientX, startY: touch.clientY, tapped: false}; 22 | element.classList.add(Button.TAPPED); 23 | } 24 | 25 | function endTap() { 26 | element.classList.remove(Button.TAPPED); 27 | touchInfo = null; 28 | } 29 | 30 | element.addEventListener('touchstart', function(e) { 31 | startTap(e.changedTouches[0]); 32 | e.preventDefault(); 33 | }); 34 | 35 | element.addEventListener('touchmove', function(e) { 36 | if (touchInfo) { 37 | var x0 = touchInfo.startX; 38 | var y0 = touchInfo.startY; 39 | var x = e.changedTouches[0].clientX; 40 | var y = e.changedTouches[0].clientY; 41 | 42 | var current = document.elementFromPoint(x, y); 43 | 44 | const THRESHOLD_SQ_PX = 50 * 50; 45 | if (!element.contains(current) || d2(x0, y0, x, y) > THRESHOLD_SQ_PX) { 46 | endTap(); 47 | } 48 | } 49 | 50 | e.preventDefault(); 51 | }); 52 | 53 | element.addEventListener('touchend', function(e) { 54 | if (touchInfo && !touchInfo.tapped) { 55 | touchInfo.tapped = true; 56 | onTap(); 57 | } 58 | 59 | endTap(); 60 | e.preventDefault(); 61 | }); 62 | 63 | element.addEventListener('touchcancel', function(e) { 64 | endTap(); 65 | e.preventDefault(); 66 | }); 67 | 68 | return element; 69 | }; 70 | -------------------------------------------------------------------------------- /npps4/system/background.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | 3 | from .. import idol 4 | from ..db import main 5 | 6 | 7 | async def has_background(context: idol.BasicSchoolIdolContext, user: main.User, background_id: int): 8 | q = ( 9 | sqlalchemy.select(main.Background) 10 | .where(main.Background.user_id == user.id, main.Background.background_id == background_id) 11 | .limit(1) 12 | ) 13 | result = await context.db.main.execute(q) 14 | return result.scalar() is not None 15 | 16 | 17 | async def unlock_background( 18 | context: idol.BasicSchoolIdolContext, user: main.User, background_id: int, set_active: bool = False 19 | ): 20 | has_bg = await has_background(context, user, background_id) 21 | if has_bg: 22 | return False 23 | 24 | bg = main.Background(user_id=user.id, background_id=background_id) 25 | context.db.main.add(bg) 26 | await context.db.main.flush() 27 | 28 | if set_active: 29 | await set_background_active(context, user, background_id) 30 | 31 | return True 32 | 33 | 34 | async def get_backgrounds(context: idol.BasicSchoolIdolContext, user: main.User): 35 | q = sqlalchemy.select(main.Background).where(main.Background.user_id == user.id) 36 | result = await context.db.main.execute(q) 37 | return result.scalars().all() 38 | 39 | 40 | async def set_background_active(context: idol.BasicSchoolIdolContext, user: main.User, background_id: int): 41 | has_bg = await has_background(context, user, background_id) 42 | if not has_bg: 43 | return False 44 | 45 | user.active_background = background_id 46 | await context.db.main.flush() 47 | return True 48 | 49 | 50 | async def init(context: idol.BasicSchoolIdolContext, user: main.User): 51 | await unlock_background(context, user, 1, True) 52 | -------------------------------------------------------------------------------- /npps4/run/app.py: -------------------------------------------------------------------------------- 1 | # This license applies to all source files in this directory and subdirectories. 2 | # 3 | # Copyright (c) 2024 Dark Energy Processor 4 | # 5 | # This software is provided 'as-is', without any express or implied warranty. In 6 | # no event will the authors be held liable for any damages arising from the use of 7 | # this software. 8 | # 9 | # Permission is granted to anyone to use this software for any purpose, including 10 | # commercial applications, and to alter it and redistribute it freely, subject to 11 | # the following restrictions: 12 | # 13 | # 1. The origin of this software must not be misrepresented; you must not claim 14 | # that you wrote the original software. If you use this software in a product, 15 | # an acknowledgment in the product documentation would be appreciated but is 16 | # not required. 17 | # 2. Altered source versions must be plainly marked as such, and must not be 18 | # misrepresented as being the original software. 19 | # 3. This notice may not be removed or altered from any source distribution. 20 | 21 | # Must be loaded first! 22 | import json 23 | import logging 24 | 25 | import fastapi 26 | 27 | from .. import setup # Needs to be first! 28 | from .. import game 29 | from .. import webview 30 | from .. import other 31 | from .. import util 32 | from ..app import app 33 | 34 | from typing import Annotated 35 | 36 | 37 | # 404 handler 38 | @app.main.post("/{module}/{action}") 39 | async def not_found_handler(module: str, action: str, request_data: Annotated[bytes, fastapi.Form()]) -> dict: 40 | util.log("Endpoint not found", f"{module}/{action}", json.loads(request_data), severity=logging.ERROR) 41 | raise fastapi.HTTPException(404) 42 | 43 | 44 | app.core.include_router(app.main) 45 | app.core.include_router(app.webview) 46 | main = app.core 47 | -------------------------------------------------------------------------------- /scripts/add_exchange_point.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import argparse 4 | 5 | import npps4.idol 6 | import npps4.system.exchange 7 | import npps4.db.exchange 8 | import npps4.scriptutils.user 9 | 10 | 11 | async def is_exchange_point_id_valid(context: npps4.idol.BasicSchoolIdolContext, exchange_point_id: int): 12 | result = await context.db.exchange.get(npps4.db.exchange.ExchangePoint, exchange_point_id) 13 | return result is not None 14 | 15 | 16 | async def run_script(arg: list[str]): 17 | parser = argparse.ArgumentParser(__file__, formatter_class=argparse.ArgumentDefaultsHelpFormatter) 18 | group = parser.add_mutually_exclusive_group(required=True) 19 | npps4.scriptutils.user.register_args(group) 20 | parser.add_argument("exchange_point_id", type=int, help="Exchange point ID.") 21 | parser.add_argument("amount", type=int, default=0, nargs="?", help="Amount of exchange point to add.") 22 | args = parser.parse_args(arg) 23 | 24 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 25 | target_user = await npps4.scriptutils.user.from_args(context, args) 26 | if not await is_exchange_point_id_valid(context, args.exchange_point_id): 27 | raise Exception("no such exchange point ID") 28 | 29 | exchange_point = await npps4.system.exchange.get_exchange_point( 30 | context, target_user, args.exchange_point_id, True 31 | ) 32 | assert exchange_point is not None 33 | 34 | print("Old:", exchange_point.amount) 35 | exchange_point.amount = max(exchange_point.amount + args.amount, 0) 36 | print("New:", exchange_point.amount) 37 | 38 | 39 | if __name__ == "__main__": 40 | import npps4.scriptutils.boot 41 | 42 | npps4.scriptutils.boot.start(run_script) 43 | -------------------------------------------------------------------------------- /scripts/migrations/2_populate_normal_live_unlock.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import sqlalchemy 4 | 5 | import npps4.db.live 6 | import npps4.db.main 7 | import npps4.idol 8 | import npps4.system.live 9 | import npps4.system.user 10 | 11 | revision = "2_populate_normal_live_unlock" 12 | prev_revision = "1_update_incentive_unit_info" 13 | 14 | 15 | async def main(context: npps4.idol.BasicSchoolIdolContext): 16 | live_track_map: dict[int, int] = {} 17 | user_cache: dict[int, npps4.db.main.User] = {} 18 | 19 | q = sqlalchemy.select(npps4.db.main.LiveClear) 20 | result = await context.db.main.execute(q) 21 | 22 | for row in result.scalars(): 23 | if row.user_id not in user_cache: 24 | target_user = await npps4.system.user.get(context, row.user_id) 25 | if target_user is None: 26 | continue 27 | user_cache[row.user_id] = target_user 28 | else: 29 | target_user = user_cache[row.user_id] 30 | 31 | if row.live_difficulty_id not in live_track_map: 32 | q = sqlalchemy.select(npps4.db.live.NormalLive.live_setting_id).where( 33 | npps4.db.live.NormalLive.live_difficulty_id == row.live_difficulty_id 34 | ) 35 | result = await context.db.live.execute(q) 36 | live_setting_id = result.scalar() 37 | if live_setting_id is None: 38 | continue 39 | 40 | live_setting_info = await npps4.system.live.get_live_setting(context, live_setting_id) 41 | if live_setting_info is None: 42 | continue 43 | live_track_map[row.live_difficulty_id] = live_setting_info.live_track_id 44 | 45 | await npps4.system.live.unlock_normal_live(context, target_user, live_track_map[row.live_difficulty_id]) 46 | -------------------------------------------------------------------------------- /scripts/encrypt_serial_code_action.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import npps4.script_dummy 4 | import npps4.data 5 | import npps4.data.schema 6 | import npps4.util 7 | 8 | 9 | async def run_script(args: list[str]): 10 | input_code = args[0] 11 | server_data = npps4.data.get() 12 | 13 | for serial_code in server_data.serial_codes: 14 | if serial_code.check_serial_code(input_code): 15 | if isinstance(serial_code.serial_code, str): 16 | raise Exception("cannot encrypt action without secure serial code") 17 | 18 | input_data = serial_code.get_action(input_code).model_dump_json(exclude_defaults=True) 19 | key = npps4.data.schema.derive_serial_code_action_key(input_code, serial_code.serial_code.salt) 20 | aes = npps4.data.schema.initialize_aes_for_action_field(key, serial_code.serial_code.salt) 21 | encrypted = aes.encrypt(input_data.encode("utf-8")) 22 | encrypted_b64 = str(base64.urlsafe_b64encode(encrypted), "utf-8") 23 | 24 | print("encrypted") 25 | for i in range(0, len(encrypted_b64), 80): 26 | print(encrypted_b64[i : i + 80]) 27 | 28 | # Test 29 | aes = npps4.data.schema.initialize_aes_for_action_field(key, serial_code.serial_code.salt) 30 | decrypted = aes.decrypt(encrypted) 31 | decrypted_type = npps4.data.schema.SERIAL_CODE_ACTION_ADAPTER.validate_json(decrypted) 32 | print() 33 | print("decrypted") 34 | print(str(decrypted, "utf-8")) 35 | print(type(decrypted_type), decrypted_type) 36 | return 37 | 38 | raise Exception("cannot find such serial code") 39 | 40 | 41 | if __name__ == "__main__": 42 | import npps4.scriptutils.boot 43 | 44 | npps4.scriptutils.boot.start(run_script) 45 | -------------------------------------------------------------------------------- /scripts/export_account.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import argparse 4 | import base64 5 | import sys 6 | 7 | import npps4.config.config 8 | import npps4.idol 9 | import npps4.system.handover 10 | import npps4.system.lila 11 | import npps4.scriptutils.user 12 | 13 | 14 | def tobytesutf8(input: str): 15 | return input.encode("utf-8") 16 | 17 | 18 | async def run_script(arg: list[str]): 19 | parser = argparse.ArgumentParser(__file__) 20 | npps4.scriptutils.user.register_args(parser.add_mutually_exclusive_group(required=True)) 21 | parser.add_argument("output", nargs="?", help="Exported account data output file") 22 | parser.add_argument( 23 | "--secret-key", 24 | type=tobytesutf8, 25 | default=npps4.config.config.get_secret_key(), 26 | help="Secret key used for signing.", 27 | required=False, 28 | ) 29 | parser.add_argument("--nullify-credentials", action="store_true", help="Remove credentials from exported data.") 30 | args = parser.parse_args(arg) 31 | 32 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 33 | target_user = await npps4.scriptutils.user.from_args(context, args) 34 | serialized_data, signature = await npps4.system.lila.export_user( 35 | context, target_user, args.secret_key, args.nullify_credentials 36 | ) 37 | 38 | print("Signature:", str(base64.urlsafe_b64encode(signature), "utf-8"), file=sys.stderr) 39 | 40 | if args.output: 41 | with open(args.output, "wb") as f: 42 | f.write(base64.urlsafe_b64encode(serialized_data)) 43 | else: 44 | sys.stdout.buffer.write(base64.urlsafe_b64encode(serialized_data)) 45 | 46 | 47 | if __name__ == "__main__": 48 | import npps4.scriptutils.boot 49 | 50 | npps4.scriptutils.boot.start(run_script) 51 | -------------------------------------------------------------------------------- /scripts/export_all_user.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import argparse 4 | import struct 5 | import traceback 6 | 7 | import sqlalchemy 8 | 9 | import npps4.config.config 10 | import npps4.db.main 11 | import npps4.idol 12 | import npps4.system.handover 13 | import npps4.system.lila 14 | 15 | 16 | async def run_script(arg: list[str]): 17 | parser = argparse.ArgumentParser(__file__) 18 | parser.add_argument("output", help="Exported data output file (binary)") 19 | args = parser.parse_args(arg) 20 | 21 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 22 | with open(args.output, "wb") as f: 23 | q = sqlalchemy.select(npps4.db.main.User).where( 24 | ((npps4.db.main.User.key != None) & (npps4.db.main.User.passwd != None)) 25 | | (npps4.db.main.User.transfer_sha1 != None) 26 | ) 27 | result = await context.db.main.execute(q) 28 | 29 | for target_user in result.scalars(): 30 | try: 31 | serialized_data, signature = await npps4.system.lila.export_user(context, target_user) 32 | except Exception as e: 33 | print("Cannot export user:", target_user.id, target_user.name, target_user.invite_code) 34 | traceback.print_exception(e) 35 | continue 36 | 37 | payloadsize = struct.pack(" NoticeMarqueeResponse: 51 | # TODO 52 | util.stub("notice", "noticeMarquee", context.raw_request_data) 53 | return NoticeMarqueeResponse(item_count=0, marquee_list=[]) 54 | 55 | 56 | @idol.register("notice", "noticeFriendVariety") 57 | async def notice_noticefriendvariety( 58 | context: idol.SchoolIdolUserParams, request: NoticeFriendVarietyRequest 59 | ) -> NoticeFriendVarietyResponse: 60 | # TODO 61 | util.stub("notice", "noticeFriendVariety", request) 62 | return NoticeFriendVarietyResponse(item_count=0, notice_list=[]) 63 | -------------------------------------------------------------------------------- /npps4/download/none.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import fastapi 4 | 5 | from . import dltype 6 | from .. import util 7 | from .. import idoltype 8 | from ..config import config 9 | 10 | 11 | def get_server_version(): 12 | return util.parse_sif_version(config.CONFIG_DATA.download.none.client_version) 13 | 14 | 15 | def get_db_path(name: str) -> str: 16 | path = f"{config.get_data_directory()}/db/{name}.db_" 17 | if os.path.isfile(path): 18 | return path 19 | 20 | raise NotImplementedError(f"'none' backend does not automatically load databases! Unable to find '{path}'") 21 | 22 | 23 | async def get_update_files( 24 | request: fastapi.Request, platform: idoltype.PlatformType, from_client_version: tuple[int, int] 25 | ) -> list[dltype.UpdateInfo]: 26 | raise NotImplementedError("not implemented get_update_files") 27 | 28 | 29 | async def get_batch_files( 30 | request: fastapi.Request, platform: idoltype.PlatformType, package_type: int, exclude: list[int] 31 | ) -> list[dltype.BatchInfo]: 32 | raise NotImplementedError("not implemented get_batch_files") 33 | 34 | 35 | async def get_single_package( 36 | request: fastapi.Request, platform: idoltype.PlatformType, package_type: int, package_id: int 37 | ) -> list[dltype.BaseInfo] | None: 38 | return None 39 | 40 | 41 | async def get_raw_files(request: fastapi.Request, platform: idoltype.PlatformType, files: list[str]): 42 | target = str(request.url) 43 | target = (target + "missing") if target[-1] == "/" else (target + "/missing") 44 | return [ 45 | dltype.BaseInfo( 46 | url=target, 47 | size=0, 48 | checksums=dltype.Checksum( 49 | md5="d41d8cd98f00b204e9800998ecf8427e", 50 | sha256="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 51 | ), 52 | ) 53 | ] * len(files) 54 | 55 | 56 | def initialize(): 57 | pass 58 | -------------------------------------------------------------------------------- /npps4/system/core.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | ########################################################################################################## 6 | # Most of these information is obtained from https://w.atwiki.jp/lovelive-sif/pages/23.html unless noted # 7 | ########################################################################################################## 8 | 9 | 10 | def _get_next_exp_base(rank: int) -> int: 11 | if rank <= 1: 12 | return 11 13 | elif rank < 34: 14 | # Rank < 34 is exactly this formula 15 | return round(_get_next_exp_base(rank - 1) + 34.45 * rank / 33) 16 | else: 17 | return round(34.45 * rank - 551) 18 | 19 | 20 | def get_next_exp(rank: int): 21 | result: int = 0 22 | if rank > 0: 23 | result = _get_next_exp_base(rank) 24 | if rank < 100: 25 | result = round(result / 2) 26 | return result 27 | 28 | 29 | def get_next_exp_cumulative(rank: int): 30 | return sum(map(get_next_exp, range(1, rank + 1))) 31 | 32 | 33 | # functools.cache does not play very well with type checking right now. 34 | if not TYPE_CHECKING: 35 | get_next_exp_cumulative = functools.cache(get_next_exp_cumulative) 36 | get_next_exp = functools.cache(get_next_exp) 37 | 38 | 39 | def get_invite_code(user_id: int): 40 | # https://auahdark687291.blogspot.com/2018/05/sif-user-collision.html 41 | return (user_id * 805306357) % 999999937 42 | 43 | 44 | def get_energy_by_rank(rank: int): 45 | lp1 = min(300, rank) // 2 + 25 46 | lp2 = max(rank - 300, 0) // 3 47 | return lp1 + lp2 48 | 49 | 50 | def get_max_friend_by_rank(rank: int): 51 | friend1 = 10 + min(50, rank) // 5 52 | friend2 = max(rank - 50, 0) // 10 53 | return min(friend1 + friend2, 99) 54 | 55 | 56 | def get_training_energy_by_rank(rank: int): 57 | m1 = 3 + min(200, rank) // 50 58 | m2 = max(rank - 200, 0) // 100 59 | return min(m1 + m2, 10) 60 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2025_05_04_1307-6a6186e47091_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 6a6186e47091 4 | Revises: d701309ff89e 5 | Create Date: 2025-05-04 13:07:24.508384 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "6a6186e47091" 17 | down_revision: Union[str, None] = "d701309ff89e" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | "player_ranking", 26 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 27 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 28 | sa.Column("day", sa.Integer(), nullable=False), 29 | sa.Column("score", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 30 | sa.ForeignKeyConstraint( 31 | ["user_id"], 32 | ["user.id"], 33 | ), 34 | sa.PrimaryKeyConstraint("id"), 35 | sa.UniqueConstraint("user_id", "day"), 36 | ) 37 | with op.batch_alter_table("player_ranking", schema=None) as batch_op: 38 | batch_op.create_index( 39 | batch_op.f("ix_player_ranking_user_id"), 40 | [sa.text("user_id ASC"), sa.text("day DESC"), sa.text("score DESC")], # type: ignore 41 | unique=False, 42 | ) 43 | 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade() -> None: 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | with op.batch_alter_table("player_ranking", schema=None) as batch_op: 50 | batch_op.drop_index(batch_op.f("ix_player_ranking_user_id")) 51 | 52 | op.drop_table("player_ranking") 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_05_12_1109-1d73d9b010d0_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 1d73d9b010d0 4 | Revises: a7561a269dac 5 | Create Date: 2024-05-12 11:09:56.034624 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "1d73d9b010d0" 17 | down_revision: Union[str, None] = "a7561a269dac" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | "normal_live_unlock", 26 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 27 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 28 | sa.Column("live_track_id", sa.Integer(), nullable=False), 29 | sa.ForeignKeyConstraint( 30 | ["user_id"], 31 | ["user.id"], 32 | ), 33 | sa.PrimaryKeyConstraint("id"), 34 | sa.UniqueConstraint("user_id", "live_track_id"), 35 | ) 36 | with op.batch_alter_table("normal_live_unlock", schema=None) as batch_op: 37 | batch_op.create_index(batch_op.f("ix_normal_live_unlock_live_track_id"), ["live_track_id"], unique=False) 38 | batch_op.create_index(batch_op.f("ix_normal_live_unlock_user_id"), ["user_id"], unique=False) 39 | 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade() -> None: 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | with op.batch_alter_table("normal_live_unlock", schema=None) as batch_op: 46 | batch_op.drop_index(batch_op.f("ix_normal_live_unlock_user_id")) 47 | batch_op.drop_index(batch_op.f("ix_normal_live_unlock_live_track_id")) 48 | 49 | op.drop_table("normal_live_unlock") 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /npps4/webui/endpoints/unlock_backgrounds.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import fastapi 4 | import sqlalchemy 5 | 6 | from .. import template 7 | from ... import idol 8 | from ...app import webui 9 | from ...db import item 10 | from ...db import main 11 | from ...system import background 12 | 13 | from typing import Annotated 14 | 15 | 16 | async def get_backgrounds(context: idol.BasicSchoolIdolContext, user: main.User): 17 | q = sqlalchemy.select(item.Background) 18 | result = await context.db.item.execute(q) 19 | all_backgrounds = {k.background_id: k for k in result.scalars()} 20 | 21 | user_backgrounds = await background.get_backgrounds(context, user) 22 | unlocked_ids = set(bg.id for bg in user_backgrounds) 23 | locked_ids = set(all_backgrounds.keys()) - unlocked_ids 24 | 25 | unlocked_backgrounds = [all_backgrounds[i] for i in sorted(unlocked_ids)] 26 | locked_backgrounds = [all_backgrounds[i] for i in sorted(locked_ids)] 27 | 28 | return unlocked_backgrounds, locked_backgrounds 29 | 30 | 31 | @webui.app.get("/unlock_backgrounds.html") 32 | async def unlock_backgrounds( 33 | request: fastapi.Request, uid: int, bg_id: Annotated[list[int], fastapi.Query(default_factory=list)] 34 | ): 35 | async with idol.BasicSchoolIdolContext(lang=idol.Language.en) as context: 36 | target_user = await context.db.main.get(main.User, uid) 37 | if target_user is None: 38 | raise ValueError("invalid user_id") 39 | 40 | # Perform unlock first. 41 | for background_id in bg_id: 42 | await background.unlock_background(context, target_user, background_id) 43 | 44 | # Get locked and unlocked backgrounds 45 | unlocked, locked = await get_backgrounds(context, target_user) 46 | 47 | return template.template.TemplateResponse( 48 | request, 49 | "unlock_backgrounds.html", 50 | {"uid": uid, "unlocked_backgrounds": unlocked, "locked_backgrounds": locked}, 51 | ) 52 | -------------------------------------------------------------------------------- /npps4/leader_skill.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from typing import Protocol 4 | 5 | 6 | class _LeaderSkillCalcFunc(Protocol): 7 | def __call__(self, smile: int, pure: int, cool: int, by: float) -> tuple[int, int, int]: ... 8 | 9 | 10 | def inc_smile(smile: int, pure: int, cool: int, by: float): 11 | return math.ceil(smile * by), 0, 0 12 | 13 | 14 | def inc_pure(smile: int, pure: int, cool: int, by: float): 15 | return 0, math.ceil(pure * by), 0 16 | 17 | 18 | def inc_cool(smile: int, pure: int, cool: int, by: float): 19 | return 0, 0, math.ceil(cool * by) 20 | 21 | 22 | def inc_smile_by_pure(smile: int, pure: int, cool: int, by: float): 23 | return math.ceil(pure * by), 0, 0 24 | 25 | 26 | def inc_smile_by_cool(smile: int, pure: int, cool: int, by: float): 27 | return math.ceil(cool * by), 0, 0 28 | 29 | 30 | def inc_pure_by_smile(smile: int, pure: int, cool: int, by: float): 31 | return 0, math.ceil(smile * by), 0 32 | 33 | 34 | def inc_pure_by_cool(smile: int, pure: int, cool: int, by: float): 35 | return 0, math.ceil(cool * by), 0 36 | 37 | 38 | def inc_cool_by_smile(smile: int, pure: int, cool: int, by: float): 39 | return 0, 0, math.ceil(smile * by) 40 | 41 | 42 | def inc_cool_by_pure(smile: int, pure: int, cool: int, by: float): 43 | return 0, 0, math.ceil(pure * by) 44 | 45 | 46 | def _return_zero(smile: int, pure: int, cool: int, by: float): 47 | return 0, 0, 0 48 | 49 | 50 | LEADER_SKILL_CALC_FUNC: dict[int, _LeaderSkillCalcFunc] = { 51 | 1: inc_smile, 52 | 2: inc_pure, 53 | 3: inc_cool, 54 | 112: inc_pure_by_smile, 55 | 113: inc_cool_by_smile, 56 | 121: inc_smile_by_pure, 57 | 123: inc_cool_by_pure, 58 | 131: inc_smile_by_cool, 59 | 132: inc_pure_by_cool, 60 | } 61 | 62 | 63 | def calculate_bonus(leader_skill_effect_type: int, effect_value: int, smile: int, pure: int, cool: int): 64 | func = LEADER_SKILL_CALC_FUNC.get(leader_skill_effect_type, _return_zero) 65 | return func(smile, pure, cool, effect_value / 100) 66 | -------------------------------------------------------------------------------- /scripts/list_ach_live_unlock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python -m npps4.script 2 | import sqlalchemy 3 | 4 | import npps4.db.main 5 | import npps4.db.achievement 6 | import npps4.db.live 7 | import npps4.idol 8 | 9 | 10 | def select_en(jp: str, en: str | None): 11 | return en or jp 12 | 13 | 14 | async def run_script(args: list[str]): 15 | context = npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) 16 | 17 | async with context: 18 | # Get achievement that require live show unlocks 19 | q = sqlalchemy.select(npps4.db.achievement.Achievement).where( 20 | npps4.db.achievement.Achievement.achievement_type == 32 21 | ) 22 | result = await context.db.achievement.execute(q) 23 | live_clear_ach = dict((a.achievement_id, a) for a in result.scalars()) 24 | 25 | fast_live_clear_ach_lookup = set(live_clear_ach.keys()) 26 | q = sqlalchemy.select(npps4.db.achievement.Story).where( 27 | npps4.db.achievement.Story.next_achievement_id.in_(live_clear_ach.keys()) 28 | ) 29 | result = await context.db.achievement.execute(q) 30 | ach_trees = list(filter(lambda a: a.next_achievement_id in fast_live_clear_ach_lookup, result.scalars())) 31 | 32 | for ach_id in sorted(ach_trees, key=lambda k: k.achievement_id): 33 | ach = live_clear_ach[ach_id.next_achievement_id] 34 | live_track_id = int(ach.params1 or 0) 35 | live_track = await context.db.live.get(npps4.db.live.LiveTrack, live_track_id) 36 | assert live_track is not None 37 | track_name = select_en(live_track.name, live_track.name_en) 38 | # 110: [item.Reward(add_type=ADD_TYPE.LIVE, item_id=3)], # Reward: Snow Halation 39 | print( 40 | f"{ach_id.achievement_id}: [item.Reward(add_type=ADD_TYPE.LIVE, item_id={live_track_id})], # Reward: {track_name}" 41 | ) 42 | 43 | 44 | if __name__ == "__main__": 45 | import npps4.scriptutils.boot 46 | 47 | npps4.scriptutils.boot.start(run_script) 48 | -------------------------------------------------------------------------------- /util/unit_removable_skill_m.md: -------------------------------------------------------------------------------- 1 | Removable Skill DB 2 | ===== 3 | 4 | Documents information about unit removable skill a.k.a. SIS. 5 | 6 | Fields: 7 | * `skill_type` - SIS type. 1 = Normal. 2 = Live Arena. 8 | * `level` - _Unknown_. Always 0 for SIS type 1. 9 | * `size` - Amount of space it occupy. 10 | * `effect_range` - Scope of the effect applied. 1 = Self, 2 = All Team. 11 | * `effect_type` - Kind of effect. See below for possible values. 12 | * `effect_value` - Value of the effect. Unit is either flat value or multiplier, depending on `fixed_value_flag` below. 13 | * `fixed_value_flag`- If this is 1, `effect_value` applies fixed-value instead of percentage. 14 | * `target_reference_type` - Egligible members to use this SIS. Affects `target_type` value. See below for possible values. 15 | * `target_type` - Target type based on `target_reference_type`. 16 | * `trigger_reference_type` - Prerequisite for the SIS to take effect. 0 = No condition. 4 = `trigger_type` is `member_tag_id`. 17 | * `trigger_type` - Target prerequisite for `trigger_reference_type` 18 | 19 | `effect_type` values: 20 | * 1 = Apply to Smile directly 21 | * 2 = Apply to Pure directly 22 | * 3 = Apply to Cool directly 23 | * 11 = Score boost enhance. 24 | * 12 = Add score equal to recovery value times `effect_value` on heal. 25 | * 13 = Increase score on timing boost skill/perfect lock active (Smile). 26 | * 14 = Increase score on timing boost skill/perfect lock active (Pure). 27 | * 15 = Increase score on timing boost skill/perfect lock active (Cool). 28 | 29 | `target_reference_type` mapping: 30 | * 0 = Any. `target_type` is unused. 31 | * 1 = Member year. `target_type` is the required member target year. 32 | * 2 = Specific member. `target_type` is `unit_type_id`. 33 | * 3 = Target attribute to boost during live show. `target_type` specify attribute ID. 34 | 35 | `trigger_type` if `trigger_reference_type` is 4: 36 | * 4 = All myus member in team. 37 | * 5 = All Aqours member in team. 38 | * 60 = All Nijigasaki member in team (all unit must be different unit type). 39 | * 143 = All Liella member in team. -------------------------------------------------------------------------------- /scripts/import_account.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import argparse 4 | import base64 5 | import sys 6 | 7 | import npps4.idol 8 | import npps4.system.handover 9 | import npps4.system.lila 10 | 11 | 12 | def tobytesutf8(input: str): 13 | return input.encode("utf-8") 14 | 15 | 16 | async def run_script(arg: list[str]): 17 | parser = argparse.ArgumentParser(__file__) 18 | parser.add_argument("file", nargs="?", help="Base64-encoded exported account data.") 19 | parser.add_argument( 20 | "--signature", 21 | type=base64.urlsafe_b64decode, 22 | help="Signature data to verify the exported account data.", 23 | default=None, 24 | ) 25 | parser.add_argument("--no-passcode", action="store_true", help="Do not create transfer passcode.") 26 | args = parser.parse_args(arg) 27 | 28 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 29 | if args.file: 30 | with open(args.file, "rb") as f: 31 | contents = f.read() 32 | else: 33 | contents = sys.stdin.buffer.read() 34 | 35 | decoded_contents = base64.urlsafe_b64decode(contents) 36 | account_data = npps4.system.lila.extract_serialized_data(decoded_contents, args.signature) 37 | 38 | target_user = await npps4.system.lila.import_user(context, account_data) 39 | print("User ID:", target_user.id) 40 | print("Name:", target_user.name) 41 | print("Friend ID:", target_user.invite_code) 42 | 43 | if not args.no_passcode: 44 | transfer_code = npps4.system.handover.generate_transfer_code() 45 | target_user.transfer_sha1 = npps4.system.handover.generate_passcode_sha1( 46 | target_user.invite_code, transfer_code 47 | ) 48 | print("Transfer ID:", target_user.invite_code) 49 | print("Transfer Passcode:", transfer_code) 50 | 51 | 52 | if __name__ == "__main__": 53 | import npps4.scriptutils.boot 54 | 55 | npps4.scriptutils.boot.start(run_script) 56 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_08_18_1341-f8b44a48b0ef_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: f8b44a48b0ef 4 | Revises: 3c0c34f09192 5 | Create Date: 2024-08-18 13:41:01.349881 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "f8b44a48b0ef" 17 | down_revision: Union[str, None] = "3c0c34f09192" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | with op.batch_alter_table("achievement", schema=None) as batch_op: 25 | batch_op.add_column(sa.Column("reset_type", sa.Integer(), nullable=False, server_default=sa.text("0"))) 26 | batch_op.add_column( 27 | sa.Column( 28 | "reset_value", 29 | sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), 30 | nullable=False, 31 | server_default=sa.text("0"), 32 | ) 33 | ) 34 | batch_op.create_index(batch_op.f("ix_achievement_reset_type"), ["reset_type"], unique=False) 35 | batch_op.create_index(batch_op.f("ix_achievement_reset_value"), ["reset_value"], unique=False) 36 | 37 | with op.batch_alter_table("achievement", schema=None) as batch_op: 38 | batch_op.alter_column("reset_type", nullable=False, server_default=None) 39 | batch_op.alter_column("reset_value", nullable=False, server_default=None) 40 | 41 | # ### end Alembic commands ### 42 | 43 | 44 | def downgrade() -> None: 45 | # ### commands auto generated by Alembic - please adjust! ### 46 | with op.batch_alter_table("achievement", schema=None) as batch_op: 47 | batch_op.drop_index(batch_op.f("ix_achievement_reset_value")) 48 | batch_op.drop_index(batch_op.f("ix_achievement_reset_type")) 49 | batch_op.drop_column("reset_value") 50 | batch_op.drop_column("reset_type") 51 | 52 | # ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_04_05_1441-930ed8d00ec1_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 930ed8d00ec1 4 | Revises: 58d5edd4f818 5 | Create Date: 2024-04-05 14:41:45.322772 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "930ed8d00ec1" 17 | down_revision: Union[str, None] = "58d5edd4f818" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | "exchange_point_item", 26 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 27 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 28 | sa.Column("exchange_point_id", sa.Integer(), nullable=False), 29 | sa.Column("amount", sa.Integer(), nullable=False), 30 | sa.ForeignKeyConstraint( 31 | ["user_id"], 32 | ["user.id"], 33 | ), 34 | sa.PrimaryKeyConstraint("id"), 35 | sa.UniqueConstraint("user_id", "exchange_point_id"), 36 | ) 37 | with op.batch_alter_table("exchange_point_item", schema=None) as batch_op: 38 | batch_op.create_index( 39 | batch_op.f("ix_exchange_point_item_exchange_point_id"), ["exchange_point_id"], unique=False 40 | ) 41 | batch_op.create_index(batch_op.f("ix_exchange_point_item_user_id"), ["user_id"], unique=False) 42 | 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade() -> None: 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | with op.batch_alter_table("exchange_point_item", schema=None) as batch_op: 49 | batch_op.drop_index(batch_op.f("ix_exchange_point_item_user_id")) 50 | batch_op.drop_index(batch_op.f("ix_exchange_point_item_exchange_point_id")) 51 | 52 | op.drop_table("exchange_point_item") 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /scripts/import_all_user.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import argparse 4 | import struct 5 | import traceback 6 | 7 | import npps4.config.config 8 | import npps4.db.main 9 | import npps4.idol 10 | import npps4.system.handover 11 | import npps4.system.lila 12 | 13 | 14 | async def run_script(arg: list[str]): 15 | parser = argparse.ArgumentParser(__file__) 16 | parser.add_argument("input", help="Exported data input file (binary)") 17 | parser.add_argument("--no-verify", action="store_true", help="Disable signature verification") 18 | args = parser.parse_args(arg) 19 | 20 | with open(args.input, "rb") as f: 21 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 22 | while True: 23 | signature_size = f.read(1) 24 | if len(signature_size) == 0: 25 | print("EOF reached. Completing import.") 26 | break 27 | 28 | # Read binary data 29 | signature = f.read(signature_size[0]) 30 | payload_size: int = struct.unpack(" None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | "exchange_item_limit", 26 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 27 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 28 | sa.Column("exchange_item_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 29 | sa.Column("count", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 30 | sa.ForeignKeyConstraint( 31 | ["user_id"], 32 | ["user.id"], 33 | ), 34 | sa.PrimaryKeyConstraint("id"), 35 | sa.UniqueConstraint("user_id", "exchange_item_id"), 36 | ) 37 | with op.batch_alter_table("exchange_item_limit", schema=None) as batch_op: 38 | batch_op.create_index(batch_op.f("ix_exchange_item_limit_exchange_item_id"), ["exchange_item_id"], unique=False) 39 | batch_op.create_index(batch_op.f("ix_exchange_item_limit_user_id"), ["user_id"], unique=False) 40 | 41 | # ### end Alembic commands ### 42 | 43 | 44 | def downgrade() -> None: 45 | # ### commands auto generated by Alembic - please adjust! ### 46 | with op.batch_alter_table("exchange_item_limit", schema=None) as batch_op: 47 | batch_op.drop_index(batch_op.f("ix_exchange_item_limit_user_id")) 48 | batch_op.drop_index(batch_op.f("ix_exchange_item_limit_exchange_item_id")) 49 | 50 | op.drop_table("exchange_item_limit") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /scripts/list_ach_scenario_unlock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python -m npps4.script 2 | import sqlalchemy 3 | 4 | import npps4.db.main 5 | import npps4.db.achievement 6 | import npps4.db.scenario 7 | import npps4.idol 8 | 9 | 10 | def select_en(jp: str, en: str | None): 11 | return en or jp 12 | 13 | 14 | async def run_script(args: list[str]): 15 | context = npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) 16 | 17 | async with context: 18 | # Get achievement that require main story unlocks 19 | q = sqlalchemy.select(npps4.db.achievement.Achievement).where( 20 | npps4.db.achievement.Achievement.achievement_type == 23 21 | ) 22 | result = await context.db.achievement.execute(q) 23 | main_story_ach = dict((a.achievement_id, a) for a in result.scalars()) 24 | 25 | fast_main_story_ach_lookup = set(main_story_ach.keys()) 26 | q = sqlalchemy.select(npps4.db.achievement.Story).where( 27 | npps4.db.achievement.Story.next_achievement_id.in_(main_story_ach.keys()) 28 | ) 29 | result = await context.db.achievement.execute(q) 30 | ach_trees = list(filter(lambda a: a.next_achievement_id in fast_main_story_ach_lookup, result.scalars())) 31 | 32 | for ach_id in sorted(ach_trees, key=lambda k: k.achievement_id): 33 | ach = main_story_ach[ach_id.next_achievement_id] 34 | scenario_id = int(ach.params1 or 0) 35 | scenario = await context.db.scenario.get(npps4.db.scenario.Scenario, scenario_id) 36 | assert scenario is not None 37 | scenario_chapter = await context.db.scenario.get(npps4.db.scenario.Chapter, scenario.scenario_chapter_id) 38 | assert scenario_chapter is not None 39 | scenario_chapter_name = select_en(scenario_chapter.name, scenario_chapter.name_en) 40 | scenario_name = select_en(scenario.title, scenario.title_en) 41 | print( 42 | f"{ach_id.achievement_id}: [item.Reward(add_type=ADD_TYPE.SCENARIO, item_id={scenario_id})], # Reward: {scenario_chapter_name} - {scenario_name}" 43 | ) 44 | 45 | 46 | if __name__ == "__main__": 47 | import npps4.scriptutils.boot 48 | 49 | npps4.scriptutils.boot.start(run_script) 50 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_04_05_1443-397436128a36_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 397436128a36 4 | Revises: 930ed8d00ec1 5 | Create Date: 2024-04-05 14:43:34.696856 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "397436128a36" 17 | down_revision: Union[str, None] = "930ed8d00ec1" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | UNIQUE_CONSTRAINT_ITEM_NAME = "uq_item_user_id_item_id" 22 | UNIQUE_CONSTRAINT_RECOVERY_ITEM_NAME = "uq_recovery_item_user_id_item_id" 23 | 24 | 25 | def upgrade() -> None: 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | with op.batch_alter_table("item", schema=None) as batch_op: 28 | batch_op.drop_index("ix_item_user_id") 29 | batch_op.create_index(batch_op.f("ix_item_user_id"), ["user_id"], unique=False) 30 | batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_ITEM_NAME, ["user_id", "item_id"]) 31 | 32 | with op.batch_alter_table("recovery_item", schema=None) as batch_op: 33 | batch_op.drop_index("ix_recovery_item_user_id") 34 | batch_op.create_index(batch_op.f("ix_recovery_item_user_id"), ["user_id"], unique=False) 35 | batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_RECOVERY_ITEM_NAME, ["user_id", "item_id"]) 36 | 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade() -> None: 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | with op.batch_alter_table("recovery_item", schema=None) as batch_op: 43 | batch_op.drop_constraint(UNIQUE_CONSTRAINT_ITEM_NAME, type_="unique") 44 | batch_op.drop_index(batch_op.f("ix_recovery_item_user_id")) 45 | batch_op.create_index("ix_recovery_item_user_id", ["user_id"], unique=1) 46 | 47 | with op.batch_alter_table("item", schema=None) as batch_op: 48 | batch_op.drop_constraint(UNIQUE_CONSTRAINT_RECOVERY_ITEM_NAME, type_="unique") 49 | batch_op.drop_index(batch_op.f("ix_item_user_id")) 50 | batch_op.create_index("ix_item_user_id", ["user_id"], unique=1) 51 | 52 | # ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /external/badwords.py: -------------------------------------------------------------------------------- 1 | # NPPS4 example Badwords Checker file. 2 | # This defines if a certain words are badwords or not. 3 | # Bad words are words that can't be used in team formation, names, etc. 4 | # You can specify other, vanilla Python file, but it must match the function specification below. 5 | # 6 | # This is free and unencumbered software released into the public domain. 7 | # 8 | # Anyone is free to copy, modify, publish, use, compile, sell, or distribute 9 | # this software, either in source code form or as a compiled binary, for any 10 | # purpose, commercial or non-commercial, and by any means. 11 | # 12 | # In jurisdictions that recognize copyright laws, the author or authors of this 13 | # software dedicate any and all copyright interest in the software to the public 14 | # domain. We make this dedication for the benefit of the public at large and to 15 | # the detriment of our heirs and successors. We intend this dedication to be an 16 | # overt act of relinquishment in perpetuity of all present and future rights to 17 | # this software under copyright law. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 23 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | # 26 | # For more information, please refer to 27 | 28 | import re 29 | 30 | import npps4.data 31 | import npps4.idol 32 | 33 | STRIP_WHITESPACE = re.compile(r"\s+") 34 | 35 | 36 | # Badwords Checker must define "has_badwords" function with these parameters: 37 | # * "text" (str) 38 | # * "context" (npps4.idol.BasicSchoolIdolContext) to access the database. 39 | # 40 | # It then returns a boolean if the specified text contains badword. 41 | async def has_badwords(text: str, context: npps4.idol.BasicSchoolIdolContext) -> bool: 42 | new_text = re.sub(STRIP_WHITESPACE, "", text.lower()) 43 | server_data = npps4.data.get() 44 | 45 | for badword in server_data.badwords: 46 | if badword in new_text: 47 | return True 48 | 49 | return False 50 | -------------------------------------------------------------------------------- /npps4/db/common.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import sqlalchemy 4 | import sqlalchemy.orm 5 | import sqlalchemy.dialects.postgresql 6 | import sqlalchemy.dialects.mysql 7 | import sqlalchemy.dialects.oracle 8 | import sqlalchemy.dialects.sqlite 9 | 10 | 11 | IDInteger = sqlalchemy.BigInteger().with_variant(sqlalchemy.dialects.sqlite.INTEGER(), "sqlite") 12 | 13 | 14 | type_map_override = { 15 | str: sqlalchemy.Text(), 16 | float: sqlalchemy.Float(16) 17 | .with_variant(sqlalchemy.dialects.sqlite.REAL(), "sqlite") 18 | .with_variant(sqlalchemy.dialects.oracle.BINARY_DOUBLE(), "oracle") 19 | .with_variant(sqlalchemy.dialects.mysql.DOUBLE(), "mysql", "mariadb") 20 | .with_variant(sqlalchemy.dialects.postgresql.DOUBLE_PRECISION(), "postgresql"), 21 | } 22 | 23 | 24 | class PrettyPrinter: 25 | def __repr__(self): 26 | t = type(self) 27 | kvresult: list[str] = [] 28 | 29 | for var in t.__annotations__.keys(): 30 | if hasattr(self, var): 31 | kvresult.append(f"{var}={getattr(self, var)!r}") 32 | 33 | return f"{t.__name__}({', '.join(kvresult)})" 34 | 35 | 36 | class GameDBBase(sqlalchemy.orm.DeclarativeBase, PrettyPrinter): 37 | type_annotation_map = type_map_override 38 | 39 | 40 | SNAKECASE_RE1 = re.compile("(.)([A-Z][a-z]+)") 41 | SNAKECASE_RE2 = re.compile("__([A-Z])") 42 | SNAKECASE_RE3 = re.compile("([a-z0-9])([A-Z])") 43 | 44 | 45 | class Base(sqlalchemy.orm.MappedAsDataclass, sqlalchemy.orm.DeclarativeBase): 46 | type_annotation_map = type_map_override 47 | 48 | def __copy__(self): 49 | cls = self.__class__ 50 | table = cls.__table__ 51 | primary_key = set(t.name for t in table.primary_key) 52 | columns = dict((k, getattr(self, k)) for k in table.columns.keys() if k not in primary_key) 53 | dup = cls(**columns) 54 | return dup 55 | 56 | @sqlalchemy.orm.declared_attr.directive 57 | def __tablename__(cls): 58 | name = cls.__name__ 59 | name = re.sub(SNAKECASE_RE1, r"\1_\2", name) 60 | name = re.sub(SNAKECASE_RE2, r"_\1", name) 61 | name = re.sub(SNAKECASE_RE3, r"\1_\2", name) 62 | return name.lower() 63 | 64 | 65 | class MaybeEncrypted: 66 | release_tag: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 67 | _encryption_release_id: sqlalchemy.orm.Mapped[int | None] = sqlalchemy.orm.mapped_column() 68 | -------------------------------------------------------------------------------- /npps4/game/banner.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | from .. import util 5 | from ..system import user 6 | 7 | 8 | class BannerInfo(pydantic.BaseModel): 9 | banner_type: int 10 | target_id: int 11 | asset_path: str 12 | webview_url: str | None = None 13 | is_registered: bool | None = None 14 | fixed_flag: bool 15 | back_side: bool 16 | banner_id: int 17 | start_date: str 18 | end_date: str 19 | 20 | 21 | class BannerListResponse(pydantic.BaseModel): 22 | time_limit: str 23 | banner_list: list[BannerInfo] 24 | 25 | 26 | @idol.register("banner", "bannerList", exclude_none=True) 27 | async def banner_bannerlist(context: idol.SchoolIdolUserParams) -> BannerListResponse: 28 | current_user = await user.get_current(context) 29 | # TODO 30 | util.stub("banner", "bannerList", context.raw_request_data) 31 | 32 | return BannerListResponse( 33 | time_limit=util.timestamp_to_datetime(2147483647), 34 | banner_list=[ 35 | # SIF2 transfer banner 36 | BannerInfo( 37 | banner_type=18, 38 | target_id=1, 39 | asset_path=( 40 | "en/assets/image/handover/banner/banner_01.png" 41 | if context.lang == idol.Language.en 42 | else "assets/image/handover/banner/banner_01.png" 43 | ), 44 | is_registered=current_user.transfer_sha1 is not None, 45 | fixed_flag=False, 46 | back_side=False, 47 | banner_id=1800002, 48 | start_date=util.timestamp_to_datetime(1476522000), 49 | end_date=util.timestamp_to_datetime(2147483647), 50 | ), 51 | # TenFes banner 52 | BannerInfo( 53 | banner_type=2, 54 | target_id=1, 55 | asset_path=( 56 | "en/assets/image/webview/wv_ba_01.png" 57 | if context.lang == idol.Language.en 58 | else "assets/image/webview/wv_ba_01.png" 59 | ), 60 | webview_url="/", 61 | fixed_flag=False, 62 | back_side=True, 63 | banner_id=200001, 64 | start_date=util.timestamp_to_datetime(1476522000), 65 | end_date=util.timestamp_to_datetime(2147483647), 66 | ), 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /npps4/game/tutorial.py: -------------------------------------------------------------------------------- 1 | from .. import idol 2 | from ..idol import error 3 | from ..system import tutorial 4 | from ..system import user 5 | 6 | import pydantic 7 | 8 | 9 | class TutorialProgressRequest(pydantic.BaseModel): 10 | tutorial_state: int 11 | 12 | 13 | @idol.register("tutorial", "progress", batchable=False) 14 | async def tutorial_progress(context: idol.SchoolIdolUserParams, request: TutorialProgressRequest) -> None: 15 | current_user = await user.get_current(context) 16 | if current_user.tutorial_state == -1: 17 | raise error.IdolError(detail="Tutorial already finished") 18 | 19 | if current_user.tutorial_state == 0 and request.tutorial_state == 1: 20 | await tutorial.phase1(context, current_user) 21 | elif current_user.tutorial_state == 1 and request.tutorial_state == 2: 22 | await tutorial.phase2(context, current_user) 23 | elif current_user.tutorial_state == 2 and request.tutorial_state == 3: 24 | await tutorial.phase3(context, current_user) 25 | elif current_user.tutorial_state == 3 and request.tutorial_state == -1: 26 | await tutorial.finalize(context, current_user) 27 | else: 28 | raise error.IdolError(detail=f"Unknown state, u {current_user.tutorial_state} r {request.tutorial_state}") 29 | 30 | 31 | @idol.register("tutorial", "skip", batchable=False) 32 | async def tutorial_skip(context: idol.SchoolIdolUserParams) -> None: 33 | current_user = await user.get_current(context) 34 | if current_user.tutorial_state == -1: 35 | raise error.IdolError(detail="Tutorial already finished") 36 | 37 | match current_user.tutorial_state: 38 | case 0: 39 | await tutorial.phase1(context, current_user) 40 | await tutorial.phase2(context, current_user) 41 | await tutorial.phase3(context, current_user) 42 | await tutorial.finalize(context, current_user) 43 | case 1: 44 | await tutorial.phase2(context, current_user) 45 | await tutorial.phase3(context, current_user) 46 | await tutorial.finalize(context, current_user) 47 | case 2: 48 | await tutorial.phase3(context, current_user) 49 | await tutorial.finalize(context, current_user) 50 | case 3: 51 | await tutorial.finalize(context, current_user) 52 | case _: 53 | raise error.IdolError(detail=f"Invalid tutorial state: {current_user.tutorial_state}") 54 | -------------------------------------------------------------------------------- /npps4/webview/serialcode.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import fastapi 4 | import pydantic 5 | 6 | from .. import data 7 | from .. import idol 8 | from .. import serialcode 9 | from .. import util 10 | from ..app import app 11 | from ..system import user 12 | 13 | from typing import Annotated 14 | 15 | 16 | class SerialCodeAPIRequest(pydantic.BaseModel): 17 | key: str 18 | 19 | 20 | class SerialCodeAPIResponse(pydantic.BaseModel): 21 | ok: bool 22 | msg: str 23 | 24 | 25 | @app.webview.get("/serialCode/index") 26 | async def serial_code_index(request: fastapi.Request): 27 | token = "" 28 | authorize_header = request.headers.get("Authorize") 29 | if authorize_header is not None: 30 | token = util.extract_token_from_authorize(authorize_header) 31 | 32 | return app.templates.TemplateResponse( 33 | request, "serialcode.html", {"token": token, "lang": request.headers.get("lang", "en")} 34 | ) 35 | 36 | 37 | @app.webview.post( 38 | "/serialCode/index", response_class=fastapi.responses.JSONResponse, response_model=SerialCodeAPIResponse 39 | ) 40 | async def serial_code_index_post( 41 | authorize: Annotated[str, fastapi.Header()], 42 | lang: Annotated[idol.Language, fastapi.Header(alias="LANG")], 43 | key_request: SerialCodeAPIRequest, 44 | ): 45 | try: 46 | async with idol.BasicSchoolIdolContext(lang) as context: 47 | token = util.extract_token_from_authorize(authorize) 48 | if token is None: 49 | return SerialCodeAPIResponse(ok=False, msg="Missing Token") 50 | 51 | current_user = await user.get_from_token(context, token) 52 | if current_user is None: 53 | return SerialCodeAPIResponse(ok=False, msg="Missing User") 54 | 55 | # Find the serial code 56 | input_code = key_request.key.strip() 57 | for serial_code in data.get().serial_codes: 58 | if serial_code.check_serial_code(input_code): 59 | # Found 60 | return SerialCodeAPIResponse( 61 | ok=True, msg=await serialcode.execute(context, current_user, input_code, serial_code) 62 | ) 63 | 64 | return SerialCodeAPIResponse(ok=False, msg="unknown or invalid serial code") 65 | except Exception as e: 66 | util.log("Error while running serial code", severity=util.logging.ERROR, e=e) 67 | return SerialCodeAPIResponse(ok=False, msg=str(e)) 68 | -------------------------------------------------------------------------------- /npps4/idol/cache.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | 3 | import sqlalchemy 4 | 5 | from . import session 6 | from .. import util 7 | from ..db import main 8 | 9 | from typing import cast 10 | 11 | ENABLE_CACHE = False 12 | 13 | 14 | async def load_response(context: session.SchoolIdolParams, endpoint: str): 15 | if ENABLE_CACHE: 16 | if isinstance(context, session.SchoolIdolUserParams) and context.nonce > 0: 17 | assert context.token is not None 18 | q = sqlalchemy.select(main.RequestCache).where( 19 | main.RequestCache.user_id == context.token.user_id, main.RequestCache.nonce == context.nonce 20 | ) 21 | result = await context.db.main.execute(q) 22 | 23 | cache = result.scalar() 24 | if cache is not None and cache.endpoint == endpoint: 25 | util.log("Cache for endpoint", endpoint, context.nonce, "FOUND!!") 26 | return cache.response 27 | 28 | util.log("Cache for endpoint", endpoint, context.nonce, "not found") 29 | return None 30 | 31 | 32 | async def store_response( 33 | context: session.SchoolIdolParams, endpoint: str, response_base: collections.abc.Sequence[int] 34 | ): 35 | if ENABLE_CACHE: 36 | if isinstance(context, session.SchoolIdolUserParams) and context.nonce > 0: 37 | assert context.token is not None 38 | user_id = context.token.user_id 39 | nonce = context.nonce 40 | q = sqlalchemy.select(main.RequestCache).where( 41 | main.RequestCache.user_id == user_id, main.RequestCache.nonce == nonce 42 | ) 43 | result = await context.db.main.execute(q) 44 | 45 | response = bytes(response_base) 46 | cache = result.scalar() 47 | if cache is None: 48 | cache = main.RequestCache(user_id=user_id, endpoint=endpoint, nonce=nonce, response=response) 49 | context.db.main.add(cache) 50 | else: 51 | cache.endpoint = endpoint 52 | cache.response = response 53 | await context.db.main.flush() 54 | util.log("Stored cache for endpoint", endpoint, context.nonce) 55 | 56 | 57 | async def clear(context: session.BasicSchoolIdolContext, user_id: int): 58 | q = sqlalchemy.delete(main.RequestCache).where(main.RequestCache.user_id == user_id) 59 | result = cast(sqlalchemy.CursorResult, await context.db.main.execute(q)) 60 | return result.rowcount 61 | -------------------------------------------------------------------------------- /npps4/scriptutils/user.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sqlalchemy 3 | 4 | from .. import idol 5 | from .. import util 6 | from ..db import main 7 | from ..game import login 8 | from ..system import tos 9 | from ..system import tutorial 10 | from ..system import unit 11 | 12 | 13 | async def from_invite(context: idol.BasicSchoolIdolContext, invite_code: str): 14 | q = sqlalchemy.select(main.User).where(main.User.invite_code == invite_code) 15 | result = await context.db.main.execute(q) 16 | return result.scalar() 17 | 18 | 19 | async def from_id(context: idol.BasicSchoolIdolContext, uid: int): 20 | return await context.db.main.get(main.User, uid) 21 | 22 | 23 | def register_args(parser): 24 | parser.add_argument("-u", "--user-id", type=int, help="User ID.") 25 | parser.add_argument("-i", "--invite-code", type=str, help="Invite Code.") 26 | 27 | 28 | async def from_args(context: idol.BasicSchoolIdolContext, args: argparse.Namespace): 29 | if args.user_id is not None: 30 | target_user = await from_id(context, args.user_id) 31 | else: 32 | target_user = await from_invite(context, args.invite_code) 33 | 34 | if target_user is None: 35 | raise Exception("no such user") 36 | 37 | return target_user 38 | 39 | 40 | async def simulate_completion( 41 | context: idol.BasicSchoolIdolContext, /, user: main.User, unit_initial_set_id: int | None = None 42 | ): 43 | await tos.agree(context, user, 1) 44 | 45 | if unit_initial_set_id is None: 46 | unit_initial_set_id = util.SYSRAND.choice(range(1, 19)) 47 | 48 | target = unit_initial_set_id - 1 49 | unit_ids = login._generate_deck_list(login.INITIAL_UNIT_IDS[target // 9][target % 9]) 50 | 51 | units: list[main.Unit] = [] 52 | for uid in unit_ids: 53 | unit_object = await unit.add_unit_simple(context, user, uid, True) 54 | if unit_object is None: 55 | raise RuntimeError("unable to add units") 56 | 57 | units.append(unit_object) 58 | 59 | # Idolize center 60 | center = units[4] 61 | await unit.idolize(context, user, center) 62 | await unit.set_unit_center(context, user, center) 63 | 64 | deck, _ = await unit.load_unit_deck(context, user, 1, True) 65 | await unit.save_unit_deck(context, user, deck, [u.id for u in units]) 66 | 67 | # Simulate tutorial 68 | await tutorial.phase1(context, user) 69 | await tutorial.phase2(context, user) 70 | await tutorial.phase3(context, user) 71 | await tutorial.finalize(context, user) 72 | 73 | await context.db.main.flush() 74 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | # GitHub actions workflow which builds and publishes the docker images. 2 | 3 | name: Build docker images 4 | 5 | on: 6 | workflow_run: 7 | branches: [master] 8 | workflows: [Build] 9 | types: [completed] 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | packages: write 15 | id-token: write # needed for signing the images with GitHub OIDC Token 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 21 | steps: 22 | - name: Set up QEMU 23 | id: qemu 24 | uses: docker/setup-qemu-action@v3 25 | with: 26 | platforms: arm64 27 | - name: Set up Docker Buildx 28 | id: buildx 29 | uses: docker/setup-buildx-action@v3 30 | - name: Inspect builder 31 | run: docker buildx inspect 32 | - name: Install Cosign 33 | uses: sigstore/cosign-installer@v3.5.0 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | - name: Extract version from NaN 37 | id: version 38 | run: echo "npps4_version=0.1" >> $GITHUB_OUTPUT 39 | - name: Log in to GHCR 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.repository_owner }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Calculate docker image tag 46 | id: set-tag 47 | uses: docker/metadata-action@master 48 | with: 49 | images: ghcr.io/${{ github.repository }} 50 | flavor: latest=true 51 | tags: type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} 52 | - name: Build and push all platforms 53 | id: build-and-push 54 | uses: docker/build-push-action@v6 55 | with: 56 | push: true 57 | context: '.' 58 | labels: | 59 | gitsha1=${{ github.sha }} 60 | org.opencontainers.image.version=${{ steps.version.outputs.npps4_version }} 61 | tags: "${{ steps.set-tag.outputs.tags }}" 62 | platforms: linux/amd64,linux/arm64 63 | build-args: | 64 | CARGO_NET_GIT_FETCH_WITH_CLI=true 65 | - name: Sign the images with GitHub OIDC Token 66 | env: 67 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 68 | TAGS: ${{ steps.set-tag.outputs.tags }} 69 | run: | 70 | images="" 71 | for tag in ${TAGS}; do 72 | images+="${tag}@${DIGEST} " 73 | done 74 | cosign sign --yes ${images} 75 | -------------------------------------------------------------------------------- /npps4/download/download.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | 3 | from . import none as none_backend 4 | 5 | from .. import idoltype 6 | from ..config import config, cfgtype 7 | 8 | from typing import Callable 9 | 10 | 11 | def n4dlapi_factory(): 12 | from . import n4dlapi as n4dlapi_backend 13 | 14 | return n4dlapi_backend 15 | 16 | 17 | def internal_factory(): 18 | from . import internal as internal_backend 19 | 20 | return internal_backend 21 | 22 | 23 | BACKEND_FACTORY: dict[str, Callable[[], cfgtype.DownloadBackendProtocol]] = { 24 | "none": lambda: none_backend, 25 | "n4dlapi": n4dlapi_factory, 26 | "internal": internal_factory, 27 | "custom": config.get_custom_download_protocol, 28 | } 29 | assert config.CONFIG_DATA.download.backend is not None 30 | _used_backend = BACKEND_FACTORY.get(config.CONFIG_DATA.download.backend) 31 | if _used_backend is None: 32 | raise Exception(f"Missing or unknown backend '{config.CONFIG_DATA.download.backend}'") 33 | 34 | CURRENT_BACKEND = _used_backend() 35 | 36 | 37 | def get_server_version(): 38 | global CURRENT_BACKEND 39 | assert CURRENT_BACKEND is not None 40 | return CURRENT_BACKEND.get_server_version() 41 | 42 | 43 | def get_db_path(name: str): 44 | global CURRENT_BACKEND 45 | assert CURRENT_BACKEND is not None 46 | return CURRENT_BACKEND.get_db_path(name) 47 | 48 | 49 | async def get_update_files( 50 | request: fastapi.Request, platform: idoltype.PlatformType, from_client_version: tuple[int, int] 51 | ): 52 | global CURRENT_BACKEND 53 | assert CURRENT_BACKEND is not None 54 | return await CURRENT_BACKEND.get_update_files(request, platform, from_client_version) 55 | 56 | 57 | async def get_batch_files( 58 | request: fastapi.Request, platform: idoltype.PlatformType, package_type: int, exclude: list[int] 59 | ): 60 | global CURRENT_BACKEND 61 | assert CURRENT_BACKEND is not None 62 | return await CURRENT_BACKEND.get_batch_files(request, platform, package_type, exclude) 63 | 64 | 65 | async def get_single_package( 66 | request: fastapi.Request, platform: idoltype.PlatformType, package_type: int, package_id: int 67 | ): 68 | global CURRENT_BACKEND 69 | assert CURRENT_BACKEND is not None 70 | return await CURRENT_BACKEND.get_single_package(request, platform, package_type, package_id) 71 | 72 | 73 | async def get_raw_files(request: fastapi.Request, platform: idoltype.PlatformType, files: list[str]): 74 | global CURRENT_BACKEND 75 | assert CURRENT_BACKEND is not None 76 | return await CURRENT_BACKEND.get_raw_files(request, platform, files) 77 | 78 | 79 | CURRENT_BACKEND.initialize() 80 | -------------------------------------------------------------------------------- /npps4/system/common.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | import functools 3 | 4 | import pydantic 5 | 6 | from . import item_model 7 | from . import live_model 8 | from . import scenario_model 9 | from . import unit_model 10 | from .. import idol 11 | from .. import util 12 | 13 | from typing import Callable, Hashable, cast 14 | 15 | 16 | AnyItem = unit_model.AnyUnitItem | scenario_model.ScenarioItem | live_model.LiveItem | item_model.Item 17 | 18 | 19 | class BeforeAfter[_T](pydantic.BaseModel): 20 | before: _T 21 | after: _T 22 | 23 | 24 | class BaseRewardInfo(pydantic.BaseModel): 25 | game_coin: int 26 | game_coin_reward_box_flag: bool 27 | 28 | 29 | class ItemCount(pydantic.BaseModel): 30 | item_id: int 31 | amount: int 32 | 33 | 34 | class TimestampMixin(pydantic.BaseModel): 35 | server_timestamp: int = pydantic.Field(default_factory=util.time) 36 | 37 | 38 | class CenterUnitInfo(pydantic.BaseModel): 39 | unit_id: int 40 | level: int 41 | love: int 42 | rank: int 43 | display_rank: int 44 | smile: int 45 | cute: int 46 | cool: int 47 | is_love_max: bool 48 | is_rank_max: bool 49 | is_level_max: bool 50 | unit_skill_exp: int 51 | removable_skill_ids: list[int] 52 | unit_removable_skill_capacity: int 53 | 54 | 55 | async def get_cached[T: Hashable, U]( 56 | context: idol.BasicSchoolIdolContext, 57 | key: str, 58 | id: T, 59 | miss: Callable[[idol.BasicSchoolIdolContext, T], collections.abc.Awaitable[U]], 60 | /, 61 | ): 62 | result: U | None = context.get_cache(key, id) 63 | 64 | if result is None: 65 | result = await miss(context, id) 66 | context.set_cache(key, id, result) 67 | 68 | return result 69 | 70 | 71 | def context_cacheable(cache_key: str): 72 | """Decorator to allow caching result based on current idol context.""" 73 | 74 | def wrap0[T: Hashable, U](f: Callable[[idol.BasicSchoolIdolContext, T], collections.abc.Awaitable[U]]): 75 | @functools.wraps(f) 76 | async def wrap(context: idol.BasicSchoolIdolContext, identifier: T, /): 77 | return await get_cached(context, cache_key, identifier, f) 78 | 79 | # In particular, Hashable and ParamSpec are umually exclusive. Making the type of "f" a generic that needs to 80 | # be said Callable is also not possible because Python doesn't allow nesting Generics. VSCode seems able to 81 | # preserve the type signature this way so ignore the errors for now. 82 | # Related: https://github.com/python/typeshed/issues/11280 83 | return cast(type(f), wrap) # type: ignore 84 | 85 | return wrap0 86 | -------------------------------------------------------------------------------- /npps4/system/subscenario.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | 3 | from . import common 4 | from .. import db 5 | from .. import idol 6 | from ..db import main 7 | from ..db import subscenario 8 | 9 | 10 | async def unlock(context: idol.BasicSchoolIdolContext, user: main.User, subscenario_id: int): 11 | if not await is_unlocked(context, user, subscenario_id): 12 | sc = main.SubScenario(user_id=user.id, subscenario_id=subscenario_id, completed=False) 13 | context.db.main.add(sc) 14 | await context.db.main.flush() 15 | return True 16 | 17 | return False 18 | 19 | 20 | async def get(context: idol.BasicSchoolIdolContext, user: main.User, subscenario_id: int): 21 | q = sqlalchemy.select(main.SubScenario).where( 22 | main.SubScenario.user_id == user.id, main.SubScenario.subscenario_id == subscenario_id 23 | ) 24 | result = await context.db.main.execute(q) 25 | return result.scalar() 26 | 27 | 28 | async def get_all(context: idol.BasicSchoolIdolContext, user: main.User): 29 | q = sqlalchemy.select(main.SubScenario).where(main.SubScenario.user_id == user.id) 30 | result = await context.db.main.execute(q) 31 | return list(result.scalars()) 32 | 33 | 34 | @common.context_cacheable("subscenario_valid") 35 | async def valid(context: idol.BasicSchoolIdolContext, subscenario_id: int): 36 | subscenario_data = await context.db.subscenario.get(subscenario.SubScenario, subscenario_id) 37 | return subscenario_data is not None 38 | 39 | 40 | async def is_unlocked(context: idol.BasicSchoolIdolContext, user: main.User, subscenario_id: int): 41 | sc = await get(context, user, subscenario_id) 42 | return sc is not None 43 | 44 | 45 | async def is_completed(context: idol.BasicSchoolIdolContext, user: main.User, subscenario_id: int): 46 | sc = await get(context, user, subscenario_id) 47 | if sc is not None: 48 | return sc.completed 49 | return False 50 | 51 | 52 | async def complete(context: idol.BasicSchoolIdolContext, user: main.User, subscenario_id: int): 53 | sc = await get(context, user, subscenario_id) 54 | if sc is None or sc.completed: 55 | return False 56 | 57 | sc.completed = True 58 | await context.db.main.flush() 59 | return True 60 | 61 | 62 | async def get_subscenario_id_of_unit_id(context: idol.BasicSchoolIdolContext, unit_id: int): 63 | q = sqlalchemy.select(subscenario.SubScenario).where(subscenario.SubScenario.unit_id == unit_id) 64 | result = await context.db.subscenario.execute(q) 65 | sc_info = db.decrypt_row(context.db.subscenario, result.scalar()) 66 | return sc_info.subscenario_id if sc_info is not None else 0 67 | -------------------------------------------------------------------------------- /templates/convert.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 62 | 63 | 64 | 65 |

SIF Capture to NPPS4

66 |

Please populate the JSON response data of particular endpoint.

67 |

/user/userInfo

68 | 69 |
70 |

/api (large one, the first one)

71 | 72 |
73 |

/api (small one, the second one)

74 | 75 |
76 | 77 | 78 |
79 | 80 |

81 |
82 |

Converted data goes here

83 | 84 |

Signature

85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /templates/serialcode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 70 | 71 | 72 | 73 |

Input Serial Code

74 | 75 | 76 | 77 | 78 |

79 | 80 | -------------------------------------------------------------------------------- /npps4/game/album.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from .. import idol 4 | 5 | from ..db import main 6 | from ..system import album 7 | from ..system import unit 8 | from ..system import user 9 | 10 | 11 | class AlbumInfo(pydantic.BaseModel): 12 | unit_id: int 13 | rank_max_flag: bool 14 | love_max_flag: bool 15 | rank_level_max_flag: bool 16 | all_max_flag: bool 17 | highest_love_per_unit: int 18 | total_love: int 19 | favorite_point: int 20 | sign_flag: bool 21 | 22 | 23 | class AlbumAllResponse(pydantic.RootModel[list[AlbumInfo]]): 24 | pass 25 | 26 | 27 | class AlbumSeriesInfo(pydantic.BaseModel): 28 | series_id: int 29 | unit_list: list[AlbumInfo] 30 | 31 | 32 | class AlbumSeriesAllResponse(pydantic.RootModel[list[AlbumSeriesInfo]]): 33 | pass 34 | 35 | 36 | def album_to_response(data: main.Album): 37 | return AlbumInfo( 38 | unit_id=data.unit_id, 39 | rank_max_flag=data.rank_max_flag, 40 | love_max_flag=data.love_max_flag, 41 | rank_level_max_flag=data.rank_level_max_flag, 42 | all_max_flag=data.rank_max_flag and data.love_max_flag and data.rank_level_max_flag, 43 | highest_love_per_unit=data.highest_love_per_unit, 44 | total_love=data.highest_love_per_unit, # TODO: Rectify this. 45 | favorite_point=data.favorite_point, 46 | sign_flag=data.sign_flag, 47 | ) 48 | 49 | 50 | def sort_by_series_id(data: AlbumSeriesInfo): 51 | return data.series_id 52 | 53 | 54 | def sort_by_unit_id(data: AlbumInfo): 55 | return data.unit_id 56 | 57 | 58 | @idol.register("album", "albumAll") 59 | async def album_albumall(context: idol.SchoolIdolUserParams) -> AlbumAllResponse: 60 | current_user = await user.get_current(context) 61 | all_album = await album.all(context, current_user) 62 | return AlbumAllResponse.model_validate([album_to_response(a) for a in all_album]) 63 | 64 | 65 | @idol.register("album", "seriesAll") 66 | async def album_seriesall(context: idol.SchoolIdolUserParams) -> AlbumSeriesAllResponse: 67 | current_user = await user.get_current(context) 68 | all_album = await album.all(context, current_user) 69 | series: dict[int, list[AlbumInfo]] = await album.all_series(context) 70 | 71 | for a in all_album: 72 | unit_info = await unit.get_unit_info(context, a.unit_id) 73 | if unit_info is not None and unit_info.album_series_id is not None: 74 | series[unit_info.album_series_id].append(album_to_response(a)) 75 | 76 | return AlbumSeriesAllResponse.model_validate( 77 | sorted( 78 | [AlbumSeriesInfo(series_id=k, unit_list=sorted(v, key=sort_by_unit_id)) for k, v in series.items()], 79 | key=sort_by_series_id, 80 | ) 81 | ) 82 | -------------------------------------------------------------------------------- /scripts/migrations/7_give_loveca_to_cleared_songs.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import sqlalchemy 4 | 5 | import npps4.idol 6 | import npps4.db.main 7 | import npps4.system.item 8 | import npps4.system.live 9 | import npps4.system.reward 10 | 11 | revision = "7_give_loveca_to_cleared_songs" 12 | prev_revision = "6_send_subunit_rewards_take2" 13 | 14 | FIXES_ACHIEVEMENT_IDS = [10090010, *range(10090012, 10090019)] 15 | 16 | 17 | async def main(context: npps4.idol.BasicSchoolIdolContext): 18 | q = sqlalchemy.select(npps4.db.main.User) 19 | 20 | async for target_user in await context.db.main.stream_scalars(q): 21 | live_difficulty_id_checked: set[int] = set() 22 | loveca = 0 23 | 24 | q = sqlalchemy.select(npps4.db.main.LiveClear).where(npps4.db.main.LiveClear.user_id == target_user.id) 25 | async for live_clear in await context.db.main.stream_scalars(q): 26 | if live_clear.live_difficulty_id in live_difficulty_id_checked: 27 | continue 28 | 29 | live_setting = await npps4.system.live.get_live_setting_from_difficulty_id( 30 | context, live_clear.live_difficulty_id 31 | ) 32 | if live_setting is None: 33 | raise ValueError(f"invalid live_difficulty_id {live_clear.live_difficulty_id}") 34 | 35 | if live_setting.difficulty > 3: 36 | # User cleared Expert or higher. Give loveca directly. 37 | loveca = loveca + 1 38 | else: 39 | # User cleared Easy, Normal, or Hard. Only give loveca if all is cleared. 40 | enh_list = ( 41 | await npps4.system.live.get_enh_live_difficulty_ids(context, live_clear.live_difficulty_id) 42 | ).copy() 43 | enh_list[live_setting.difficulty] = live_clear.live_difficulty_id 44 | cleared = True 45 | 46 | for i in range(1, 4): 47 | live_clear_data_adjacent = await npps4.system.live.get_live_clear_data( 48 | context, target_user, enh_list[i] 49 | ) 50 | if live_clear_data_adjacent is None or live_clear_data_adjacent.clear_cnt == 0: 51 | cleared = False 52 | break 53 | 54 | if cleared: 55 | loveca = loveca + 1 56 | live_difficulty_id_checked.update(enh_list.values()) 57 | 58 | if loveca > 0: 59 | print(f"Given {loveca} loveca to user {target_user.id} ({target_user.invite_code})") 60 | item = npps4.system.item.loveca(loveca) 61 | await npps4.system.reward.add_item(context, target_user, item, "First Live Clear Bonuses") 62 | -------------------------------------------------------------------------------- /npps4/system/museum.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | import sqlalchemy 3 | 4 | from .. import idol 5 | from ..db import main 6 | from ..db import museum 7 | 8 | 9 | class MuseumParameterData(pydantic.BaseModel): 10 | smile: int = 0 11 | pure: int = 0 12 | cool: int = 0 13 | 14 | 15 | class MuseumInfoData(pydantic.BaseModel): 16 | parameter: MuseumParameterData 17 | contents_id_list: list[int] 18 | 19 | 20 | class MuseumMixin(pydantic.BaseModel): 21 | museum_info: MuseumInfoData 22 | 23 | 24 | async def unlock(context: idol.BasicSchoolIdolContext, user: main.User, museum_contents_id: int): 25 | if (await context.db.museum.get(museum.MuseumContents, museum_contents_id)) is None: 26 | raise ValueError("invalid museum contents id") 27 | 28 | q = sqlalchemy.select(main.MuseumUnlock).where( 29 | main.MuseumUnlock.user_id == user.id, main.MuseumUnlock.museum_contents_id == museum_contents_id 30 | ) 31 | result = await context.db.main.execute(q) 32 | if result.scalar() is not None: 33 | return False 34 | 35 | museum_unlock = main.MuseumUnlock(user_id=user.id, museum_contents_id=museum_contents_id) 36 | context.db.main.add(museum_unlock) 37 | await context.db.main.flush() 38 | return True 39 | 40 | 41 | async def has(context: idol.BasicSchoolIdolContext, user: main.User, museum_contents_id: int): 42 | q = sqlalchemy.select(main.MuseumUnlock).where( 43 | main.MuseumUnlock.user_id == user.id, main.MuseumUnlock.museum_contents_id == museum_contents_id 44 | ) 45 | result = await context.db.main.execute(q) 46 | return result.scalar() is not None 47 | 48 | 49 | TEST_MUSEUM_UNLOCK_ALL = False 50 | 51 | 52 | async def get_museum_info_data(context: idol.BasicSchoolIdolContext, user: main.User): 53 | if TEST_MUSEUM_UNLOCK_ALL: 54 | q = sqlalchemy.select(museum.MuseumContents) 55 | result = await context.db.museum.execute(q) 56 | contents_id_list = [mu.museum_contents_id for mu in result.scalars()] 57 | else: 58 | q = sqlalchemy.select(main.MuseumUnlock).where(main.MuseumUnlock.user_id == user.id) 59 | result = await context.db.main.execute(q) 60 | contents_id_list = [mu.museum_contents_id for mu in result.scalars()] 61 | 62 | parameter = MuseumParameterData() 63 | q = sqlalchemy.select(museum.MuseumContents).where(museum.MuseumContents.museum_contents_id.in_(contents_id_list)) 64 | result = await context.db.museum.execute(q) 65 | 66 | for mu in result.scalars(): 67 | parameter.smile = parameter.smile + mu.smile_buff 68 | parameter.pure = parameter.pure + mu.pure_buff 69 | parameter.cool = parameter.cool + mu.cool_buff 70 | 71 | return MuseumInfoData(parameter=parameter, contents_id_list=contents_id_list) 72 | -------------------------------------------------------------------------------- /npps4/db/museum.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | import sqlalchemy.ext.asyncio 3 | import sqlalchemy.orm 4 | import sqlalchemy.pool 5 | 6 | from . import common 7 | from ..download import download 8 | 9 | 10 | class MuseumContents(common.GameDBBase, common.MaybeEncrypted): 11 | """```sql 12 | CREATE TABLE `museum_contents_m` ( 13 | `museum_contents_id` INTEGER NOT NULL, 14 | `museum_tab_category_id` INTEGER NOT NULL, 15 | `master_id` INTEGER, 16 | `thumbnail_asset` TEXT, 17 | `thumbnail_asset_en` TEXT, 18 | `title` TEXT NOT NULL, 19 | `title_en` TEXT, 20 | `category` TEXT NOT NULL, 21 | `category_en` TEXT, 22 | `museum_rarity` INTEGER NOT NULL, 23 | `attribute_id` INTEGER NOT NULL, 24 | `smile_buff` INTEGER NOT NULL, 25 | `pure_buff` INTEGER NOT NULL, 26 | `cool_buff` INTEGER NOT NULL, 27 | `sort_id` INTEGER NOT NULL, 28 | `release_tag` TEXT, `_encryption_release_id` INTEGER NULL, 29 | PRIMARY KEY (`museum_contents_id`) 30 | ) 31 | ```""" 32 | 33 | __tablename__ = "museum_contents_m" 34 | museum_contents_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column(primary_key=True) 35 | museum_tab_category_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 36 | master_id: sqlalchemy.orm.Mapped[int | None] = sqlalchemy.orm.mapped_column() 37 | thumbnail_asset: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 38 | thumbnail_asset_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 39 | title: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 40 | title_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 41 | category: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 42 | category_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 43 | museum_rarity: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 44 | attribute_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 45 | smile_buff: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 46 | pure_buff: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 47 | cool_buff: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 48 | sort_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 49 | 50 | 51 | engine = sqlalchemy.ext.asyncio.create_async_engine( 52 | f"sqlite+aiosqlite:///file:{download.get_db_path('museum')}?mode=ro&uri=true", 53 | poolclass=sqlalchemy.pool.NullPool, 54 | connect_args={"check_same_thread": False}, 55 | ) 56 | sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(engine) 57 | 58 | 59 | def get_sessionmaker(): 60 | global sessionmaker 61 | return sessionmaker 62 | -------------------------------------------------------------------------------- /npps4/config/cfgtype.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | 3 | import fastapi 4 | 5 | from .. import idoltype 6 | from ..download import dltype 7 | 8 | from typing import Literal, Protocol, Any 9 | 10 | 11 | class LoginBonusProtocol(Protocol): 12 | async def get_rewards( 13 | self, day: int, month: int, year: int, context 14 | ) -> tuple[int, int, int, tuple[str, str | None] | None]: ... 15 | 16 | 17 | class BadwordsCheckProtocol(Protocol): 18 | async def has_badwords(self, text: str, context) -> bool: ... 19 | 20 | 21 | class BeatmapData(Protocol): 22 | timing_sec: float 23 | notes_attribute: int 24 | notes_level: int 25 | effect: int 26 | effect_value: float 27 | position: int 28 | speed: float # Beatmap speed multipler 29 | vanish: Literal[0, 1, 2] # 0 = Normal; 1 = Note hidden as it approaches; 2 = Note shows just before its timing. 30 | 31 | 32 | class BeatmapProviderProtocol(Protocol): 33 | async def get_beatmap_data(self, livejson: str, context) -> collections.abc.Iterable[BeatmapData] | None: ... 34 | 35 | async def randomize_beatmaps( 36 | self, beatmap: collections.abc.Iterable[BeatmapData], seed: bytes, context 37 | ) -> collections.abc.Iterable[BeatmapData]: ... 38 | 39 | 40 | class LiveUnitDropProtocol(Protocol): 41 | async def get_live_drop_unit(self, live_setting_id: int, context) -> int: ... 42 | 43 | 44 | class DownloadBackendProtocol(Protocol): 45 | def initialize(self): ... 46 | 47 | def get_server_version(self) -> tuple[int, int]: ... 48 | 49 | def get_db_path(self, name: str) -> str: ... 50 | 51 | async def get_update_files( 52 | self, request: fastapi.Request, platform: idoltype.PlatformType, from_client_version: tuple[int, int] 53 | ) -> list[dltype.UpdateInfo]: ... 54 | 55 | async def get_batch_files( 56 | self, request: fastapi.Request, platform: idoltype.PlatformType, package_type: int, exclude: list[int] 57 | ) -> list[dltype.BatchInfo]: ... 58 | 59 | async def get_single_package( 60 | self, request: fastapi.Request, platform: idoltype.PlatformType, package_type: int, package_id: int 61 | ) -> list[dltype.BaseInfo] | None: ... 62 | 63 | async def get_raw_files( 64 | self, request: fastapi.Request, platform: idoltype.PlatformType, files: list[str] 65 | ) -> list[dltype.BaseInfo]: ... 66 | 67 | 68 | class LiveDropBoxResult(Protocol): 69 | new_live_effort_point_box_spec_id: int 70 | offer_limited_effort_event_id: int 71 | rewards: list[tuple[int, int, int, dict[str, Any] | None]] 72 | 73 | 74 | class LiveDropBoxProtocol(Protocol): 75 | async def process_effort_box( 76 | self, context, current_live_effort_point_box_spec_id: int, current_limited_effort_event_id: int, score: int 77 | ) -> LiveDropBoxResult: ... 78 | -------------------------------------------------------------------------------- /external/login_bonus.py: -------------------------------------------------------------------------------- 1 | # NPPS4 example Login Bonus Calendar file. 2 | # This defines rewards given to players during the daily login bonus. 3 | # You can specify other, vanilla Python file, but it must match 4 | # the function specification below. 5 | # 6 | # This is free and unencumbered software released into the public domain. 7 | # 8 | # Anyone is free to copy, modify, publish, use, compile, sell, or distribute 9 | # this software, either in source code form or as a compiled binary, for any 10 | # purpose, commercial or non-commercial, and by any means. 11 | # 12 | # In jurisdictions that recognize copyright laws, the author or authors of this 13 | # software dedicate any and all copyright interest in the software to the public 14 | # domain. We make this dedication for the benefit of the public at large and to 15 | # the detriment of our heirs and successors. We intend this dedication to be an 16 | # overt act of relinquishment in perpetuity of all present and future rights to 17 | # this software under copyright law. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 23 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | # 26 | # For more information, please refer to 27 | 28 | import datetime 29 | 30 | START_OF_EPOCH = datetime.date(1970, 1, 1) 31 | 32 | 33 | # Login bonus calendar file must define "get_rewards" async function with these parameters: 34 | # * "day" (int) 35 | # * "month" (int) 36 | # * "year" (int) 37 | # * "context" (npps4.idol.BasicSchoolIdolContext) to access the database. 38 | # 39 | # It then returns 4 values in a tuple in exactly this order: 40 | # * "add_type" (int), consult game_mater.db_ for details. 41 | # * "item_id" (int), consult game_mater.db_ and item.db_ for details (depends on add_type). 42 | # * "amount" (int) 43 | # * "special_day" (tuple[str, str|None]|None), If it's special day, specify special day image asset for Japanese 44 | # language and optionally for English language (which can be None). 45 | async def get_rewards(day: int, month: int, year: int, context) -> tuple[int, int, int, tuple[str, str | None] | None]: 46 | delta = datetime.date(year, month, day) - START_OF_EPOCH 47 | match delta.days % 3: 48 | case 0: 49 | # 20000 G 50 | return (3000, 3, 20000, None) 51 | case 1: 52 | # 2500 Friend Points 53 | return (3002, 2, 2500, None) 54 | case _: 55 | # 1 Loveca 56 | return (3001, 4, 1, None) 57 | -------------------------------------------------------------------------------- /scripts/inspect_export_data.py: -------------------------------------------------------------------------------- 1 | import npps4.script_dummy # isort:skip 2 | 3 | import argparse 4 | import base64 5 | import json 6 | import sys 7 | 8 | import npps4.config.config 9 | import npps4.idol 10 | import npps4.system.handover 11 | import npps4.system.lila 12 | 13 | 14 | def tobytesutf8(input: str): 15 | return input.encode("utf-8") 16 | 17 | 18 | async def run_script(arg: list[str]): 19 | parser = argparse.ArgumentParser(__file__) 20 | parser.add_argument("file", nargs="?", help="Base64-encoded exported account data.") 21 | parser.add_argument( 22 | "--signature", 23 | type=base64.urlsafe_b64decode, 24 | help="Signature data to verify the exported account data.", 25 | default=None, 26 | ) 27 | parser.add_argument( 28 | "--secret-key", 29 | type=tobytesutf8, 30 | default=npps4.config.config.get_secret_key(), 31 | help="Secret key used for signing the account data.", 32 | required=False, 33 | ) 34 | parser.add_argument("--compact", action="store_true", help="Print compact JSON representation.") 35 | args = parser.parse_args(arg) 36 | 37 | async with npps4.idol.BasicSchoolIdolContext(lang=npps4.idol.Language.en) as context: 38 | if args.file: 39 | with open(args.file, "rb") as f: 40 | contents = f.read() 41 | else: 42 | contents = sys.stdin.buffer.read() 43 | 44 | decoded_contents = base64.urlsafe_b64decode(contents) 45 | signature_text = "" 46 | exitcode = 0 47 | 48 | if args.signature: 49 | try: 50 | account_data = npps4.system.lila.extract_serialized_data( 51 | decoded_contents, args.signature, args.secret_key 52 | ) 53 | signature_text = "// Signature: Good" 54 | except npps4.system.lila.BadSignature: 55 | account_data = npps4.system.lila.extract_serialized_data(decoded_contents, None, args.secret_key) 56 | signature_text = "// Signature: Bad" 57 | exitcode = 1 58 | else: 59 | account_data = npps4.system.lila.extract_serialized_data(decoded_contents, None, args.secret_key) 60 | 61 | if signature_text: 62 | sys.stderr.writelines((signature_text,)) 63 | 64 | if args.compact: 65 | json_data = json.dumps(account_data.model_dump(), ensure_ascii=False, separators=(",", ":")) 66 | else: 67 | json_data = json.dumps(account_data.model_dump(), ensure_ascii=False, indent="\t") 68 | 69 | for i in range(0, len(json_data), 1024): 70 | sys.stdout.write(json_data[i : i + 1024]) 71 | 72 | sys.exit(exitcode) 73 | 74 | 75 | if __name__ == "__main__": 76 | import npps4.scriptutils.boot 77 | 78 | npps4.scriptutils.boot.start(run_script) 79 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_03_12_1858-5039725fabc6_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 5039725fabc6 4 | Revises: fea75afe02ba 5 | Create Date: 2024-03-12 18:58:06.611228 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "5039725fabc6" 17 | down_revision: Union[str, None] = "fea75afe02ba" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | "request_cache", 26 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 27 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 28 | sa.Column("endpoint", sa.Text(), nullable=False), 29 | sa.Column("nonce", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 30 | sa.Column("response", sa.LargeBinary(), nullable=False), 31 | sa.ForeignKeyConstraint( 32 | ["user_id"], 33 | ["user.id"], 34 | ), 35 | sa.PrimaryKeyConstraint("id"), 36 | sa.UniqueConstraint("user_id", "nonce"), 37 | ) 38 | with op.batch_alter_table("request_cache", schema=None) as batch_op: 39 | batch_op.create_index(batch_op.f("ix_request_cache_nonce"), ["nonce"], unique=False) 40 | batch_op.create_index(batch_op.f("ix_request_cache_user_id"), ["user_id"], unique=False) 41 | 42 | op.create_table( 43 | "session", 44 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 45 | sa.Column("token", sa.Text(), nullable=False), 46 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=True), 47 | sa.Column("client_key", sa.LargeBinary(), nullable=False), 48 | sa.Column("server_key", sa.LargeBinary(), nullable=False), 49 | sa.Column("last_accessed", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 50 | sa.ForeignKeyConstraint( 51 | ["user_id"], 52 | ["user.id"], 53 | ), 54 | sa.PrimaryKeyConstraint("id"), 55 | sa.UniqueConstraint("token"), 56 | ) 57 | # ### end Alembic commands ### 58 | 59 | 60 | def downgrade() -> None: 61 | # ### commands auto generated by Alembic - please adjust! ### 62 | op.drop_table("session") 63 | with op.batch_alter_table("request_cache", schema=None) as batch_op: 64 | batch_op.drop_index(batch_op.f("ix_request_cache_user_id")) 65 | batch_op.drop_index(batch_op.f("ix_request_cache_nonce")) 66 | 67 | op.drop_table("request_cache") 68 | # ### end Alembic commands ### 69 | -------------------------------------------------------------------------------- /npps4/system/secretbox_model.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from . import common 4 | from .. import const 5 | 6 | 7 | class SecretboxGaugeInfo(pydantic.BaseModel): 8 | max_gauge_point: int = 100 9 | gauge_point: int = 0 10 | 11 | 12 | class SecretboxAllAnimation2Asset(pydantic.BaseModel): 13 | type: const.SECRETBOX_ANIMATION_TYPE 14 | background_asset: str 15 | additional_asset_1: str 16 | additional_asset_2: str 17 | 18 | 19 | class SecretboxAllAnimation3Asset(SecretboxAllAnimation2Asset): 20 | additional_asset_3: str 21 | 22 | 23 | AnySecretboxAllAnimationAsset = SecretboxAllAnimation3Asset | SecretboxAllAnimation2Asset 24 | 25 | 26 | class SecretboxAllCost(pydantic.BaseModel): 27 | id: int 28 | payable: bool 29 | unit_count: int 30 | type: const.SECRETBOX_COST_TYPE 31 | # Note: IF "type" above is "LOVECA", then possible item_id values: 32 | # * 1: Paid loveca 33 | # * anything else: Free loveca + paid loveca 34 | item_id: int | None = None 35 | amount: int 36 | 37 | 38 | class SecretboxAllButton(pydantic.BaseModel): 39 | secret_box_button_type: const.SECRETBOX_BUTTON_TYPE 40 | cost_list: list[SecretboxAllCost] 41 | secret_box_name: str 42 | 43 | 44 | class SecretboxAllButtonWithBaloon(SecretboxAllButton): 45 | balloon_asset: str | None = None 46 | 47 | 48 | class SecretboxAllButtonShowCost(pydantic.BaseModel): 49 | cost_type: const.SECRETBOX_COST_TYPE 50 | item_id: int | None = None 51 | unit_count: int 52 | 53 | 54 | class SecretboxAllButtonWithShowCost(SecretboxAllButton): 55 | show_cost: SecretboxAllButtonShowCost 56 | 57 | 58 | AnySecretboxButton = SecretboxAllButton | SecretboxAllButtonWithShowCost | SecretboxAllButtonWithBaloon 59 | 60 | 61 | class SecretboxAllSecretboxInfo(pydantic.BaseModel): 62 | secret_box_id: int 63 | # Some button naming difference: 64 | # DEFAULT: Uses "name" field in this class when pressing button. 65 | # STUB: Uses "secret_box_name" field in the button list. 66 | secret_box_type: const.SECRETBOX_LAYOUT_TYPE 67 | name: str 68 | description: str | None = None 69 | start_date: str 70 | end_date: str 71 | add_gauge: int 72 | always_display_flag: int 73 | pon_count: int 74 | pon_upper_limit: int = 0 75 | 76 | 77 | class SecretboxAllSecretboxInfoWithShowEndDate(SecretboxAllSecretboxInfo): 78 | show_end_date: str 79 | 80 | 81 | AnySecretboxInfo = SecretboxAllSecretboxInfo | SecretboxAllSecretboxInfoWithShowEndDate 82 | 83 | 84 | class SecretboxAllPage(pydantic.BaseModel): 85 | menu_asset: str 86 | page_order: int 87 | animation_assets: AnySecretboxAllAnimationAsset 88 | button_list: list[AnySecretboxButton] 89 | secret_box_info: AnySecretboxInfo 90 | 91 | 92 | class SecretboxAllMemberCategory(pydantic.BaseModel): 93 | member_category: int 94 | page_list: list[SecretboxAllPage] 95 | -------------------------------------------------------------------------------- /npps4/db/effort.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | import sqlalchemy.ext.asyncio 3 | import sqlalchemy.orm 4 | import sqlalchemy.pool 5 | 6 | from . import common 7 | from ..download import download 8 | 9 | 10 | class LiveEffortPointBoxSpec(common.GameDBBase, common.MaybeEncrypted): 11 | """```sql 12 | CREATE TABLE `live_effort_point_box_spec_m` ( 13 | `live_effort_point_box_spec_id` INTEGER NOT NULL, 14 | `capacity` INTEGER NOT NULL, 15 | `limited_capacity` INTEGER NOT NULL, 16 | `num_rewards` INTEGER NOT NULL, 17 | `closed_asset` TEXT NOT NULL, 18 | `closed_asset_en` TEXT, 19 | `opened_asset` TEXT NOT NULL, 20 | `opened_asset_en` TEXT, 21 | `box_asset` TEXT NOT NULL, 22 | `box_asset_en` TEXT, 23 | `login_box_asset` TEXT NOT NULL, 24 | `login_box_asset_en` TEXT, 25 | `movie_name` TEXT NOT NULL, 26 | `movie_name_en` TEXT, 27 | `asset_se_id` INTEGER NOT NULL, 28 | `release_tag` TEXT, `_encryption_release_id` INTEGER NULL, 29 | PRIMARY KEY (`live_effort_point_box_spec_id`) 30 | )``` 31 | """ 32 | 33 | __tablename__ = "live_effort_point_box_spec_m" 34 | live_effort_point_box_spec_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column( 35 | common.IDInteger, primary_key=True 36 | ) 37 | capacity: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 38 | limited_capacity: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 39 | num_rewards: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 40 | closed_asset: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 41 | closed_asset_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 42 | opened_asset: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 43 | opened_asset_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 44 | box_asset: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 45 | box_asset_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 46 | login_box_asset: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 47 | login_box_asset_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 48 | movie_name: sqlalchemy.orm.Mapped[str] = sqlalchemy.orm.mapped_column() 49 | movie_name_en: sqlalchemy.orm.Mapped[str | None] = sqlalchemy.orm.mapped_column() 50 | asset_se_id: sqlalchemy.orm.Mapped[int] = sqlalchemy.orm.mapped_column() 51 | 52 | 53 | engine = sqlalchemy.ext.asyncio.create_async_engine( 54 | f"sqlite+aiosqlite:///file:{download.get_db_path('effort')}?mode=ro&uri=true", 55 | poolclass=sqlalchemy.pool.NullPool, 56 | connect_args={"check_same_thread": False}, 57 | ) 58 | sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(engine) 59 | 60 | 61 | def get_sessionmaker(): 62 | global sessionmaker 63 | return sessionmaker 64 | -------------------------------------------------------------------------------- /npps4/alembic/versions/2024_04_04_1044-58d5edd4f818_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 58d5edd4f818 4 | Revises: 5039725fabc6 5 | Create Date: 2024-04-04 10:44:23.813581 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "58d5edd4f818" 17 | down_revision: Union[str, None] = "5039725fabc6" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.create_table( 25 | "item", 26 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 27 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 28 | sa.Column("item_id", sa.Integer(), nullable=False), 29 | sa.Column("amount", sa.Integer(), nullable=False), 30 | sa.ForeignKeyConstraint( 31 | ["user_id"], 32 | ["user.id"], 33 | ), 34 | sa.PrimaryKeyConstraint("id"), 35 | ) 36 | with op.batch_alter_table("item", schema=None) as batch_op: 37 | batch_op.create_index(batch_op.f("ix_item_item_id"), ["item_id"], unique=False) 38 | batch_op.create_index(batch_op.f("ix_item_user_id"), ["user_id"], unique=True) 39 | 40 | op.create_table( 41 | "recovery_item", 42 | sa.Column("id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 43 | sa.Column("user_id", sa.BigInteger().with_variant(sa.INTEGER(), "sqlite"), nullable=False), 44 | sa.Column("item_id", sa.Integer(), nullable=False), 45 | sa.Column("amount", sa.Integer(), nullable=False), 46 | sa.ForeignKeyConstraint( 47 | ["user_id"], 48 | ["user.id"], 49 | ), 50 | sa.PrimaryKeyConstraint("id"), 51 | ) 52 | with op.batch_alter_table("recovery_item", schema=None) as batch_op: 53 | batch_op.create_index(batch_op.f("ix_recovery_item_item_id"), ["item_id"], unique=False) 54 | batch_op.create_index(batch_op.f("ix_recovery_item_user_id"), ["user_id"], unique=True) 55 | 56 | # ### end Alembic commands ### 57 | 58 | 59 | def downgrade() -> None: 60 | # ### commands auto generated by Alembic - please adjust! ### 61 | with op.batch_alter_table("recovery_item", schema=None) as batch_op: 62 | batch_op.drop_index(batch_op.f("ix_recovery_item_user_id")) 63 | batch_op.drop_index(batch_op.f("ix_recovery_item_item_id")) 64 | 65 | op.drop_table("recovery_item") 66 | with op.batch_alter_table("item", schema=None) as batch_op: 67 | batch_op.drop_index(batch_op.f("ix_item_user_id")) 68 | batch_op.drop_index(batch_op.f("ix_item_item_id")) 69 | 70 | op.drop_table("item") 71 | # ### end Alembic commands ### 72 | -------------------------------------------------------------------------------- /npps4/alembic/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import pool 5 | from sqlalchemy.engine import Connection 6 | from sqlalchemy.ext.asyncio import create_async_engine 7 | 8 | from alembic import context 9 | from alembic.ddl.sqlite import SQLiteImpl 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | import npps4.db.main as npps4_model 25 | import npps4.config.config as npps4_config 26 | import npps4.evloop 27 | 28 | target_metadata = npps4_model.common.Base.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline() -> None: 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = npps4_config.get_database_url() 49 | context.configure( 50 | url=url, 51 | target_metadata=target_metadata, 52 | literal_binds=True, 53 | dialect_opts={"paramstyle": "named"}, 54 | render_as_batch=True, 55 | ) 56 | 57 | with context.begin_transaction(): 58 | context.run_migrations() 59 | 60 | 61 | def do_run_migrations(connection: Connection) -> None: 62 | context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True) 63 | 64 | with context.begin_transaction(): 65 | context.run_migrations() 66 | 67 | 68 | async def run_async_migrations() -> None: 69 | """In this scenario we need to create an Engine 70 | and associate a connection with the context. 71 | 72 | """ 73 | 74 | connectable = create_async_engine( 75 | npps4_config.get_database_url(), poolclass=pool.NullPool, echo=True, connect_args={"autocommit": False} 76 | ) 77 | # https://github.com/sqlalchemy/alembic/issues/1655 78 | SQLiteImpl.transactional_ddl = True 79 | 80 | async with connectable.connect() as connection: 81 | await connection.run_sync(do_run_migrations) 82 | 83 | await connectable.dispose() 84 | 85 | 86 | def run_migrations_online() -> None: 87 | """Run migrations in 'online' mode.""" 88 | 89 | asyncio.run(run_async_migrations(), loop_factory=npps4.evloop.new_event_loop) 90 | 91 | 92 | if context.is_offline_mode(): 93 | run_migrations_offline() 94 | else: 95 | run_migrations_online() 96 | --------------------------------------------------------------------------------