push: ALL.Net and a little of CHUNITHM

This commit is contained in:
beerpsi 2023-11-25 12:54:15 +07:00
parent 8186d6b488
commit 3cffcd1410
25 changed files with 1691 additions and 77 deletions

2
.gitignore vendored
View File

@ -143,6 +143,8 @@ dist
# End of https://www.toptal.com/developers/gitignore/api/node
data
logs
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal

81
config.example.json5 Normal file
View File

@ -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",
],
},
},
}
}

View File

@ -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",

View File

@ -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'}

View File

@ -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,

80
src/external/db/entity/chunithm/item.ts vendored Normal file
View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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),

View File

@ -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({

View File

@ -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";

View File

@ -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"> = (

View File

@ -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, _) => {

View File

@ -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") {

View File

@ -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);

View File

@ -0,0 +1 @@
// TODO

View File

@ -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.

View File

@ -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<T extends HasIdCardVersion>(
record: typeof HasIdCardVersion & (new (...args: Array<unknown>) => T),
cardId: integer,
version: integer
): Promise<T | null> {
return record.findOne<T>({
// @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",
},
});
}

View File

@ -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) {

View File

@ -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,
});
}
}

View File

@ -0,0 +1,36 @@
function IsNumericArray(a: unknown): a is Array<number> {
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");
}

View File

@ -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;
}

View File

@ -11,3 +11,7 @@ export function EscapeStringRegexp(string: string) {
export function IsRecord(maybeRecord: unknown): maybeRecord is Record<string, unknown> {
return typeof maybeRecord === "object" && maybeRecord !== null;
}
export function IsHexadecimalString(s: string): boolean {
return /^[0-9a-f]+$/iu.test(s);
}

View File

@ -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"
]
}
}