Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server-side Player model implementation #6

Merged
merged 5 commits into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,31 +100,32 @@ Real and production-level application could also have additional tools, such as

Data model consists of three main entities: `Player`, `Game`, and `Move`.

1. `Players` table: This table stores information about each player.
1. `players` table: This table stores information about each player.
Fields:
- `player_id` (UUID, required): A unique identifier for each player. This is the primary key.
- `session_id` (UUID, required): The session ID associated with the player. This is used to maintain the player's connection and game state.
- `username` (text, required): The player's chosen username.
- `created_at` (timestamp, required): The time when given player joined.
- `last_active_at` (timestamp, required): The time when player made a last move.
- `last_active_at` (timestamp, required): The time when player last time made any activity (made a move, joined a game, etc.)
- `deleted_at` (timestamp, optional): The time when player was deleted

2. `Games` table: This table stores information about each game.
2. `games` table: This table stores information about each game.
Fields:
- `game_id` (UUID, required): A unique identifier for each game. This is the primary key.
- `player1_id` (UUID, required): The ID of the first player (owner of the game). This is a foreign key referencing `Players.player_id`.
- `player2_id` (UUID, optional): The ID of the second player. This is a foreign key referencing `Players.player_id`. This field is optional because a game might be created before the second player has joined.
- `current_player_id` (UUID, required): The ID of the player whose turn it is. This is a foreign key referencing `Players.player_id`.
- `player1_id` (UUID, optional): The ID of the first player (owner of the game). This is a foreign key referencing `players.player_id`. This field is optional because a game might be created before the first player has joined, or a first player might abandon the game.
- `player2_id` (UUID, optional): The ID of the second player. This is a foreign key referencing `players.player_id`. This field is optional because a game might be created before the second player has joined, or a second player might abandon the game.
- `current_player_id` (UUID, required): The ID of the player whose turn it is. This is a foreign key referencing `players.player_id`.
- `current_game_state` (enum, required): The current state of the game. This is an enumeration with values like "waiting for players", "in progress", "finished", etc.
- `next_possible_moves` (integer[][2], required): An array of pairs of integers representing the X and Y coordinates of the next possible moves.
- `winner_id` (UUID, optional): The ID of the winning player, if the game has finished. This is a foreign key referencing `Players.player_id`.
- `winner_id` (UUID, optional): The ID of the winning player, if the game has finished. This is a foreign key referencing `players.player_id`.
- `created_at` (timestamp, required): The time when the game was created.
- `finished_at` (timestamp, optional): The time when the game was finished. This field is optional because it will be empty for games that are still in progress.

3. `Moves` table: This table stores information about each move made in a game.
3. `moves` table: This table stores information about each move made in a game.
Fields:
- `move_id` (UUID, required): A unique identifier for each move. This is the primary key.
- `game_id` (UUID, required): The ID of the game in which the move was made. This is a foreign key referencing `Games.game_id`.
- `player_id` (UUID, required): The ID of the player who made the move. This is a foreign key referencing `Players.player_id`.
- `game_id` (UUID, required): The ID of the game in which the move was made. This is a foreign key referencing `games.game_id`.
- `player_id` (UUID, required): The ID of the player who made the move. This is a foreign key referencing `players.player_id`.
- `move_number` (integer, required): The order in which the move was made in the game. This can be used to reconstruct the game state.
- `position_x` (integer, required): The X position on the game board where the move was made.
- `position_y` (integer, required): The Y position on the game board where the move was made.
Expand All @@ -142,7 +143,7 @@ Fields:

4. **User creates a new game or joins an existing one**
- A: **User creates a new game**: User clicks on "New Game" button. New game is created, and user is redirected to a route with a new game. They are assigned as Player 1 and there's no Player 2 yet. User waits for another player to join. If they are currently participating in another game, they are removed from that other game.
- B: **User joins an existing game**: User clicks on one of the games in progress. They are redirected to a route with an existing game. If there's a free spot, user clicks on "Join Game" button. User is assigned as Player 1 or Player 2. If there isn't any free spot, user can watch the game.
- B: **User joins an existing game**: User clicks on one of the games in progress. They are redirected to a route with an existing game. If there's a free spot, user clicks on "Join Game" button. If they are currently participating in another game, they are removed from that other game. User is assigned as Player 1 or Player 2. If there isn't any free spot, user can watch the game.

5. **Game starts**: The game board is displayed, and Player 1 is prompted to make the first move.

