├── .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 |
12 | - List Users
13 | - Server Configuration
14 |
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 | - {{ i[1]|e }}
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 |
12 | {% for user in users %}
13 | - {{ user.name }} : Unlock backgrounds
14 | {% endfor %}
15 |
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 | | Rarity |
24 | Weight |
25 | Percentage |
26 |
27 |
28 |
29 | {% for i in rates %}
30 |
31 | | {{ i[0] }} |
32 | {{ i[1] }} |
33 | {{ "%.2f" % (i[2] * 100) }}% |
34 |
35 | {% endfor %}
36 |
37 |
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 | {{ bg.description_en.replace('\\n','\n') }} |
14 | {% endfor %}
15 |
16 |
17 | Locked backgrounds
18 |
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 |
19 | {% for i in achievement.needs %}
20 | - {{ i[1]|e }}
21 | {% endfor %}
22 |
23 |
24 |
25 | Achievement Info
26 |
27 |
28 | | Parameter |
29 | Value |
30 |
31 | {% for i in achievement.params %}
32 |
33 | | {{ i[0] }} |
34 | {{ i[1] | safe }} |
35 |
36 | {% endfor %}
37 |
38 |
39 |
40 | Reward Data
41 |
42 |
43 |
44 | Opens
45 |
46 | {% for i in achievement.opens %}
47 | - {{ i[1]|e }}
48 | {% endfor %}
49 |
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 |
--------------------------------------------------------------------------------