diff --git a/application/index.ts b/application/index.ts index 36c0d44..e63fb96 100644 --- a/application/index.ts +++ b/application/index.ts @@ -1 +1 @@ -export * as quest from "application/Quest"; +export * from "./Quest"; diff --git a/core/Account/Avatar.ts b/core/Account/Avatar.ts new file mode 100644 index 0000000..1e0c453 --- /dev/null +++ b/core/Account/Avatar.ts @@ -0,0 +1,23 @@ +import { Item } from "core"; + +export enum Race { + Egg, + Plant, +} + +export class Avatar { + /** + * Level function is `50x ^ 2`. + */ + public get level(): number { + return this.totalExp == 0 ? 0 : Math.sqrt(this.totalExp / 50); + } + + constructor(public race: Race, public totalExp: number) {} + + public applyItem(...items: Item[]): void { + items.forEach((item) => { + item.takeEffect(); + }); + } +} diff --git a/core/Account/User.test.ts b/core/Account/User.test.ts new file mode 100644 index 0000000..03aa257 --- /dev/null +++ b/core/Account/User.test.ts @@ -0,0 +1,32 @@ +import { IAccountRepository } from "infrastructure"; +import { Avatar, Race } from "./Avatar"; +import { Player, User } from "./User"; + +describe("User's functionality", () => { + test("password hashing", () => { + const cases = [ + { raw: "password", hash: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" }, + { raw: "cello", hash: "9bbf02efd82322aadc5d06c9bcf35bb4b0e3302ca158dc800407be1a4fea67e2" }, + ]; + + cases.forEach((c) => { + expect(User.hashPassword(c.raw)).toBe(c.hash.toLowerCase()); + }); + }); +}); + +describe("Player's functionality", () => { + test("player level", () => { + [ + { exp: 0, lv: 0 }, + { exp: 1250, lv: 5 }, + { exp: 5000, lv: 10 }, + ].forEach(async (c) => { + const repoMock = jest.fn(() => new Avatar(Race.Egg, c.exp)); + const p = await Player.New({ getAvatar: repoMock } as unknown as IAccountRepository, "", ""); + const lv = p.level; + expect(repoMock).toBeCalledTimes(1); + expect(lv).toBe(c.lv); + }); + }); +}); diff --git a/core/Account/User.ts b/core/Account/User.ts new file mode 100644 index 0000000..f961fc6 --- /dev/null +++ b/core/Account/User.ts @@ -0,0 +1,81 @@ +import { Avatar } from "core"; +import crypto from "crypto"; +import { IAccountRepository } from "infrastructure"; + +export abstract class User { + public password?: string; + + constructor(public repo: IAccountRepository, public accountId: string, public email: string) {} + + public async login(options: LoginOptions): Promise { + if (this.password === undefined) { + this.password = await this.repo.getUserPassword(this.accountId); + } + + let result: boolean = this.password === User.hashPassword(options.password); + let timestamp: number = Date.now(); + + if (result) { + this.repo.setLastLogin(timestamp); + } else { + this.repo.setLastLoginAttempt(timestamp); + } + + return result; + } + + public static hashPassword(password: string): string { + return crypto.createHash("sha256", {}).update(password).digest("hex"); + } + + public static register(repo: IAccountRepository, email: string, password: string): Promise { + password = this.hashPassword(password); + return Promise.resolve( + repo.registerNewUser({ repo: repo, accountId: "", email: email, password: password } as User) + ); + } + + public async updatePassword(password: string): Promise { + this.password = this.password ?? (await this.repo.getUserPassword(this.accountId)); + + password = User.hashPassword(password); + + if (password === this.password) { + return false; + } + + this.repo.updateUserPassword(this.accountId, password); + return true; + } + + public upgradeToPlayer(avatar: Avatar): Player { + const p = new Player(this.repo, this.accountId, this.email); + p.avatar = avatar; + return p; + } +} + +export class LoginOptions { + constructor(public email: string, public password: string) {} +} + +export class Player extends User { + public avatar!: Avatar; + + public get level(): number { + return this.avatar.level; + } + + /** + * プレイヤーをインスタンス化する場合は`New`を使ってください。 + */ + constructor(repo: IAccountRepository, accountId: string, email: string) { + super(repo, accountId, email); + } + + public static async New(repo: IAccountRepository, accountId: string, email: string): Promise { + const p = new Player(repo, accountId, email); + p.avatar = await p.repo.getAvatar(p.accountId); + return p; + } +} diff --git a/domain/Quest/Answer.ts b/core/Quest/Answer.ts similarity index 100% rename from domain/Quest/Answer.ts rename to core/Quest/Answer.ts diff --git a/domain/Quest/Effect.ts b/core/Quest/Effect.ts similarity index 100% rename from domain/Quest/Effect.ts rename to core/Quest/Effect.ts diff --git a/domain/Quest/Item.ts b/core/Quest/Item.ts similarity index 85% rename from domain/Quest/Item.ts rename to core/Quest/Item.ts index 88d3ca8..acaf87a 100644 --- a/domain/Quest/Item.ts +++ b/core/Quest/Item.ts @@ -1,4 +1,4 @@ -import { Effect, EffectOption } from "domain/Quest"; +import { Effect, EffectOption } from "core"; export abstract class Item { public effect: Effect | null; diff --git a/domain/Quest/Quest.ts b/core/Quest/Quest.ts similarity index 91% rename from domain/Quest/Quest.ts rename to core/Quest/Quest.ts index 9a16549..991002f 100644 --- a/domain/Quest/Quest.ts +++ b/core/Quest/Quest.ts @@ -1,4 +1,4 @@ -import { Answer, Item } from "domain/Quest"; +import { Answer, Item } from "core"; export abstract class Quest { private solution: Answer; diff --git a/core/index.ts b/core/index.ts new file mode 100644 index 0000000..feec093 --- /dev/null +++ b/core/index.ts @@ -0,0 +1,6 @@ +export * from "./Account/Avatar"; +export * from "./Account/User"; +export * from "./Quest/Answer"; +export * from "./Quest/Effect"; +export * from "./Quest/Item"; +export * from "./Quest/Quest"; diff --git a/domain/Account/Avatar.ts b/domain/Account/Avatar.ts deleted file mode 100644 index 8f61de1..0000000 --- a/domain/Account/Avatar.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Player } from "domain/Account"; -import { IAccountRepository } from "infrastructure"; - -export enum Race { - Egg, - Plant, -} - -export class Avatar { - constructor(public repo: IAccountRepository, public player: Player, public race: Race) {} -} diff --git a/domain/Account/User.ts b/domain/Account/User.ts deleted file mode 100644 index 2896829..0000000 --- a/domain/Account/User.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Avatar } from "domain/Account"; -import { IAccountRepository } from "infrastructure"; - -export abstract class User { - constructor( - public repo: IAccountRepository, - public accountId: string, - public email: string, - public password: string - ) {} - - public login(options: LoginOptions): boolean { - let result: boolean = this.password !== options.password; - let timestamp: number = Date.now(); - - if (result) { - this.repo.setLastLoginAttempt(timestamp); - } else { - this.repo.setLastLogin(timestamp); - } - - return result; - } -} - -export class LoginOptions { - constructor(public email: string, public password: string) {} -} - -export class Player extends User { - constructor( - repo: IAccountRepository, - accountId: string, - email: string, - password: string, - public totalExp: number, - public avatar: Avatar - ) { - super(repo, accountId, email, password); - } -} diff --git a/domain/Account/index.ts b/domain/Account/index.ts deleted file mode 100644 index 4a5d804..0000000 --- a/domain/Account/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./Avatar"; -export * from "./User"; diff --git a/domain/Quest/index.ts b/domain/Quest/index.ts deleted file mode 100644 index 70224a9..0000000 --- a/domain/Quest/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./Answer"; -export * from "./Effect"; -export * from "./Item"; -export * from "./Quest"; diff --git a/domain/index.ts b/domain/index.ts deleted file mode 100644 index 403cf0e..0000000 --- a/domain/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * as account from "domain/Account"; -export * as quest from "domain/Quest"; diff --git a/index.ts b/index.ts index 5a9989f..fc80201 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,3 @@ -export * as domain from "./domain"; +export * as domain from "./core"; export * as infrastructure from "./infrastructure"; export * as application from "./application"; diff --git a/infrastructure/index.ts b/infrastructure/index.ts index 00a1869..2c8d38c 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -1,22 +1,32 @@ -import { Avatar, Player, User } from "domain/Account"; -import { Answer, Item, Quest } from "domain/Quest"; +import { Answer, Avatar, Item, Player, Quest, User } from "core"; export type Identifier = string | number; export interface IQuestRepository { - getQuest: (id: Identifier) => Quest; - getAnswer: (id: Identifier) => Answer; - getItem: (id: Identifier) => Item; + getQuest: (id: Identifier) => Promise; + getAnswer: (id: Identifier) => Promise; + getItem: (id: Identifier) => Promise; } export interface IAccountRepository { - getUser(id: Identifier): User; - getPlayer(id: Identifier): Player; - getAvatar(id: Identifier): Avatar; + getUser(id: Identifier): Promise; + getUserPassword(id: Identifier): Promise; + getPlayer(id: Identifier): Promise; + /** + * プレイヤーのIDからアバターを取得する。 + * @param player プレイヤーのID + */ + getAvatar(player: Identifier): Promise; + /** + * ユーザーのパスワードを更新する。 + * @param user ユーザーのID + * @param password 新しいパスワードのハッシュ + */ + updateUserPassword(user: Identifier, password: string): Promise; - setLastLoginAttempt(timestamp: number): void; - setLastLogin(timestamp: number): void; - registerNewUser(user: User): void; + setLastLoginAttempt(timestamp: number): Promise; + setLastLogin(timestamp: number): Promise; + registerNewUser(user: User): Promise; upgradeUserToPlayer(user: User, player: Player): Promise; unregisterUser(id: Identifier): Promise; unregisterPlayer(id: Identifier): Promise; diff --git a/tsconfig.json b/tsconfig.json index 6725053..faa86cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "allowUnreachableCode": false, "skipLibCheck": true }, - "include": ["application/**/*.ts", "domain/**/*.ts", "infrastructure/**/*.ts"] + "include": ["application/**/*.ts", "core/**/*.ts", "infrastructure/**/*.ts"] }