From 3cffcd141036f972636e036d2e7d74c540e0cf54 Mon Sep 17 00:00:00 2001 From: beerpsi Date: Sat, 25 Nov 2023 12:54:15 +0700 Subject: [PATCH] push: ALL.Net and a little of CHUNITHM --- .gitignore | 2 + config.example.json5 | 81 ++ package.json | 4 +- pnpm-lock.yaml | 27 + src/external/db/data-source.ts | 16 +- src/external/db/entity/chunithm/item.ts | 80 ++ src/external/db/entity/chunithm/profile.ts | 753 ++++++++++++++++++ src/external/db/entity/chunithm/score.ts | 32 + src/external/db/utils/has-id-card-version.ts | 15 + src/lib/setup/config.ts | 21 +- src/servers/aimedb/handlers/aime-account.ts | 3 +- src/servers/aimedb/handlers/aime-log.ts | 2 +- src/servers/aimedb/handlers/campaign.ts | 2 +- src/servers/aimedb/handlers/status-check.ts | 2 +- src/servers/allnet/index.ts | 10 + .../sys/servlet/DownloadOrder/router.ts | 33 +- .../sys/servlet/LoaderStateRecorder/router.ts | 1 + src/servers/titles/chunithm/index.ts | 169 +++- src/servers/titles/chunithm/utils.ts | 23 + .../titles/chunithm/versions/100-base.ts | 385 ++++++++- .../chunithm/versions/135-amazonplus.ts | 43 + src/servers/titles/utils/buffer.ts | 36 + src/servers/titles/utils/string-checks.ts | 19 + src/utils/misc.ts | 4 + tsconfig.json | 5 + 25 files changed, 1691 insertions(+), 77 deletions(-) create mode 100644 config.example.json5 create mode 100644 src/external/db/entity/chunithm/item.ts create mode 100644 src/external/db/entity/chunithm/score.ts create mode 100644 src/external/db/utils/has-id-card-version.ts create mode 100644 src/servers/allnet/router/sys/servlet/LoaderStateRecorder/router.ts create mode 100644 src/servers/titles/chunithm/utils.ts create mode 100644 src/servers/titles/utils/buffer.ts create mode 100644 src/servers/titles/utils/string-checks.ts diff --git a/.gitignore b/.gitignore index a29e4e4..effcc42 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,8 @@ dist # End of https://www.toptal.com/developers/gitignore/api/node +data +logs *.sqlite3 *.sqlite3-shm *.sqlite3-wal diff --git a/config.example.json5 b/config.example.json5 new file mode 100644 index 0000000..1da973a --- /dev/null +++ b/config.example.json5 @@ -0,0 +1,81 @@ +{ + // Used as ALL.Net arcade name for unregistered arcades + NAME: "KozukataToa", + DATABASE_PATH: "data/db.sqlite3", + LISTEN_ADDRESS: "0.0.0.0", + LOGGER_CONFIG: { + LOG_LEVEL: "debug", + CONSOLE: true, + // Enter a valid path to save log files. + FOLDER: "", + }, + ALLNET_CONFIG: { + ENABLED: true, + PORT: 80, + ALLOW_UNREGISTERED_SERIALS: true, + // Only used for network delivery. + HOSTNAME: "localhost", + // Enter a valid path here to enable remote updates. + UPDATE_CFG_FOLDER: "", + }, + AIMEDB_CONFIG: { + ENABLED: true, + PORT: 22345, + // Really. That's the key. + KEY: "Copyright(C)SEGA", + // These keys are for generating old-style access codes from FeliCa IDms. + // If you want to roll your own keys, just shuffle the 16 hexadecimal characters. + AIME_MOBILE_CARD_KEY: "5CD3E81B9024F67A", + RESERVED_CARD_PREFIX: "01054", + RESERVED_CARD_KEY: "82DAF451B3E076C9", + }, + TITLES_CONFIG: { + ENABLED: true, + PORT: 8080, + // You may need to change this if title server connection is BAD. Some games + // explicitly disallows localhost and similar addresses, but other games don't. + HOSTNAME: "localhost", + }, + CHUNITHM_CONFIG: { + ENABLE: true, + MODS: { + // Enter a name to display as team in CHUNTHM. + TEAM_NAME: "", + // Enable login bonuses + USE_LOGIN_BONUS: true, + // You have everything. + FORCE_UNLOCK_ALL: false, + }, + // The ROM and data version to report to the game. This must match your game, + // or else you will not be able to play some game modes. + // - ROM version: Match with "GAME SYSTEM INFORMATION" in test menu. + // - Data version: Match with data.conf file in latest option (last in alphabetical order) + VERSIONS: { + "200": { + rom: "2.00.00", + data: "2.00.00", + }, + "205": { + rom: "2.05.00", + data: "2.05.00", + }, + "210": { + rom: "2.10.00", + data: "2.10.00", + }, + }, + // Since CRYSTAL+, network communications are encrypted and the endpoint is hashed. + // Enter the [key, iv, salt] used for that here. + // Optionally, set ENCRYPTED_ONLY to true to disallow unencrypted network communications. + CRYPTO: { + ENCRYPTED_ONLY: false, + KEYS: { + "210": [ + "75695c3d265f434c3953454c5830522b4b3c4d7b42482a312e5627216b2b4060", + "31277c37707044377141595058345a6b", + "04780206ca5f36f4", + ], + }, + }, + } +} \ No newline at end of file diff --git a/package.json b/package.json index efa0772..7569fbb 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,10 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-async-errors": "^3.1.1", - "fletcher": "^0.0.3", "iconv-lite": "^0.6.3", "json5": "^2.2.3", "luxon": "^3.4.4", - "micro-packed": "^0.3.2", + "on-headers": "^1.0.2", "raw-body": "^2.5.2", "reflect-metadata": "^0.1.13", "safe-json-stringify": "^1.2.0", @@ -50,6 +49,7 @@ "@types/iconv": "^3.0.4", "@types/luxon": "^3.3.4", "@types/node": "16", + "@types/on-headers": "^1.0.3", "@types/safe-json-stringify": "^1.1.5", "@typescript-eslint/eslint-plugin": "5.47.1", "@typescript-eslint/parser": "5.47.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1643761..fedb50d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ specifiers: '@types/iconv': ^3.0.4 '@types/luxon': ^3.3.4 '@types/node': '16' + '@types/on-headers': ^1.0.3 '@types/safe-json-stringify': ^1.1.5 '@typescript-eslint/eslint-plugin': 5.47.1 '@typescript-eslint/parser': 5.47.1 @@ -21,10 +22,12 @@ specifiers: json5: ^2.2.3 luxon: ^3.4.4 micro-packed: ^0.3.2 + on-headers: ^1.0.2 raw-body: ^2.5.2 reflect-metadata: ^0.1.13 safe-json-stringify: ^1.2.0 sqlite3: ^5.1.6 + stream-combiner: ^0.2.2 tap: ^18.6.1 tsconfig-paths: ^4.2.0 typed-struct: ^2.3.0 @@ -45,10 +48,12 @@ dependencies: json5: 2.2.3 luxon: 3.4.4 micro-packed: 0.3.2 + on-headers: 1.0.2 raw-body: 2.5.2 reflect-metadata: 0.1.13 safe-json-stringify: 1.2.0 sqlite3: 5.1.6 + stream-combiner: 0.2.2 tsconfig-paths: 4.2.0 typed-struct: 2.3.0_iconv-lite@0.6.3 typeorm: 0.3.17_sqlite3@5.1.6 @@ -64,6 +69,7 @@ devDependencies: '@types/iconv': 3.0.4 '@types/luxon': 3.3.4 '@types/node': 16.18.62 + '@types/on-headers': 1.0.3 '@types/safe-json-stringify': 1.1.5 '@typescript-eslint/eslint-plugin': 5.47.1_o6yrxajvsx2b7l3udqdd2yq4ii '@typescript-eslint/parser': 5.47.1_4njjt2tu6ubn7rwbgcnueihm54 @@ -942,6 +948,12 @@ packages: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true + /@types/on-headers/1.0.3: + resolution: {integrity: sha512-jvGNvFo8uOL6fiBGvD4Ul4lT8mZoJ57l3h0ZN/a1oHziTTXUV3slaRcYm2K1wvvLX1fhIg9AvKykxKFt3mM+Xg==} + dependencies: + '@types/node': 16.18.62 + dev: true + /@types/qs/6.9.10: resolution: {integrity: sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==} dev: true @@ -1825,6 +1837,10 @@ packages: engines: {node: '>=12'} dev: false + /duplexer/0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + dev: false + /eastasianwidth/0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -4560,6 +4576,13 @@ packages: engines: {node: '>= 0.8'} dev: false + /stream-combiner/0.2.2: + resolution: {integrity: sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==} + dependencies: + duplexer: 0.1.2 + through: 2.3.8 + dev: false + /string-length/6.0.0: resolution: {integrity: sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==} engines: {node: '>=16'} @@ -4792,6 +4815,10 @@ packages: any-promise: 1.3.0 dev: false + /through/2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + dev: false + /to-regex-range/5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} diff --git a/src/external/db/data-source.ts b/src/external/db/data-source.ts index 8726423..d32599a 100644 --- a/src/external/db/data-source.ts +++ b/src/external/db/data-source.ts @@ -1,26 +1,14 @@ import "reflect-metadata"; -import { AimeCard, AimeUser, FelicaCardLookup, FelicaMobileLookup } from "./entity/aimedb"; -import { Arcade, Machine } from "./entity/allnet"; -import { EventLog } from "./entity/base"; -import { ChunithmStaticEvent } from "./entity/chunithm/static"; import { Config } from "lib/setup/config"; import { DataSource } from "typeorm"; +import { join } from "path"; export const AppDataSource = new DataSource({ type: "sqlite", database: Config.DATABASE_PATH, synchronize: true, logging: false, - entities: [ - Arcade, - AimeCard, - AimeUser, - ChunithmStaticEvent, - EventLog, - FelicaCardLookup, - FelicaMobileLookup, - Machine, - ], + entities: [join(__dirname, "entity", "**", "*.{ts,js}")], migrations: [], subscribers: [], enableWAL: true, diff --git a/src/external/db/entity/chunithm/item.ts b/src/external/db/entity/chunithm/item.ts new file mode 100644 index 0000000..bb2089e --- /dev/null +++ b/src/external/db/entity/chunithm/item.ts @@ -0,0 +1,80 @@ +import { AimeCard } from "../aimedb"; +import { ConstructableBaseEntity } from "external/db/utils/constructable-base-entity"; +import { HasIdCardVersion } from "external/db/utils/has-id-card-version"; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, Unique } from "typeorm"; +import { integer } from "types/misc"; + +@Entity("chunithm_user_character") +@Unique(["card", "characterId"]) +export class ChunithmUserCharacter extends ConstructableBaseEntity { + @PrimaryGeneratedColumn() + id!: integer; + + @ManyToOne(() => AimeCard, { eager: true }) + card!: AimeCard; + + @Column() + characterId!: integer; + + @Column() + skillId!: integer; + + @Column({ default: 1 }) + level!: integer; + + @Column({ default: 0 }) + param1!: integer; + + @Column({ default: 0 }) + param2!: integer; + + @Column({ type: "boolean", default: true }) + isValid!: boolean; + + @Column({ type: "boolean", default: true }) + isNewMark!: boolean; + + @Column({ default: 0 }) + playCount!: integer; + + @Column({ default: 0 }) + friendshipExp!: integer; + + @Column({ default: 0 }) + assignIllust!: integer; + + @Column({ default: 0 }) + exMaxLv!: integer; +} + +@Entity("chunithm_user_item") +@Unique(["card", "itemId"]) +export class ChunithmUserItem extends ConstructableBaseEntity { + @PrimaryGeneratedColumn() + id!: integer; + + @ManyToOne(() => AimeCard, { eager: true }) + card!: AimeCard; + + @Column() + itemId!: integer; + + @Column() + itemKind!: integer; + + @Column({ default: 1 }) + stock!: integer; + + @Column({ type: "boolean", default: true }) + isValid!: boolean; +} + +@Entity("chunithm_user_favorite_item") +@Unique(["card", "version", "favId", "favKind"]) +export class ChunithmUserFavoriteItem extends HasIdCardVersion { + @Column() + favId!: integer; + + @Column() + favKind!: integer; +} diff --git a/src/external/db/entity/chunithm/profile.ts b/src/external/db/entity/chunithm/profile.ts index e69de29..b1fcf9c 100644 --- a/src/external/db/entity/chunithm/profile.ts +++ b/src/external/db/entity/chunithm/profile.ts @@ -0,0 +1,753 @@ +import { AimeCard } from "../aimedb"; +import { ConstructableBaseEntity } from "external/db/utils/constructable-base-entity"; +import { HasIdCardVersion } from "external/db/utils/has-id-card-version"; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, Unique } from "typeorm"; +import { integer } from "types/misc"; + +@Entity("chunithm_user_data") +@Unique(["card", "version"]) +export class ChunithmUserData extends HasIdCardVersion { + @Column({ type: "text" }) + userName!: string; + + @Column({ type: "text" }) + userNameEx!: string; + + /** + * @since NEW + */ + @Column({ default: 0 }) + teamId!: integer; + + // #region Rating + @Column({ default: 0 }) + playerRating!: integer; + + @Column({ default: 0 }) + highestRating!: integer; + // #endregion + + // #region OVER POWER + @Column({ default: 0 }) + overPowerPoint!: integer; + + @Column({ default: 0 }) + overPowerRate!: integer; + + @Column({ default: 0 }) + overPowerLowerRank!: integer; + // #endregion + + // #region User level + @Column({ default: 0 }) + reincarnationNum!: integer; + + @Column({ default: 0 }) + level!: integer; + + @Column({ default: 0 }) + exp!: integer; + // #endregion + + // #region Course mode + @Column({ default: 0 }) + classEmblemBase!: integer; + + @Column({ default: 0 }) + classEmblemMedal!: integer; + // #endregion + + // #region Play count + @Column({ default: 0 }) + playCount!: integer; + + @Column({ default: 0 }) + multiPlayCount!: integer; + + @Column({ default: 0 }) + multiWinCount!: integer; + // #endregion + + // #region Currency + @Column({ default: 0 }) + point!: integer; + + @Column({ default: 0 }) + totalPoint!: integer; + + @Column({ default: 0 }) + avatarPoint!: integer; + // #endregion + + // #region Collections + @Column({ default: 1 }) + nameplateId!: integer; + + @Column({ default: 1 }) + frameId!: integer; + + @Column({ default: 0 }) + characterId!: integer; + + @Column({ default: 0 }) + trophyId!: integer; + + @Column({ default: 1 }) + voiceId!: integer; + + @Column({ default: 1 }) + mapIconId!: integer; + + @Column({ default: 0 }) + charaIllustId!: integer; + + @Column({ default: 100000 }) + skillId!: integer; + // #endregion + + // #region User avatar + @Column({ default: 1100001 }) + avatarWear!: integer; + + @Column({ default: 1200001 }) + avatarHead!: integer; + + @Column({ default: 1300001 }) + avatarFace!: integer; + + @Column({ default: 1400001 }) + avatarSkin!: integer; + + @Column({ default: 1500001 }) + avatarItem!: integer; + + @Column({ default: 1600001 }) + avatarFront!: integer; + + @Column({ default: 1700001 }) + avatarBack!: integer; + // #endregion + + // #region Tutorial + @Column({ default: 0 }) + playedTutorialBit!: integer; + + @Column({ default: 0 }) + firstTutorialCancelNum!: integer; + + @Column({ default: 0 }) + masterTutorialCancelNum!: integer; + // #endregion + + // #region Cummulative statistics + @Column({ default: 0 }) + totalMapNum!: integer; + + @Column({ default: 0 }) + totalHiScore!: integer; + + @Column({ default: 0 }) + totalBasicHighScore!: integer; + + @Column({ default: 0 }) + totalAdvancedHighScore!: integer; + + @Column({ default: 0 }) + totalExpertHighScore!: integer; + + @Column({ default: 0 }) + totalMasterHighScore!: integer; + + @Column({ default: 0 }) + totalRepertoireCount!: integer; + + /** + * @since NEW + */ + @Column({ type: "integer", nullable: true, default: null }) + totalUltimaHighScore!: integer | null; + // #endregion + + // #region Map + @Column({ default: 0 }) + stockedGridCount!: integer; + + @Column({ default: 0 }) + exMapLoopCount!: integer; + // #endregion + + // #region Online battle + @Column({ default: 0 }) + netBattlePlayCount!: integer; + + @Column({ default: 0 }) + netBattleWinCount!: integer; + + @Column({ default: 0 }) + netBattleLoseCount!: integer; + + @Column({ default: 0 }) + netBattleConsecutiveWinCount!: integer; + + @Column({ default: 0 }) + battleRankId!: integer; + + @Column({ default: 0 }) + battleRankPoint!: integer; + + @Column({ default: 0 }) + eliteRankPoint!: integer; + + @Column({ default: 0 }) + netBattle1stCount!: integer; + + @Column({ default: 0 }) + netBattle2ndCount!: integer; + + @Column({ default: 0 }) + netBattle3rdCount!: integer; + + @Column({ default: 0 }) + netBattle4thCount!: integer; + + @Column({ default: 0 }) + netBattleCorrection!: integer; + + @Column({ default: 0 }) + netBattleErrCnt!: integer; + + @Column({ default: 0 }) + netBattleHostErrCnt!: integer; + + @Column({ default: 0 }) + battleRewardStatus!: integer; + + @Column({ default: 0 }) + battleRewardIndex!: integer; + + @Column({ default: 0 }) + battleRewardCount!: integer; + + @Column({ type: "boolean", default: false }) + isNetBattleHost!: boolean; + + @Column({ default: 0 }) + netBattleEndState!: integer; + // #endregion + + // #region Play location history + @Column({ type: "text", nullable: true }) + firstGameId!: string | null; + + @Column({ type: "text", nullable: true }) + firstRomVersion!: string | null; + + @Column({ type: "text", nullable: true }) + firstDataVersion!: string | null; + + @Column({ type: "datetime", nullable: true }) + firstPlayDate!: Date | null; + + @Column({ type: "text", nullable: true }) + lastGameId!: string | null; + + @Column({ type: "text", nullable: true }) + lastRomVersion!: string | null; + + @Column({ type: "text", nullable: true }) + lastDataVersion!: string | null; + + @Column({ type: "datetime", nullable: true }) + lastPlayDate!: Date | null; + + @Column({ type: "integer", nullable: true }) + lastPlaceId!: integer | null; + + @Column({ type: "varchar", length: 60, nullable: true }) + lastPlaceName!: string | null; + + @Column({ type: "integer", nullable: true }) + lastRegionId!: number | null; + + @Column({ type: "varchar", length: 48, nullable: true }) + lastRegionName!: string | null; + + @Column({ type: "integer", nullable: true }) + lastAllNetId!: number | null; + + @Column({ type: "varchar", length: 11, nullable: true }) + lastClientId!: string | null; + + @Column({ type: "varchar", length: 3, nullable: true }) + lastCountryCode!: string | null; + // #endregion + + // #region what the hell is a res + @Column({ default: 0 }) + acceptResCount!: integer; + + @Column({ default: 0 }) + requestResCount!: integer; + + @Column({ default: 0 }) + successResCount!: integer; + // #endregion + + // #region Other fields + /** + * @deprecated since NEW + */ + @Column({ type: "boolean", default: false }) + isMaimai!: boolean; + + /** + * @deprecated since NEW + */ + @Column({ type: "boolean", default: false }) + isWebJoin!: boolean; + + @Column({ type: "datetime", nullable: true, default: null }) + eventWatchedDate!: Date | null; + + @Column({ type: "datetime", nullable: true, default: null }) + webLimitDate!: Date | null; + + @Column({ default: 0 }) + friendCount!: integer; + + @Column({ type: "text", nullable: true }) + compatibleCmVersion!: string | null; + + @Column({ default: 0 }) + medal!: integer; + // #endregion + + // #region Extension columns + @Column({ default: 0 }) + ext1!: integer; + + @Column({ default: 0 }) + ext2!: integer; + + @Column({ default: 0 }) + ext3!: integer; + + @Column({ default: 0 }) + ext4!: integer; + + @Column({ default: 0 }) + ext5!: integer; + + @Column({ default: 0 }) + ext6!: integer; + + @Column({ default: 0 }) + ext7!: integer; + + @Column({ default: 0 }) + ext8!: integer; + + @Column({ default: 0 }) + ext9!: integer; + + @Column({ default: 0 }) + ext10!: integer; + + @Column({ type: "text", nullable: true }) + extStr1!: string | null; + + @Column({ type: "text", nullable: true }) + extStr2!: string | null; + + @Column({ type: "bigint", nullable: true }) + extLong1!: string; + + @Column({ type: "bigint", nullable: true }) + extLong2!: string; + // #endregion +} + +@Entity("chunithm_user_data_ex") +@Unique(["card", "version"]) +export class ChunithmUserDataEx extends HasIdCardVersion { + @Column({ default: 0 }) + medal!: integer; + + @Column({ default: 1 }) + voiceId!: integer; + + @Column({ default: 1 }) + mapIconId!: integer; + + @Column({ type: "text", nullable: true }) + compatibleCmVersion!: string | null; + + @Column({ default: 0 }) + ext1!: integer; + + @Column({ default: 0 }) + ext2!: integer; + + @Column({ default: 0 }) + ext3!: integer; + + @Column({ default: 0 }) + ext4!: integer; + + @Column({ default: 0 }) + ext5!: integer; + + @Column({ default: 0 }) + ext6!: integer; + + @Column({ default: 0 }) + ext7!: integer; + + @Column({ default: 0 }) + ext8!: integer; + + @Column({ default: 0 }) + ext9!: integer; + + @Column({ default: 0 }) + ext10!: integer; + + @Column({ default: 0 }) + ext11!: integer; + + @Column({ default: 0 }) + ext12!: integer; + + @Column({ default: 0 }) + ext13!: integer; + + @Column({ default: 0 }) + ext14!: integer; + + @Column({ default: 0 }) + ext15!: integer; + + @Column({ default: 0 }) + ext16!: integer; + + @Column({ default: 0 }) + ext17!: integer; + + @Column({ default: 0 }) + ext18!: integer; + + @Column({ default: 0 }) + ext19!: integer; + + @Column({ default: 0 }) + ext20!: integer; + + @Column({ type: "text", nullable: true }) + extStr1!: string | null; + + @Column({ type: "text", nullable: true }) + extStr2!: string | null; + + @Column({ type: "text", nullable: true }) + extStr3!: string | null; + + @Column({ type: "text", nullable: true }) + extStr4!: string | null; + + @Column({ type: "text", nullable: true }) + extStr5!: string | null; + + @Column({ type: "bigint", nullable: true }) + extLong1!: string; + + @Column({ type: "bigint", nullable: true }) + extLong2!: string; + + @Column({ type: "bigint", nullable: true }) + extLong3!: string; + + @Column({ type: "bigint", nullable: true }) + extLong4!: string; + + @Column({ type: "bigint", nullable: true }) + extLong5!: string; +} + +@Entity("chunithm_user_option") +@Unique(["card", "version"]) +export class ChunithmUserOption extends HasIdCardVersion { + /** + * Options preset selected. + */ + @Column({ default: 0 }) + optionSet!: integer; + + /** + * Headphone volume. + */ + @Column({ default: 0 }) + headphone!: integer; + + // what?? + @Column({ default: 0 }) + hardJudge!: integer; + + // #region Game settings + /** + * @note This may look like speed 5, but not really. CHUNITHM stores note speed in an enum. + * The actual note speed is `speed / 4 + 1`. Speed 2 matches the beginner speed in-game. + */ + @Column({ default: 4 }) + speed!: integer; + + @Column({ default: 4 }) + speed_120!: integer; + + @Column({ default: 0 }) + mirrorFumen!: integer; + + @Column({ default: 0 }) + trackSkip!: integer; + + /** + * @note Displayed in game as JUDGEMENT TIMING A. Default +0.0 + */ + @Column({ default: 20 }) + playTimingOffset!: integer; + + @Column({ default: 20 }) + playTimingOffset_120!: integer; + + /** + * @note Displayed in game as JUDGEMENT TIMING B. Default +0.0 + */ + @Column({ default: 20 }) + judgeTimingOffset!: integer; + + @Column({ default: 20 }) + judgeTimingOffset_120!: integer; + + @Column({ default: 1 }) + matching!: integer; + + @Column({ default: 1 }) + playerLevel!: integer; + + @Column({ default: 1 }) + rating!: integer; + + /** + * Display OVER POWER details on the song selection menu. + */ + @Column({ default: 0 }) + categoryDetail!: integer; + + // wtf is this??? + @Column({ default: 0 }) + privacy!: integer; + // #endregion + + // #region Sound settings + @Column({ default: 5 }) + guideSound!: integer; + + // Sound effect played on TAP + @Column({ default: 0 }) + successTapTimbre!: integer; + + @Column({ default: 5 }) + successTap!: integer; + + @Column({ default: 5 }) + successExTap!: integer; + + @Column({ default: 5 }) + successSlideHold!: integer; + + @Column({ default: 5 }) + successAir!: integer; + + @Column({ default: 5 }) + successFlick!: integer; + + @Column({ default: 0 }) + soundEffect!: integer; + + @Column({ default: 0 }) + judgeAppendSe!: integer; + + @Column({ default: 0 }) + resultVoiceShort!: integer; + // #endregion + + // #region Judgement display settings + @Column({ default: 1 }) + judgePos!: integer; + + @Column({ default: 2 }) + judgeAttack!: integer; + + @Column({ default: 3 }) + judgeJustice!: integer; + + @Column({ default: 3 }) + judgeCritical!: integer; + // #endregion + + // #region Sort settings + @Column({ default: 1 }) + selectMusicFilterLv!: integer; + + @Column({ default: 0 }) + sortMusicFilterLv!: integer; + + @Column({ default: 0 }) + sortMusicGenre!: integer; + // #endregion + + // #region Background display settings + @Column({ default: 2 }) + guideLine!: integer; + + @Column({ default: 1 }) + fieldColor!: integer; + + @Column({ default: 0 }) + fieldWallPosition!: integer; + + @Column({ default: 0 }) + fieldWallPosition_120!: integer; + + @Column({ default: 1 }) + bgInfo!: integer; + + @Column({ default: 0 }) + notesThickness!: integer; + // #endregion + + // #region Extension columns + @Column({ default: 0 }) + ext1!: integer; + + @Column({ default: 0 }) + ext2!: integer; + + @Column({ default: 0 }) + ext3!: integer; + + @Column({ default: 0 }) + ext4!: integer; + + @Column({ default: 0 }) + ext5!: integer; + + @Column({ default: 0 }) + ext6!: integer; + + @Column({ default: 0 }) + ext7!: integer; + + @Column({ default: 0 }) + ext8!: integer; + + @Column({ default: 0 }) + ext9!: integer; + + @Column({ default: 0 }) + ext10!: integer; + // #endregion +} + +@Entity("chunithm_user_option_ex") +@Unique(["card", "version"]) +export class ChunithmUserOptionEx extends HasIdCardVersion { + @Column({ default: 0 }) + ext1!: integer; + + @Column({ default: 0 }) + ext2!: integer; + + @Column({ default: 0 }) + ext3!: integer; + + @Column({ default: 0 }) + ext4!: integer; + + @Column({ default: 0 }) + ext5!: integer; + + @Column({ default: 0 }) + ext6!: integer; + + @Column({ default: 0 }) + ext7!: integer; + + @Column({ default: 0 }) + ext8!: integer; + + @Column({ default: 0 }) + ext9!: integer; + + @Column({ default: 0 }) + ext10!: integer; + + @Column({ default: 0 }) + ext11!: integer; + + @Column({ default: 0 }) + ext12!: integer; + + @Column({ default: 0 }) + ext13!: integer; + + @Column({ default: 0 }) + ext14!: integer; + + @Column({ default: 0 }) + ext15!: integer; + + @Column({ default: 0 }) + ext16!: integer; + + @Column({ default: 0 }) + ext17!: integer; + + @Column({ default: 0 }) + ext18!: integer; + + @Column({ default: 0 }) + ext19!: integer; + + @Column({ default: 0 }) + ext20!: integer; +} + +@Entity("chunithm_user_activity") +export class ChunithmUserActivity extends ConstructableBaseEntity { + @PrimaryGeneratedColumn() + id!: integer; + + @ManyToOne(() => AimeCard, { eager: true }) + card!: AimeCard; + + @Column() + activityId!: integer; + + @Column() + kind!: integer; + + @Column() + sortNumber!: integer; + + @Column() + param1!: integer; + + @Column() + param2!: integer; + + @Column() + param3!: integer; + + @Column() + param4!: integer; +} diff --git a/src/external/db/entity/chunithm/score.ts b/src/external/db/entity/chunithm/score.ts new file mode 100644 index 0000000..6f3625b --- /dev/null +++ b/src/external/db/entity/chunithm/score.ts @@ -0,0 +1,32 @@ +import { AimeCard } from "../aimedb"; +import { ConstructableBaseEntity } from "external/db/utils/constructable-base-entity"; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, Unique } from "typeorm"; +import { integer } from "types/misc"; + +@Entity() +@Unique(["card", "version", "order"]) +export class ChunithmUserRecentRating extends ConstructableBaseEntity { + @PrimaryGeneratedColumn() + id!: integer; + + @ManyToOne(() => AimeCard, { eager: true }) + card!: AimeCard; + + @Column() + version!: integer; + + @Column() + order!: integer; + + @Column() + musicId!: integer; + + @Column() + difficultId!: integer; + + @Column() + romVersionCode!: integer; + + @Column() + score!: integer; +} diff --git a/src/external/db/utils/has-id-card-version.ts b/src/external/db/utils/has-id-card-version.ts new file mode 100644 index 0000000..a5201ff --- /dev/null +++ b/src/external/db/utils/has-id-card-version.ts @@ -0,0 +1,15 @@ +import { ConstructableBaseEntity } from "./constructable-base-entity"; +import { AimeCard } from "../entity/aimedb"; +import { PrimaryGeneratedColumn, ManyToOne, Column } from "typeorm"; +import { integer } from "types/misc"; + +export class HasIdCardVersion extends ConstructableBaseEntity { + @PrimaryGeneratedColumn() + id!: integer; + + @ManyToOne(() => AimeCard, { eager: true }) + card!: AimeCard; + + @Column() + version!: integer; +} diff --git a/src/lib/setup/config.ts b/src/lib/setup/config.ts index 7703d6b..0b73d3b 100644 --- a/src/lib/setup/config.ts +++ b/src/lib/setup/config.ts @@ -36,12 +36,21 @@ const configSchema = z.object({ CONSOLE: z.boolean().default(true), FOLDER: z.string().default("logs"), }), - ALLNET_CONFIG: z.object({ - ENABLED: z.boolean().default(true), - PORT: zod16bitNumber.default(80), - ALLOW_UNREGISTERED_SERIALS: z.boolean().default(true), - UPDATE_CFG_FOLDER: z.string().optional(), - }), + ALLNET_CONFIG: z + .object({ + ENABLED: z.boolean().default(true), + PORT: zod16bitNumber.default(80), + ALLOW_UNREGISTERED_SERIALS: z.boolean().default(true), + UPDATE_CFG_FOLDER: z.string().optional(), + HOSTNAME: z.string().optional(), + }) + .refine((c) => { + if (c.UPDATE_CFG_FOLDER) { + return !!c.HOSTNAME; + } + + return true; + }, "Must specify ALL.Net hostname if allowing updates."), AIMEDB_CONFIG: z.object({ ENABLED: z.boolean().default(true), PORT: zod16bitNumber.default(22345), diff --git a/src/servers/aimedb/handlers/aime-account.ts b/src/servers/aimedb/handlers/aime-account.ts index 8b5c877..5afa1c6 100644 --- a/src/servers/aimedb/handlers/aime-account.ts +++ b/src/servers/aimedb/handlers/aime-account.ts @@ -1,3 +1,4 @@ +import { CommandId, PortalRegistration, ResultCodes } from "../../../lib/constants/aimedb"; import { AimeAccountQueryStruct, AimeAccountResponseStruct, @@ -5,7 +6,6 @@ import { AimeAccountExtendedQueryStruct, } from "../types/aime-account"; import { PacketHeaderStruct } from "../types/header"; -import { CommandId, PortalRegistration, ResultCodes } from "../../../lib/constants/aimedb"; import { AimeCard } from "external/db/entity/aimedb"; import CreateLogCtx from "lib/logger/logger"; import type { AimeDBHandlerFn } from "../types/handlers"; @@ -35,6 +35,7 @@ export const GetAimeAccountHandler: AimeDBHandlerFn<"AimeAccountResponse"> = asy return resp; } + // TODO: Verify access code validity const accessCode = Buffer.from(req.accessCode).toString("hex"); const card = await AimeCard.findOne({ diff --git a/src/servers/aimedb/handlers/aime-log.ts b/src/servers/aimedb/handlers/aime-log.ts index 35dd55e..455ac7f 100644 --- a/src/servers/aimedb/handlers/aime-log.ts +++ b/src/servers/aimedb/handlers/aime-log.ts @@ -1,3 +1,4 @@ +import { CommandId, LogStatus, ResultCodes } from "../../../lib/constants/aimedb"; import { AimeLogStruct, AimeLogExtendedResponseStruct, @@ -5,7 +6,6 @@ import { StatusLogStruct, } from "../types/aime-log"; import { PacketHeaderStruct } from "../types/header"; -import { CommandId, LogStatus, ResultCodes } from "../../../lib/constants/aimedb"; import { AppDataSource } from "external/db/data-source"; import { EventLog } from "external/db/entity/base"; import CreateLogCtx from "lib/logger/logger"; diff --git a/src/servers/aimedb/handlers/campaign.ts b/src/servers/aimedb/handlers/campaign.ts index 64f7380..dc44d2c 100644 --- a/src/servers/aimedb/handlers/campaign.ts +++ b/src/servers/aimedb/handlers/campaign.ts @@ -1,11 +1,11 @@ // TODO: Actually support campaigns +import { CommandId, ResultCodes } from "../../../lib/constants/aimedb"; import { CampaignClearInfoResponseStruct, CampaignResponseStruct, OldCampaignResponseStruct, } from "../types/campaign"; import { PacketHeaderStruct } from "../types/header"; -import { CommandId, ResultCodes } from "../../../lib/constants/aimedb"; import type { AimeDBHandlerFn } from "../types/handlers"; export const GetCampaignInfoHandler: AimeDBHandlerFn<"CampaignResponse" | "OldCampaignResponse"> = ( diff --git a/src/servers/aimedb/handlers/status-check.ts b/src/servers/aimedb/handlers/status-check.ts index cdfa850..5a5096f 100644 --- a/src/servers/aimedb/handlers/status-check.ts +++ b/src/servers/aimedb/handlers/status-check.ts @@ -1,5 +1,5 @@ -import { PacketHeaderStruct } from "../types/header"; import { CommandId, ResultCodes } from "../../../lib/constants/aimedb"; +import { PacketHeaderStruct } from "../types/header"; import type { AimeDBHandlerFn } from "../types/handlers"; export const ServiceHealthCheckHandler: AimeDBHandlerFn = (header, _) => { diff --git a/src/servers/allnet/index.ts b/src/servers/allnet/index.ts index 873dfcf..f77f836 100644 --- a/src/servers/allnet/index.ts +++ b/src/servers/allnet/index.ts @@ -10,6 +10,7 @@ import { DFIRequestResponse } from "./middleware/dfi"; import type { Express } from "express"; import { RequestLoggerMiddleware } from "../../lib/middleware/request-logger"; import { IsRecord } from "utils/misc"; +import { Config } from "lib/setup/config"; const logger = CreateLogCtx(__filename); @@ -54,6 +55,15 @@ app.use(RequestLoggerMiddleware); app.use("/", mainRouter); +if (Config.ALLNET_CONFIG.UPDATE_CFG_FOLDER) { + logger.info(`Running network delivery server at ${Config.ALLNET_CONFIG.UPDATE_CFG_FOLDER}.`, { + bootInfo: true, + }); + + app.use("/dl/ini", express.static(Config.ALLNET_CONFIG.UPDATE_CFG_FOLDER)); + app.get("/dl/ini/*", (_, res) => res.status(404).send("")); +} + const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) => { // eslint-disable-next-line cadence/no-instanceof if (err instanceof SyntaxError && req.path === "/report-api/Report") { diff --git a/src/servers/allnet/router/sys/servlet/DownloadOrder/router.ts b/src/servers/allnet/router/sys/servlet/DownloadOrder/router.ts index 2fe9d59..51b70f7 100644 --- a/src/servers/allnet/router/sys/servlet/DownloadOrder/router.ts +++ b/src/servers/allnet/router/sys/servlet/DownloadOrder/router.ts @@ -7,6 +7,8 @@ import { DownloadOrderStatus, } from "servers/allnet/types/download-order"; import { fromZodError } from "zod-validation-error"; +import { existsSync } from "fs"; +import { join } from "path"; import type { DownloadOrderResponse } from "servers/allnet/types/download-order"; const logger = CreateLogCtx(__filename); @@ -14,6 +16,15 @@ const logger = CreateLogCtx(__filename); const router: Router = Router({ mergeParams: true }); router.post("/", async (req, res) => { + if ( + !Config.ALLNET_CONFIG.UPDATE_CFG_FOLDER || + !existsSync(Config.ALLNET_CONFIG.UPDATE_CFG_FOLDER) + ) { + // Probably shouldn't place a warning. + + return res.status(200).send({ stat: DownloadOrderStatus.SUCCESS, serial: "", uri: "null" }); + } + const parseResult = DownloadOrderRequestSchema.safeParse(req.safeBody); if (!parseResult.success) { @@ -35,13 +46,31 @@ router.post("/", async (req, res) => { if (!machine) { logger.error("Rejected download order request from unknown serial.", { data }); - return res.status(200).send({ stat: 1, serial: "", uri: "null" }); + return res + .status(200) + .send({ stat: DownloadOrderStatus.SUCCESS, serial: "", uri: "null" }); } } + const appIniFilename = `${data.game_id}-${data.ver.replace(/\./u, "")}-app.ini`; + const optIniFilename = `${data.game_id}-${data.ver.replace(/\./u, "")}-opt.ini`; + const appIniPath = join(Config.ALLNET_CONFIG.UPDATE_CFG_FOLDER, appIniFilename); + const optIniPath = join(Config.ALLNET_CONFIG.UPDATE_CFG_FOLDER, optIniFilename); + let uri = ""; + + if (existsSync(appIniPath)) { + uri = `http://${Config.ALLNET_CONFIG.HOSTNAME}/dl/ini/${appIniFilename}`; + } + + if (existsSync(optIniPath)) { + uri = `${uri}|http://${Config.ALLNET_CONFIG.HOSTNAME}/dl/ini/${optIniFilename}`; + } + // TODO: Allow network delivery. const response = { - stat: DownloadOrderStatus.FAILURE, + stat: DownloadOrderStatus.SUCCESS, + serial: "", + uri, } satisfies DownloadOrderResponse; return res.status(200).send(response); diff --git a/src/servers/allnet/router/sys/servlet/LoaderStateRecorder/router.ts b/src/servers/allnet/router/sys/servlet/LoaderStateRecorder/router.ts new file mode 100644 index 0000000..70b786d --- /dev/null +++ b/src/servers/allnet/router/sys/servlet/LoaderStateRecorder/router.ts @@ -0,0 +1 @@ +// TODO diff --git a/src/servers/titles/chunithm/index.ts b/src/servers/titles/chunithm/index.ts index 0fb48d1..cf54f5d 100644 --- a/src/servers/titles/chunithm/index.ts +++ b/src/servers/titles/chunithm/index.ts @@ -1,20 +1,26 @@ import { VERSIONS } from "./versions"; +import { ChunkLength, ToBuffer } from "../utils/buffer"; import { GetClassMethods } from "../utils/reflection"; import compression from "compression"; -import { Router, json } from "express"; +import { Router } from "express"; import { ChunithmVersions } from "lib/constants/game-versions"; import CreateLogCtx from "lib/logger/logger"; import { RequestLoggerMiddleware } from "lib/middleware/request-logger"; import { Config, Environment } from "lib/setup/config"; +import onHeaders from "on-headers"; import getRawBody from "raw-body"; -import { createDecipheriv, pbkdf2Sync } from "crypto"; +import { IsHexadecimalString } from "utils/misc"; +import { createCipheriv, createDecipheriv, pbkdf2Sync } from "crypto"; +import { promisify } from "util"; +import zlib, { createDeflate, inflate } from "zlib"; import type { RequestHandler } from "express"; +import type { Transform } from "stream"; const logger = CreateLogCtx(__filename); -const router: Router = Router({ mergeParams: false }); +const inflateAsync = promisify(inflate); -router.use(compression({ threshold: 0 })); +const router: Router = Router({ mergeParams: false }); router.use(async (req, res, next) => { if ((req.headers["content-length"] ?? 0) === 0) { @@ -33,8 +39,7 @@ router.use(async (req, res, next) => { return; } - const isEncryptedRequest = - endpoint.length === 32 && [...endpoint].every((c) => "0123456789abcdefABCDEF".includes(c)); + const isEncryptedRequest = endpoint.length === 32 && IsHexadecimalString(endpoint); if ( !isEncryptedRequest && @@ -49,12 +54,20 @@ router.use(async (req, res, next) => { return res.status(200).send({ stat: "0" }); } + const body = await getRawBody(req, { limit: "20mb" }); + if (!isEncryptedRequest) { + const decompressed = await inflateAsync(body); + + // eslint-disable-next-line require-atomic-updates + req.body = JSON.parse(decompressed.toString("utf-8")); + next(); return; } logger.debug("Received encrypted request.", { version, endpoint }); + res.locals.encrypted = true; const keys = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[version]; @@ -74,41 +87,160 @@ router.use(async (req, res, next) => { const unhashedEndpoint = userAgent.split("#")[0]; if (unhashedEndpoint) { + logger.debug("Resolved to unhashed endpoint from User-Agent.", { unhashedEndpoint }); req.url = req.url.replace(endpoint, unhashedEndpoint); } else { logger.warn( "User-Agent did not contain the unhashed endpoint for version PARADISE (LOST) or older?", { version, userAgent } ); - - return res.status(200).send({ stat: "0" }); } } - const key = keys[0]; - const iv = keys[1]; + const keyString = keys[0]; + const ivString = keys[1]; - if (!key || !iv) { + if (!keyString || !ivString) { logger.error("Key or IV was not provided for the requested version.", { version, - key, - iv, + key: keyString, + iv: ivString, }); return res.status(200).send({ stat: "0" }); } - const cipher = createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), Buffer.from(iv, "hex")); - const body = await getRawBody(req); + const key = Buffer.from(keyString, "hex"); + const iv = Buffer.from(ivString, "hex"); + + const cipher = createDecipheriv("aes-256-cbc", key, iv); + const decrypted = Buffer.concat([cipher.update(body), cipher.final()]); + const decompressed = await inflateAsync(decrypted); // eslint-disable-next-line require-atomic-updates - req.body = Buffer.concat([cipher.update(body), cipher.final()]); + req.body = JSON.parse(decompressed.toString("utf-8")); - // TODO: Encrypt response + // Set up response necryption + // Eldritch horrors beyond my understanding down here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listeners: Array<[string | symbol, any]> = []; + let stream: Transform | null = null; + let ended = false; + + /* eslint-disable @typescript-eslint/unbound-method */ + const _on = res.on; + const _write = res.write; + const _end = res.end; + /* eslint-enable @typescript-eslint/unbound-method */ + + /* eslint-disable prefer-arrow-callback */ + res.write = function write(chunk, encoding, ...rest) { + if (ended) { + return false; + } + + // @ts-expect-error monkey noises + if (!this._header && !this.headersSent) { + this.writeHead(this.statusCode); + } + + const enc = typeof encoding === "function" ? undefined : encoding; + + return stream + ? stream.write(ToBuffer(chunk, enc)) + : // @ts-expect-error reeeee + _write.call(this, chunk, encoding, ...rest); + }; + + // @ts-expect-error idk + res.end = function end(chunk, encoding, ...rest) { + if (ended) { + return false; + } + + const enc = typeof encoding === "function" ? undefined : encoding; + + // @ts-expect-error how do express people live like this? + if (!this._header && !this.headersSent) { + this.writeHead(this.statusCode); + + // estimate the length + if (!this.getHeader("Content-Length")) { + this.setHeader("Content-Length", ChunkLength(chunk, enc)); + } + } + + if (!stream) { + // @ts-expect-error bruh + return _end.call(this, chunk, encoding, ...rest); + } + + // mark ended + ended = true; + + // write Buffer for Node.js 0.8 + // @ts-expect-error bruh + return chunk ? stream.end(ToBuffer(chunk, encoding)) : stream.end(); + }; + + // @ts-expect-error zzz + res.on = function on(event, listener) { + if (event !== "drain") { + return _on.call(this, event, listener); + } + + if (stream) { + return stream.on(event, listener); + } + + // buffer listeners for future stream + listeners.push([event, listener]); + + return this; + }; + + onHeaders(res, function onResponseHeaders() { + const cipher = createCipheriv("aes-256-cbc", key, iv); + + stream = createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH }); + stream.pipe(cipher); + + for (const listener of listeners) { + stream.on(listener[0], listener[1]); + } + + res.removeHeader("Content-Length"); + + cipher.on("data", (chunk) => { + // @ts-expect-error ugggh i hate monkeys + if (!_write.call(res, chunk)) { + stream?.pause(); + } + }); + + cipher.on("end", () => { + // @ts-expect-error ugggh i hate monkeys + _end.call(res); + }); + + _on.call(res, "drain", () => { + stream?.resume(); + }); + }); + /* eslint-enable prefer-arrow-callback */ next(); }); +// you WILL compress the response and you WILL be happy about it. +// chunithm assumes all responses are deflated. +router.use( + compression({ + threshold: 0, + filter: (req, res) => !res.locals.encrypted, + }) +); + router.use((req, res, next) => { if (Environment.nodeEnv === "dev" && req.header("x-debug") !== undefined) { next(); @@ -121,9 +253,6 @@ router.use((req, res, next) => { next(); }); -// All requests to the title server will be JSON, I promise! -router.use(json({ type: (_) => true })); - router.use((req, res, next) => { // Always mount an empty req body. We operate under the assumption that req.body is // always defined as atleast an object. diff --git a/src/servers/titles/chunithm/utils.ts b/src/servers/titles/chunithm/utils.ts new file mode 100644 index 0000000..6d04209 --- /dev/null +++ b/src/servers/titles/chunithm/utils.ts @@ -0,0 +1,23 @@ +import { LessThanOrEqual } from "typeorm"; +import type { HasIdCardVersion } from "external/db/utils/has-id-card-version"; +import type { integer } from "types/misc"; + +export function GetByCardIdAndVersion( + record: typeof HasIdCardVersion & (new (...args: Array) => T), + cardId: integer, + version: integer +): Promise { + return record.findOne({ + // @ts-expect-error urgh i hate typescript + where: { + card: { + id: cardId, + }, + version: LessThanOrEqual(version), + }, + // @ts-expect-error i've tried a billion ways at this point + order: { + version: "desc", + }, + }); +} diff --git a/src/servers/titles/chunithm/versions/100-base.ts b/src/servers/titles/chunithm/versions/100-base.ts index ed96f13..0dd9809 100644 --- a/src/servers/titles/chunithm/versions/100-base.ts +++ b/src/servers/titles/chunithm/versions/100-base.ts @@ -1,13 +1,25 @@ +import { GetByCardIdAndVersion } from "../utils"; +import { ChunithmUserCharacter, ChunithmUserItem } from "external/db/entity/chunithm/item"; +import { + ChunithmUserActivity, + ChunithmUserData, + ChunithmUserDataEx, + ChunithmUserOption, + ChunithmUserOptionEx, +} from "external/db/entity/chunithm/profile"; +import { ChunithmUserRecentRating } from "external/db/entity/chunithm/score"; import { ChunithmStaticCharge, ChunithmStaticEvent } from "external/db/entity/chunithm/static"; import CreateLogCtx from "lib/logger/logger"; +import { Config } from "lib/setup/config"; import { DateTime } from "luxon"; import { BaseTitle } from "servers/titles/types/titles"; +import { ParseStrInt } from "servers/titles/utils/string-checks"; +import { LessThanOrEqual } from "typeorm"; import type { Request, Response } from "express"; -import type { ITitle } from "servers/titles/types/titles"; const logger = CreateLogCtx(__filename); -export class Chunithm extends BaseTitle implements ITitle { +export class Chunithm extends BaseTitle { private readonly dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; constructor(gameCode?: string, version?: string, servletName?: string) { @@ -78,10 +90,10 @@ export class Chunithm extends BaseTitle implements ITitle { // HACK: We may want to rotate events... return res.send({ type: req.safeBody.type, - length: events.length.toString(), + length: events.length, gameEventList: events.map((e) => ({ - id: e.eventId.toString(), - type: e.type.toString(), + id: e.eventId, + type: e.type, startDate: DateTime.fromJSDate(e.startDate).toFormat(this.dateTimeFormat), endDate: "2099-12-31 00:00:00", })), @@ -89,7 +101,7 @@ export class Chunithm extends BaseTitle implements ITitle { } handle_GetGameRankingApi(req: Request, res: Response) { - // TODO + // TODO: Get most played songs from playlog return res.send({ type: req.safeBody.type, length: "0", @@ -114,10 +126,10 @@ export class Chunithm extends BaseTitle implements ITitle { // HACK return res.send({ - length: charges.length.toString(), + length: charges.length, gameChargeList: charges.map((c, i) => ({ - orderId: i.toString(), - chargeId: c.chargeId.toString(), + orderId: i, + chargeId: c.chargeId, price: "1", startDate: "2017-12-05 07:00:00.0", endDate: "2099-12-31 00:00:00.0", @@ -128,36 +140,350 @@ export class Chunithm extends BaseTitle implements ITitle { }); } - handle_GetUserPreviewApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + async handle_GetUserPreviewApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + + if (!userId) { + logger.error("Invalid request: received userId is not a number.", { + userId: req.safeBody.userId, + }); + + return res.send({ returnCode: "1" }); + } + + const profile = await GetByCardIdAndVersion(ChunithmUserData, userId, this.numericVersion); + + if (!profile) { + logger.error(`No users found.`, { + userId, + version: this.numericVersion, + }); + return res.send({ returnCode: "1" }); + } + + const options = await GetByCardIdAndVersion( + ChunithmUserOption, + userId, + this.numericVersion + ); + + const character = await ChunithmUserCharacter.findOne({ + where: { + characterId: profile.characterId, + card: { + id: profile.id, + }, + }, + }); + + const lastPlayDate = DateTime.fromJSDate(profile.lastPlayDate ?? new Date(0)).toFormat( + this.dateTimeFormat + ); + + return res.send({ + userId, + + // TODO: This should probably be false if the user is having a session somewhere else. + isLogin: false, + + lastLoginDate: lastPlayDate, + userName: profile.userName, + reincarnationNum: profile.reincarnationNum, + level: profile.level, + exp: profile.exp, + playerRating: profile.playerRating, + lastGameId: profile.lastGameId, + lastRomVersion: profile.lastRomVersion, + lastDataVersion: profile.lastDataVersion, + lastPlayDate, + trophyId: profile.trophyId, + nameplateId: profile.nameplateId, + playerLevel: profile.level, + rating: profile.playerRating, + headphone: options?.headphone ?? 0, + chargeState: 1, + userNameEx: profile.userNameEx, + userCharacter: character ? { ...character, id: undefined, profile: undefined } : {}, + }); } handle_GameLoginApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + if (!Config.CHUNITHM_CONFIG.MODS.USE_LOGIN_BONUS) { + return res.send({ returnCode: "1" }); + } + + // TODO: Add login bonuses + return res.send({ returnCode: "1" }); } - handle_GetUserDataApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + async handle_GetUserDataApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + + if (!userId) { + logger.error("Invalid request: received userId is not a number.", { + userId: req.safeBody.userId, + }); + + return res.send({ returnCode: "1" }); + } + + const profile = await GetByCardIdAndVersion(ChunithmUserData, userId, this.numericVersion); + + if (!profile) { + logger.error(`No users found.`, { + userId, + version: this.numericVersion, + }); + return res.send({ returnCode: "1" }); + } + + return res.send({ + userId, + userData: { ...profile, id: undefined, card: undefined, version: undefined }, + }); } - handle_GetUserOptionApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + async handle_GetUserDataExApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + + if (!userId) { + logger.error("Invalid request: received userId is not a number.", { + userId: req.safeBody.userId, + }); + + return res.send({ returnCode: "1" }); + } + + const profile = await GetByCardIdAndVersion( + ChunithmUserDataEx, + userId, + this.numericVersion + ); + + if (!profile) { + logger.error(`No users found.`, { + userId, + version: this.numericVersion, + }); + return res.send({ returnCode: "1" }); + } + + return res.send({ + userId, + userDataEx: { ...profile, id: undefined, card: undefined, version: undefined }, + }); } - handle_GetUserCharacterApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + async handle_GetUserOptionApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + + if (!userId) { + logger.error("Invalid request: received userId is not a number.", { + userId: req.safeBody.userId, + }); + + return res.send({ returnCode: "1" }); + } + + const options = await GetByCardIdAndVersion( + ChunithmUserOption, + userId, + this.numericVersion + ); + + if (!options) { + logger.error(`No options found for requested user.`, { + userId, + version: this.numericVersion, + }); + return res.send({ returnCode: "1" }); + } + + return res.send({ + userId, + userGameOption: { ...options, id: undefined, card: undefined, version: undefined }, + }); } - handle_GetUserActivityApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + async handle_GetUserOptionExApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + + if (!userId) { + logger.error("Invalid request: received userId is not a number.", { + userId: req.safeBody.userId, + }); + + return res.send({ returnCode: "1" }); + } + + const options = await GetByCardIdAndVersion( + ChunithmUserOptionEx, + userId, + this.numericVersion + ); + + if (!options) { + logger.error(`No options found for requested user.`, { + userId, + version: this.numericVersion, + }); + return res.send({ returnCode: "1" }); + } + + return res.send({ + userId, + userGameOption: { ...options, id: undefined, card: undefined, version: undefined }, + }); } - handle_GetUserItemApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + async handle_GetUserCharacterApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + const nextIndex = ParseStrInt(req.safeBody.nextIndex); + const maxCount = ParseStrInt(req.safeBody.maxCount); + + if (!userId || !nextIndex || !maxCount) { + logger.error("Invalid request: received userId/nextIndex/maxCount is not a number.", { + userId: req.safeBody.userId, + nextIndex: req.safeBody.nextIndex, + maxCount: req.safeBody.maxCount, + }); + + return res.send({ returnCode: "1" }); + } + + const [characters, characterCount] = await ChunithmUserCharacter.findAndCount({ + where: { + card: { + id: userId, + }, + }, + take: maxCount, + skip: nextIndex, + }); + + const nextNextIndex = nextIndex + maxCount; + + return res.send({ + userId, + length: characters.length, + nextIndex: nextNextIndex < characterCount ? nextNextIndex : -1, + userCharacterList: characters.map((c) => ({ ...c, id: undefined, card: undefined })), + }); } - handle_GetUserRecentRatingApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + async handle_GetUserActivityApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + const kind = ParseStrInt(req.safeBody.kind); + + if (!userId || !kind) { + logger.error("Invalid request: received userId/kind is not a number.", { + userId: req.safeBody.userId, + kind: req.safeBody.kind, + }); + + return res.send({ returnCode: "1" }); + } + + const activities = await ChunithmUserActivity.find({ + where: { + card: { + id: userId, + }, + kind, + }, + }); + + return res.send({ + userId, + length: activities.length, + kind, + userActivityList: activities.map((a) => ({ + ...a, + id: a.activityId, + activityId: undefined, + })), + }); + } + + async handle_GetUserItemApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + const nextIndex = ParseStrInt(req.safeBody.nextIndex); + const maxCount = ParseStrInt(req.safeBody.maxCount); + + if (!userId || !nextIndex || !maxCount) { + logger.error("Invalid request: received userId/nextIndex/maxCount is not a number.", { + userId: req.safeBody.userId, + nextIndex: req.safeBody.nextIndex, + maxCount: req.safeBody.maxCount, + }); + + return res.send({ returnCode: "1" }); + } + + const itemKind = Math.trunc(nextIndex / 1e10); + const skip = nextIndex % 1e10; + + const [items, itemsCount] = await ChunithmUserItem.findAndCount({ + where: { + card: { + id: userId, + }, + itemKind, + }, + take: maxCount, + skip, + }); + + const nextIndexWithinKind = skip + maxCount; + + return res.send({ + userId, + nextIndex: nextIndexWithinKind < itemsCount ? itemKind * 1e10 + nextIndexWithinKind : 0, + itemKind, + length: items.length, + userItemList: items.map((i) => ({ ...i, id: undefined, card: undefined })), + }); + } + + async handle_GetUserRecentRatingApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + + if (!userId) { + logger.error("Invalid request: received userId is not a number.", { + userId: req.safeBody.userId, + }); + + return res.send({ returnCode: "1" }); + } + + const recentRatings = await ChunithmUserRecentRating.find({ + where: { + card: { + id: userId, + }, + version: LessThanOrEqual(this.numericVersion), + }, + order: { + order: "asc", + }, + }); + + const maxVersion = Math.max(...recentRatings.map((r) => r.version)); + const userRecentRatingList = recentRatings + .filter((r) => r.version === maxVersion) + .map((r) => ({ + ...r, + id: undefined, + card: undefined, + version: undefined, + order: undefined, + })); + + return res.send({ + userId, + length: userRecentRatingList.length, + userRecentRatingList, + }); } handle_GetUserMusicApi(req: Request, res: Response) { @@ -165,11 +491,12 @@ export class Chunithm extends BaseTitle implements ITitle { } handle_GetUserRegionApi(req: Request, res: Response) { - throw new Error("Unimplemented"); - } - - handle_GetUserFavoriteItemApi(req: Request, res: Response) { - throw new Error("Unimplemented"); + // TODO: What the hell is a region anyways? + return res.send({ + userId: req.safeBody.userId, + length: 0, + userRegionList: [], + }); } handle_GetUserLoginBonusApi(req: Request, res: Response) { diff --git a/src/servers/titles/chunithm/versions/135-amazonplus.ts b/src/servers/titles/chunithm/versions/135-amazonplus.ts index ca9d67d..ec08350 100644 --- a/src/servers/titles/chunithm/versions/135-amazonplus.ts +++ b/src/servers/titles/chunithm/versions/135-amazonplus.ts @@ -1,6 +1,12 @@ import { ChunithmAmazon } from "./130-amazon"; +import { ChunithmUserFavoriteItem } from "external/db/entity/chunithm/item"; +import CreateLogCtx from "lib/logger/logger"; +import { ParseStrInt } from "servers/titles/utils/string-checks"; +import { LessThanOrEqual } from "typeorm"; import type { Request, Response } from "express"; +const logger = CreateLogCtx(__filename); + export class ChunithmAmazonPlus extends ChunithmAmazon { constructor(gameCode?: string, version?: string, servletName?: string) { super(gameCode ?? "SDBT", version ?? "135", servletName ?? "ChuniServlet"); @@ -26,4 +32,41 @@ export class ChunithmAmazonPlus extends ChunithmAmazon { userFavoriteMusicList: [], }); } + + async handle_GetUserFavoriteItemApi(req: Request, res: Response) { + const userId = ParseStrInt(req.safeBody.userId); + const kind = ParseStrInt(req.safeBody.kind); + + if (!userId || !kind) { + logger.error("Invalid request: received userId/kind is not a number.", { + userId: req.safeBody.userId, + kind: req.safeBody.kind, + }); + + return res.send({ returnCode: "1" }); + } + + const favorites = await ChunithmUserFavoriteItem.find({ + where: { + card: { + id: userId, + }, + version: LessThanOrEqual(this.numericVersion), + favKind: kind, + }, + }); + + const maxVersion = Math.max(...favorites.map((f) => f.version)); + const userFavoriteItemList = favorites + .filter((f) => f.version === maxVersion) + .map((f) => ({ id: f.favId })); + + return res.send({ + userId, + length: userFavoriteItemList.length, + kind, + nextIndex: -1, + userFavoriteItemList, + }); + } } diff --git a/src/servers/titles/utils/buffer.ts b/src/servers/titles/utils/buffer.ts new file mode 100644 index 0000000..2be90d7 --- /dev/null +++ b/src/servers/titles/utils/buffer.ts @@ -0,0 +1,36 @@ +function IsNumericArray(a: unknown): a is Array { + return Array.isArray(a) && a.every((c) => typeof c === "number"); +} + +export function ToBuffer(chunk: unknown, encoding?: BufferEncoding) { + if (Buffer.isBuffer(chunk)) { + return chunk; + } + + if (IsNumericArray(chunk)) { + return Buffer.from(chunk); + } + + if (typeof chunk === "string") { + return Buffer.from(chunk, encoding); + } + + throw new Error("Cannot transform into a buffer"); +} + +export function ChunkLength(chunk: unknown, encoding?: BufferEncoding) { + if (Buffer.isBuffer(chunk)) { + return chunk.length; + } + + // eslint-disable-next-line cadence/no-instanceof + if (chunk instanceof DataView) { + return Buffer.byteLength(chunk); + } + + if (typeof chunk === "string") { + return Buffer.byteLength(chunk, encoding); + } + + throw new Error("Cannot transform into a buffer"); +} diff --git a/src/servers/titles/utils/string-checks.ts b/src/servers/titles/utils/string-checks.ts new file mode 100644 index 0000000..2350382 --- /dev/null +++ b/src/servers/titles/utils/string-checks.ts @@ -0,0 +1,19 @@ +const isIntegerRegex = /^-?\d+$/u; + +export function ParseStrInt(val: unknown): number | null { + if (typeof val !== "string") { + return null; + } + + if (!isIntegerRegex.test(val)) { + return null; + } + + const v = Number(val); + + if (!Number.isSafeInteger(v)) { + return null; + } + + return v; +} diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 22e474b..6467c42 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -11,3 +11,7 @@ export function EscapeStringRegexp(string: string) { export function IsRecord(maybeRecord: unknown): maybeRecord is Record { return typeof maybeRecord === "object" && maybeRecord !== null; } + +export function IsHexadecimalString(s: string): boolean { + return /^[0-9a-f]+$/iu.test(s); +} diff --git a/tsconfig.json b/tsconfig.json index fd7be92..32218ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -106,5 +106,10 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "ts-node": { + "require": [ + "tsconfig-paths/register" + ] } }