Expand All @@ -160,12 +161,13 @@ Fields:
- [x] [Initial client setup](https://github.com/alan-hadyk/side-stacker-game/pull/3)
- [x] [Server database setup](https://github.com/alan-hadyk/side-stacker-game/pull/4)
- [x] [Server error handling](https://github.com/alan-hadyk/side-stacker-game/pull/5)
- [ ] Server-side Player model implementation
- [ ] Server-side Player routes and controllers implementation
- [ ] Server-side Game entity implementation
- [ ] Server-side Game routes and controllers implementation
- [ ] Server-side Move entity implementation
- [ ] Server-side Move routes and controllers implementation
- [x] [Server-side Player model implementation](https://github.com/alan-hadyk/side-stacker-game/pull/6)
- [ ] Server-side Game model implementation
- [ ] Server-side Move model implementation
- [ ] Server-side Player controllers and services implementation
- [ ] Server-side Game controllers and services implementation
- [ ] Server-side Move controllers and services implementation
- [ ] Server routes implementation
- [ ] Client - session logic
- [ ] Client routes
- [ ] Client - game lobby
Expand Down
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"repository": "[email protected]:alan-hadyk/side-stacker-game.git",
"scripts": {
"build": "rm -rf dist/ && tsc -p ./tsconfig.build.json",
"db:reverse-migration": "NODE_ENV=development npm run db:start && ts-node-dev -r tsconfig-paths/register --project tsconfig.json src/db/scripts/reverseLastDbMigration.ts",
"db:start": "docker-compose up db -d",
"db:stop": "docker-compose down",
"dev": "NODE_ENV=development npm run db:start && ts-node-dev -r tsconfig-paths/register --project tsconfig.json src/index.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE players
ALTER COLUMN last_active_at DROP NOT NULL,
ALTER COLUMN last_active_at DROP DEFAULT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE players
ALTER COLUMN last_active_at SET NOT NULL,
ALTER COLUMN last_active_at SET DEFAULT NOW();
17 changes: 6 additions & 11 deletions server/src/db/scripts/applyDbMigrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createSqlTag, DatabasePoolConnection } from "slonik"
import { readdir, readFile } from "node:fs/promises"
import path from "node:path"
import { MigrationObject } from "@app/db/utils/objects/migrationObject"
import { MigrationsTableInit } from "@app/db/utils/tables/migrationTable"

const migrationsDir = path.resolve(process.cwd(), "src/db/migrations/up")

Expand All @@ -12,22 +13,16 @@ const sql = createSqlTag({
})

export const applyDbMigrations = async (connection: DatabasePoolConnection) => {
await connection.query(
sql.unsafe`
CREATE TABLE IF NOT EXISTS migrations (
executed_at TIMESTAMP NOT NULL DEFAULT NOW(),
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
type TEXT NOT NULL
)
`,
)
await connection.query(MigrationsTableInit)

const migrationFiles = await readdir(migrationsDir)
migrationFiles.sort()

const executedMigrations = await connection.query(
sql.typeAlias("migration")`SELECT name FROM migrations`,
sql.typeAlias("migration")`
SELECT name
FROM migrations
`,
)
const executedMigrationNames = executedMigrations.rows.map(
(migration) => migration.name,
Expand Down
6 changes: 6 additions & 0 deletions server/src/db/scripts/initDbTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PlayersTableInit } from "@app/features/players/playerModel"
import { DatabasePoolConnection } from "slonik"

export const initDbTables = async (connection: DatabasePoolConnection) => {
await connection.query(PlayersTableInit)
}
63 changes: 63 additions & 0 deletions server/src/db/scripts/reverseLastDbMigration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createSqlTag } from "slonik"
import { readdir, readFile } from "node:fs/promises"
import path from "node:path"
import { MigrationObject } from "@app/db/utils/objects/migrationObject"
import { connectToDb, pool } from "@app/db/pool"
import { MigrationsTableInit } from "@app/db/utils/tables/migrationTable"
import { z } from "zod"

const migrationsDir = path.resolve(process.cwd(), "src/db/migrations/down")

const sql = createSqlTag({
typeAliases: {
migration: MigrationObject,
null: z.null(),
},
})

const reverseLastDbMigration = async () => {
await connectToDb()

await pool.connect(async (connection) => {
await connection.query(MigrationsTableInit)

const migrationFiles = await readdir(migrationsDir)
migrationFiles.sort()

const executedMigrations = await connection.query(
sql.typeAlias("migration")`SELECT name FROM migrations`,
)
const executedMigrationNames = executedMigrations.rows.map(
(migration) => migration.name,
)

const lastMigrationFile = migrationFiles[migrationFiles.length - 1]

if (!executedMigrationNames.includes(lastMigrationFile)) {
console.log(
`Skipping reversing of a migration that wasn't executed: ${lastMigrationFile}`,
)
return
}

await connection.transaction(async (transactionConnection) => {
const migrationSql = await readFile(
path.join(migrationsDir, lastMigrationFile),
"utf8",
)

await transactionConnection.query(sql.unsafe([migrationSql]))

const migrationTableQuery = sql.typeAlias("null")`
DELETE
FROM migrations
WHERE name = ${lastMigrationFile}
`
await transactionConnection.query(migrationTableQuery)

console.log(`Reversed migration: ${lastMigrationFile}`)
})
})
}

reverseLastDbMigration()
10 changes: 10 additions & 0 deletions server/src/db/utils/tables/migrationTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { sql } from "slonik"

export const MigrationsTableInit = sql.unsafe`
CREATE TABLE IF NOT EXISTS migrations (
executed_at TIMESTAMP NOT NULL DEFAULT NOW(),
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
type TEXT NOT NULL
)
`
4 changes: 4 additions & 0 deletions server/src/features/@types/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum OrderDirection {
ASC = "ASC",
DESC = "DESC",
}
9 changes: 9 additions & 0 deletions server/src/features/players/@types/playerModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { OrderDirection } from "@app/features/@types/models"
import { Player } from "@app/features/players/@types/playerObject"

export interface PlayerModelGetAll {
limit?: number
offset?: number
orderBy?: keyof Player
orderDirection?: OrderDirection
}
4 changes: 4 additions & 0 deletions server/src/features/players/@types/playerObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { PlayerObject } from "@app/features/players/playerObject"
import { z } from "zod"

export type Player = z.infer<typeof PlayerObject>
136 changes: 136 additions & 0 deletions server/src/features/players/playerModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { pool } from "@app/db/pool"
import { OrderDirection } from "@app/features/@types/models"
import { PlayerModelGetAll } from "@app/features/players/@types/playerModel"
import { Player } from "@app/features/players/@types/playerObject"
import { PlayerObject } from "@app/features/players/playerObject"
import { NotFoundError, createSqlTag } from "slonik"
import { z } from "zod"

const sql = createSqlTag({
typeAliases: {
null: z.null(),
player: PlayerObject,
},
})

export class PlayerModel {
static create = ({
session_id,
username,
}: Pick<Player, "session_id" | "username">): Promise<Player> =>
pool.connect(async (connection) => {
const query = sql.typeAlias("player")`
INSERT
INTO players (player_id, session_id, username, created_at, last_active_at, deleted_at)
VALUES (uuid_generate_v4(), ${session_id}, ${username}, NOW(), NOW(), NULL)
RETURNING *
`

const { rows } = await connection.query(query)

return rows[0]
})

static delete = (player_id: Player["player_id"]): Promise<Player> =>
pool.connect(async (connection) => {
const fragments = [sql.fragment`deleted_at = NOW()`]

const query = sql.typeAlias("player")`
UPDATE players
SET ${sql.join(fragments, sql.unsafe`, `)}
WHERE player_id = ${player_id}
RETURNING *
`

const { rowCount, rows } = await connection.query(query)

if (rowCount === 0) {
throw new NotFoundError(query)
}

return rows[0]
})

static getAll = ({
limit = 20,
offset = 0,
orderBy = "created_at",
orderDirection = OrderDirection.DESC,
}: PlayerModelGetAll): Promise<readonly Player[]> =>
pool.connect(async (connection) => {
const direction = orderDirection === OrderDirection.ASC ? "ASC" : "DESC"
const query = sql.typeAlias("player")`
SELECT *
FROM players
WHERE deleted_at IS NULL
ORDER BY ${sql.identifier([orderBy])} ${sql.unsafe([direction])}
LIMIT ${limit}
OFFSET ${offset}
`

return connection.many(query)
})

static getById = (player_id: Player["player_id"]): Promise<Player> =>
pool.connect(async (connection) =>
connection.one(
sql.typeAlias("player")`
SELECT *
FROM players
WHERE player_id = ${player_id}
`,
),
)

static update = (
player_id: Player["player_id"],
{
last_active_at,
session_id,
username,
}: Partial<Pick<Player, "last_active_at" | "session_id" | "username">>,
): Promise<Player> =>
pool.connect(async (connection) => {
const fragments = []

fragments.push(
last_active_at !== undefined
? sql.fragment`last_active_at = ${last_active_at}`
: sql.fragment`last_active_at = NOW()`,
)

if (session_id !== undefined) {
fragments.push(sql.fragment`session_id = ${session_id}`)
}

if (username !== undefined) {
fragments.push(sql.fragment`username = ${username}`)
}

const query = sql.typeAlias("player")`
UPDATE players
SET ${sql.join(fragments, sql.unsafe`, `)}
WHERE player_id = ${player_id}
RETURNING *
`

const { rowCount, rows } = await connection.query(query)

if (rowCount === 0) {
throw new NotFoundError(query)
}

return rows[0]
})
}

export const PlayersTableInit = sql.unsafe`
CREATE TABLE IF NOT EXISTS players (
player_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
session_id UUID NOT NULL,
username TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_active_at TIMESTAMP,
deleted_at TIMESTAMP
)
`
14 changes: 14 additions & 0 deletions server/src/features/players/playerObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from "zod"

export const PlayerObject = z
.object({
created_at: z.number(),
deleted_at: z.number(),
last_active_at: z.number(),
player_id: z.string().uuid(),
session_id: z.string().uuid(),
username: z.string().max(100),
})
.strict()

export const playerObjectKeys = PlayerObject.keyof()._def.values
4 changes: 4 additions & 0 deletions server/src/initializers/db.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { connectToDb, pool } from "@app/db/pool"
import { applyDbMigrations } from "@app/db/scripts/applyDbMigrations"
import { initDbExtensions } from "@app/db/scripts/initDbExtensions"
import { initDbTables } from "@app/db/scripts/initDbTables"

export const initDb = async () => {
await connectToDb()
Expand All @@ -9,6 +10,9 @@ export const initDb = async () => {
// Initialize extensions
await initDbExtensions(connection)

// Create tables if they don't exist
await initDbTables(connection)

// Migrations
await applyDbMigrations(connection)

Expand Down