Rebase Develop #58
@@ -1,2 +1,254 @@
|
||||
# DADProject
|
||||
# DAD Project - Bisca Game Platform
|
||||
|
||||
## Overview
|
||||
|
||||
The DAD project aims to develop and deploy the **Bisca Game Platform** as a web application. The platform is a distributed system that provides users with the ability to play the traditional Portuguese card game Bisca in single-player and multiplayer modes, manage their coins, and track their gaming statistics.
|
||||
|
||||
The web client is implemented as a **Single-Page Application (SPA)** built with **Vue.js** and hosted on the designated internal school server. The backend system includes a database, a RESTful API server, and a WebSocket server for real-time multiplayer gameplay.
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Frontend
|
||||
- **Technology**: Vue.js SPA
|
||||
- **Purpose**: User interface for game play, user management, coin transactions, and statistics visualization
|
||||
- **Features**: Real-time game updates via WebSockets, responsive design, player profiles, leaderboards
|
||||
|
||||
### Backend
|
||||
- **RESTful API Server**: Handles user authentication, coin transactions, game history, leaderboards, and administrative operations
|
||||
- **WebSocket Server**: Manages real-time multiplayer game communication, ensuring the server acts as the authoritative source for game state
|
||||
- **Database**: Persists user data, game results, transactions, and platform statistics
|
||||
|
||||
## Constraints
|
||||
|
||||
The project must adhere to the following mandatory constraints:
|
||||
|
||||
1. **Single-Page Application (SPA)** - The application must be implemented exclusively as an SPA
|
||||
2. **Vue.js Requirement** - The client application must be built with Vue.js
|
||||
3. **Payment Gateway Integration** - All external coin purchases must be processed via the provided RESTful API (Payment Gateway Service)
|
||||
4. **Deployment** - The application must be deployed on the designated internal school server and be accessible via a desktop web browser
|
||||
5. **Backend Technologies** - Must be open-source or available as free external services
|
||||
6. **Server Compatibility** - Backend technologies must be installable on or accessible from the designated server; if not supported, compliant alternatives must be selected
|
||||
7. **Database Seeding** - The deployed application's database must be seeded with realistic data in both scale and content to simulate typical usage patterns
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. User Management
|
||||
- **Registration**: Users register with email, nickname, name, password (minimum 3 characters), and optional photo/avatar
|
||||
- **Authentication**: Email and password-based login
|
||||
- **Profile Management**: View and update profile information, change password
|
||||
- **Sign Out**: Users can end their session
|
||||
- **Account Deletion**: Requires explicit confirmation; permanently forfeits all coins
|
||||
- **Welcome Bonus**: New users receive 10 coins upon registration
|
||||
- **Admin Accounts**: Created only by existing administrators; same profile capabilities as users but cannot delete their own accounts
|
||||
|
||||
### 2. Coins and Transactions
|
||||
- **Coin Balance**: Users can view their current coin balance
|
||||
- **Coin Purchases**: External purchases via Payment Gateway Service
|
||||
- **Conversion Rate**: €1 = 10 coins (purchases in €1 increments)
|
||||
- **Coin Earning**: Players earn coins through gameplay and bonuses
|
||||
- **Transaction History**: All coin-related transactions (bonuses, purchases, spending, earnings) are logged
|
||||
- **History Access**:
|
||||
- Players view only their own transaction history
|
||||
- Administrators have read-only access to all players' histories
|
||||
- Administrators cannot create, modify, or delete transactions
|
||||
|
||||
### 3. Games and Matches
|
||||
|
||||
#### Game Variants
|
||||
- **Bisca de 3**: Three-card hands
|
||||
- **Bisca de 9**: Nine-card hands
|
||||
|
||||
#### Game Modes
|
||||
- **Single-Player**: Against a bot (available to anonymous and registered users)
|
||||
- **Multiplayer**: Against human opponents (available only to registered users)
|
||||
- **Match**: Best-of series (up to 4 marks/wins) with two players
|
||||
|
||||
#### Game Rules
|
||||
- Games and matches are limited to two players
|
||||
- The bot follows game rules and attempts to win when playing second; otherwise plays its lowest-value card
|
||||
- Official Bisca rules apply (as defined in the "Bisca Game" documentation)
|
||||
|
||||
#### Multiplayer Gameplay
|
||||
- **Real-Time Communication**: WebSockets for live game updates
|
||||
- **Server Authority**: The server is the source of truth for:
|
||||
- Card dealing
|
||||
- Turn order
|
||||
- Valid moves
|
||||
- Trick resolution
|
||||
- Drawing from stock
|
||||
- Scoring
|
||||
- End-of-game/match conditions
|
||||
- **Move Timer**: 20-second limit per move
|
||||
- **Resignation**:
|
||||
- Opponent awarded all remaining cards
|
||||
- Game ends immediately
|
||||
- If in a match, player forfeits all remaining games
|
||||
- Scores computed accordingly
|
||||
- **Persistent Results**: Game and match results with metadata are persisted for user history and administrative access
|
||||
|
||||
### 4. Game History and Leaderboards
|
||||
|
||||
#### Tracking
|
||||
- **Scope**: Only multiplayer games and matches are tracked
|
||||
- **Metadata**: Start/end timestamps, variant, outcome (win/loss/draw/forfeit), final points, marks (capote, bandeira), duration
|
||||
|
||||
#### History Access
|
||||
- **Players**: View only their own multiplayer history
|
||||
- **Administrators**: Read-only access to all players' histories and detailed records
|
||||
- **Detailed Records**: Visible only to administrators and participating players
|
||||
|
||||
#### Leaderboards
|
||||
- **Personal Leaderboards**: Visible only to the owner; summarize totals for game wins, match wins, capotes, and bandeiras (optionally segmented by variant)
|
||||
- **Global Leaderboards**: Visible to all users including anonymous visitors; highlight top players by game and match wins, may include capotes and bandeiras
|
||||
- **Tie-Breaking**: Earlier achievers rank higher in case of ties
|
||||
|
||||
### 5. Administration
|
||||
|
||||
#### Administrator Capabilities
|
||||
- **Profile Management**: Same as regular users, except cannot delete their own accounts
|
||||
- **Account Management**: Create new administrator accounts, remove any account except their own
|
||||
- **Soft Deletion**: Player accounts with prior activity must be soft-deleted
|
||||
- **User Monitoring**: View all users (players and administrators), block/unblock players
|
||||
- **Read-Only Access**:
|
||||
- All transactions
|
||||
- All multiplayer games and matches
|
||||
- Platform usage summaries and statistics
|
||||
|
||||
#### Administrator Access Control
|
||||
- Cannot play games or hold coins
|
||||
- New administrator accounts can only be created by existing administrators
|
||||
|
||||
### 6. Statistics and Reporting
|
||||
|
||||
#### Anonymous Users and Players
|
||||
- Access only generic, anonymized aggregates:
|
||||
- Total registered players
|
||||
- Games and matches played
|
||||
- Recent activity summaries
|
||||
|
||||
#### Administrators
|
||||
- Full, non-anonymized access including:
|
||||
- Purchases by period and player
|
||||
- Game/match volumes
|
||||
- User activity breakdowns
|
||||
- Time-series data
|
||||
|
||||
#### Visualization
|
||||
- Statistics available in both visual (charts) and textual/tabular forms
|
||||
- Privacy for non-admin viewers is preserved
|
||||
- Exact metrics and visualizations are team-defined
|
||||
|
||||
## Enhancement and Innovation Opportunities
|
||||
|
||||
To distinguish this project, consider implementing one or more of the following:
|
||||
|
||||
### Custom Features (Examples)
|
||||
- Support for three or more players per game (optional extension while preserving two-player mode)
|
||||
- Spectator mode for multiplayer games and matches
|
||||
- Full playback of past games
|
||||
- Custom card sets
|
||||
- Extended bot capabilities (e.g., improved AI strategies)
|
||||
- Integration of bots into multiplayer games
|
||||
|
||||
### Architecture and Performance Enhancements (Examples)
|
||||
- Queue server or worker for asynchronous, long-running operations
|
||||
- Horizontal scaling of the WebSocket server
|
||||
- Session recovery after WebSocket or node failures
|
||||
- Cache server implementation
|
||||
- NoSQL database for specific use cases
|
||||
- Search engine for game history or statistics
|
||||
- Asynchronous messaging broker (e.g., Kafka, RabbitMQ)
|
||||
- Distributed components for improved scalability and resilience
|
||||
|
||||
### Implementation Quality
|
||||
- Use of technologies or techniques not covered in standard coursework
|
||||
- Performance optimization with empirical benchmarks
|
||||
- Improved architecture and code structure
|
||||
- Enhanced implementation quality and documentation
|
||||
|
||||
**Note**: Credit is proportional to demonstrated impact with preference given to distributed capabilities that improve architecture, scalability, or resilience.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frontend
|
||||
- Vue.js (mandatory)
|
||||
- Vite (build tool)
|
||||
- ESLint (code quality)
|
||||
|
||||
### Backend
|
||||
- **API Server**: Open-source, student-selected technology
|
||||
- **WebSocket Server**: Open-source, student-selected technology
|
||||
- **Database**: Open-source, student-selected technology
|
||||
- All must be compatible with the designated school server
|
||||
|
||||
### Supported Variants
|
||||
- Laravel (PHP) for REST API
|
||||
- Node.js for WebSocket server
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
DADProject/
|
||||
├── api/ # Laravel REST API backend
|
||||
├── frontend/ # Vue.js SPA
|
||||
├── websockets/ # WebSocket server
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
Refer to the individual `README.md` files in each directory for setup and deployment instructions:
|
||||
|
||||
- [API Documentation](./api/README.md)
|
||||
- [Frontend Documentation](./frontend/README.md)
|
||||
- [WebSocket Server Documentation](./websockets/README.md)
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application's database includes the following primary entities:
|
||||
|
||||
- **Users**: Player and administrator accounts
|
||||
- **Games**: Individual game records (both single-player and multiplayer)
|
||||
- **Matches**: Match records (series of games)
|
||||
- **Coin Transactions**: Purchase and spending history
|
||||
- **Coin Transaction Types**: Transaction categorization
|
||||
- **Coin Purchases**: External purchase records
|
||||
- **Leaderboard Data**: Aggregated player statistics
|
||||
|
||||
For detailed schema information, refer to the database migrations in the `api/database/migrations/` directory.
|
||||
|
||||
## Key Business Rules
|
||||
|
||||
1. **Coin System**:
|
||||
- New users receive 10 welcome coins
|
||||
- Conversion rate: €1 = 10 coins
|
||||
- Coin purchases are made in €1 increments
|
||||
- All transactions are logged and immutable (except soft-deleted accounts)
|
||||
|
||||
2. **Game Rules**:
|
||||
- Two-player games only (in standard mode)
|
||||
- Bot cannot be played in multiplayer
|
||||
- 20-second move timer in multiplayer
|
||||
- Resignation forfeits remaining games in a match
|
||||
|
||||
3. **Account Management**:
|
||||
- Administrators cannot be created via public registration
|
||||
- Administrators cannot delete their own accounts
|
||||
- Account deletion requires explicit confirmation
|
||||
- Soft deletion preserves historical data for deleted player accounts
|
||||
|
||||
4. **Leaderboard Ranking**:
|
||||
- Multiplayer games and matches only
|
||||
- Ties resolved by earlier achievement date
|
||||
|
||||
## Notes for Developers
|
||||
|
||||
- The WebSocket server acts as the authoritative source for all game state in multiplayer games
|
||||
- Ensure proper error handling and recovery for WebSocket disconnections
|
||||
- All coin transactions must be atomic and immutable
|
||||
- Database must be seeded with realistic, varied data
|
||||
- Privacy and data protection must be maintained throughout
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the EI – DAD 2025/26 course.
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Administrator, Player
|
||||
$table->enum('type', ['A', 'P'])->default('P');
|
||||
|
||||
// Nickname - must be unique
|
||||
$table->string('nickname', 20)->nullable()->unique();
|
||||
|
||||
// User access is blocked
|
||||
$table->boolean('blocked')->default(false);
|
||||
|
||||
// User Photo/Avatar
|
||||
$table->string('photo_avatar_filename')->nullable();
|
||||
|
||||
// Brain Coin Balance
|
||||
$table->integer('coins_balance')->default(0);
|
||||
|
||||
// Users can be deleted with "soft deletes"
|
||||
$table->softDeletes();
|
||||
|
||||
// custom data
|
||||
$table->json('custom')->nullable();
|
||||
});
|
||||
|
||||
// NOTE: Only multiplayer matches are registered
|
||||
Schema::create('matches', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Type of games in the match (Bisca dos 3; Bisca dos 9)
|
||||
$table->enum('type', ['3', '9'])->default('3');
|
||||
|
||||
// Player 1 (usually, creates the match)
|
||||
$table->unsignedBigInteger('player1_user_id');
|
||||
$table->foreign('player1_user_id')->references('id')->on('users');
|
||||
|
||||
// Player 2 (usually, joins the match)
|
||||
$table->unsignedBigInteger('player2_user_id');
|
||||
$table->foreign('player2_user_id')->references('id')->on('users');
|
||||
|
||||
// Winner (null when not ended)
|
||||
$table->unsignedBigInteger('winner_user_id')->nullable();
|
||||
$table->foreign('winner_user_id')->references('id')->on('users');
|
||||
|
||||
// Loser (null when not ended)
|
||||
$table->unsignedBigInteger('loser_user_id')->nullable();
|
||||
$table->foreign('loser_user_id')->references('id')->on('users');
|
||||
|
||||
// Match status
|
||||
// Pending - Match is waiting for players
|
||||
// Playing - Match is in progress
|
||||
// Ended - Match is over
|
||||
// Interrupted - Match was interrupted due to technical issues (not finished; no winner)
|
||||
// Note: depending of the project's implementation, some status may never be used
|
||||
$table->enum('status', ['Pending', 'Playing', 'Ended', 'Interrupted']);
|
||||
|
||||
// The stake for this match
|
||||
$table->integer('stake')->default(3);
|
||||
|
||||
// Moment when the match began
|
||||
$table->dateTime('began_at')->nullable();
|
||||
// Moment when the match ended
|
||||
$table->dateTime('ended_at')->nullable();
|
||||
// Match total time (in seconds) = ended_at - began_at
|
||||
$table->decimal('total_time', 8, 2)->nullable();
|
||||
|
||||
// After the match ended, the total marks for player 1
|
||||
$table->integer('player1_marks')->nullable();
|
||||
|
||||
// After the game ended, the total marks for player 2
|
||||
$table->integer('player2_marks')->nullable();
|
||||
|
||||
// After the match ended, the sum of points for player 1 (all games)
|
||||
$table->integer('player1_points')->nullable();
|
||||
|
||||
// After the match ended, the sum of points for player 2 (all games)
|
||||
$table->integer('player2_points')->nullable();
|
||||
|
||||
// custom data
|
||||
$table->json('custom')->nullable();
|
||||
|
||||
});
|
||||
|
||||
// NOTE: Only multiplayer games are registered
|
||||
Schema::create('games', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Type of game (Bisca dos 3; Bisca dos 9)
|
||||
$table->enum('type', ['3', '9'])->default('3');
|
||||
|
||||
// Player 1 (usually, creates the game)
|
||||
$table->unsignedBigInteger('player1_user_id');
|
||||
$table->foreign('player1_user_id')->references('id')->on('users');
|
||||
|
||||
// Player 2 (usually, joins the game)
|
||||
$table->unsignedBigInteger('player2_user_id');
|
||||
$table->foreign('player2_user_id')->references('id')->on('users');
|
||||
|
||||
// True when the game has ended with a draw
|
||||
$table->boolean('is_draw')->default(false);
|
||||
|
||||
// Winner (null when not ended, or when a draw)
|
||||
$table->unsignedBigInteger('winner_user_id')->nullable();
|
||||
$table->foreign('winner_user_id')->references('id')->on('users');
|
||||
|
||||
// Loser (null when not ended, or when a draw)
|
||||
$table->unsignedBigInteger('loser_user_id')->nullable();
|
||||
$table->foreign('loser_user_id')->references('id')->on('users');
|
||||
|
||||
// Match (null for standalone games)
|
||||
$table->unsignedBigInteger('match_id')->nullable();
|
||||
$table->foreign('match_id')->references('id')->on('matches');
|
||||
|
||||
// Game status: 'Pending', 'Playing', 'Ended', or 'Interrupted'.
|
||||
// Pending: waiting for players
|
||||
// Playing: in progress
|
||||
// Ended: finished
|
||||
// Interrupted: stopped due to technical issues (unfinished; no winner)
|
||||
// Note: depending on the project’s implementation, some statuses may never be used.
|
||||
$table->enum('status', ['Pending', 'Playing', 'Ended', 'Interrupted']);
|
||||
|
||||
// Moment when the game began
|
||||
$table->dateTime('began_at')->nullable();
|
||||
// Moment when the game ended
|
||||
$table->dateTime('ended_at')->nullable();
|
||||
// Game total time (in seconds) = ended_at - began_at
|
||||
$table->decimal('total_time', 8, 2)->nullable();
|
||||
|
||||
|
||||
// After the game ended, the total of points for player 1
|
||||
$table->integer('player1_points')->nullable();
|
||||
|
||||
// After the game ended, the total of points for player 2
|
||||
$table->integer('player2_points')->nullable();
|
||||
|
||||
// custom data
|
||||
$table->json('custom')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('coin_transaction_types', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 40);
|
||||
// Credit - increase coins
|
||||
// Debit - decrease coins
|
||||
$table->enum('type', ['C', 'D']);
|
||||
// Transaction Types can be deleted with "soft deletes"
|
||||
$table->softDeletes();
|
||||
|
||||
// custom data
|
||||
$table->json('custom')->nullable();
|
||||
});
|
||||
|
||||
// Add default coin_transaction_types
|
||||
DB::table('coin_transaction_types')->insert([
|
||||
['name' => 'Bonus', 'type' => 'C'], // id = 1
|
||||
['name' => 'Coin purchase', 'type' => 'C'], // id = 2
|
||||
['name' => 'Game fee', 'type' => 'D'], // id = 3
|
||||
['name' => 'Match stake', 'type' => 'D'], // id = 4
|
||||
['name' => 'Game payout', 'type' => 'C'], // id = 5
|
||||
['name' => 'Match payout', 'type' => 'C'], // id = 6
|
||||
]);
|
||||
|
||||
Schema::create('coin_transactions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Datetime of the coin transaction
|
||||
$table->dateTime('transaction_datetime');
|
||||
|
||||
// User associated with the transaction
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->foreign('user_id')->references('id')->on('users');
|
||||
|
||||
// Match associated with the transaction (optional - it can be null)
|
||||
$table->unsignedBigInteger('match_id')->nullable();
|
||||
$table->foreign('match_id')->references('id')->on('matches');
|
||||
|
||||
// Game associated with the transaction (optional - it can be null)
|
||||
$table->unsignedBigInteger('game_id')->nullable();
|
||||
$table->foreign('game_id')->references('id')->on('games');
|
||||
|
||||
// Type of the transaction
|
||||
$table->unsignedBigInteger('coin_transaction_type_id');
|
||||
$table->foreign('coin_transaction_type_id')->references('id')->on('coin_transaction_types');
|
||||
|
||||
// Amount of the transaction (coins)
|
||||
// Positive -> increments the total amount of brain coins (Credit)
|
||||
// Negative -> decrements the total amount of brain coins (Debit)
|
||||
$table->integer('coins');
|
||||
|
||||
// custom data
|
||||
$table->json('custom')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('coin_purchases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Datetime of the coin purchase
|
||||
$table->dateTime('purchase_datetime');
|
||||
|
||||
// User associated with the purchase
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->foreign('user_id')->references('id')->on('users');
|
||||
|
||||
// Coin Transaction associated with this purchase
|
||||
// One to One relation (purchase must have a coin transaction)
|
||||
$table->unsignedBigInteger('coin_transaction_id')->unique();
|
||||
$table->foreign('coin_transaction_id')->references('id')->on('coin_transactions');
|
||||
|
||||
// Amount of the purchase (real money in euros)
|
||||
$table->decimal('euros', 8, 2);
|
||||
|
||||
// Purchases will involve a payment with a type and a reference
|
||||
// MBWAY - Phone number with 9 digits
|
||||
// PAYPAL - eMail
|
||||
// IBAN - bank transfer (2 letters + 23 digits)
|
||||
// MB - Multibanco payment - entity number (5 digits) + Reference (9 digits))
|
||||
// VISA - Visa card number (16 digits)
|
||||
$table->enum('payment_type', ['MBWAY', 'PAYPAL', 'IBAN', 'MB', 'VISA']);
|
||||
$table->string('payment_reference', 30);
|
||||
|
||||
// custom data
|
||||
$table->json('custom')->nullable();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::drop('coin_purchases');
|
||||
Schema::drop('coin_transactions');
|
||||
Schema::drop('coin_transaction_types');
|
||||
Schema::drop('games');
|
||||
Schema::drop('matches');
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('deleted_at');
|
||||
$table->dropColumn('custom');
|
||||
$table->dropColumn('coins_balance');
|
||||
$table->dropColumn('photo_avatar_filename');
|
||||
$table->dropColumn('blocked');
|
||||
$table->dropColumn('nickname');
|
||||
$table->dropColumn('type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,21 +5,80 @@ namespace Database\Seeders;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public static $startDate;
|
||||
public static $dbInsertBlockSize = 500;
|
||||
|
||||
// public static $seedType = "small";
|
||||
//public static $seedType = "full";
|
||||
//public static $seedLanguage = "pt_PT";
|
||||
public static $seedLanguage = "en_US";
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
$this->command->info("-----------------------------------------------");
|
||||
$this->command->info("START of database seeder");
|
||||
$this->command->info("-----------------------------------------------");
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => '[email protected]',
|
||||
]);
|
||||
self::$startDate = Carbon::now()->subMonths(14);
|
||||
self::$seedLanguage = $this->command->choice('What is the language for users\' names?', ['pt_PT', 'en_US'], 0);
|
||||
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::statement('PRAGMA foreign_keys = OFF');
|
||||
} else {
|
||||
DB::statement('SET foreign_key_checks=0');
|
||||
// No permissions to change global setting. Change the session setting only
|
||||
//DB::statement("SET @@global.time_zone = '+00:00'");
|
||||
DB::statement("SET time_zone = '+00:00'");
|
||||
}
|
||||
|
||||
DB::table('users')->delete();
|
||||
DB::table('matches')->delete();
|
||||
DB::table('games')->delete();
|
||||
DB::table('coin_purchases')->delete();
|
||||
DB::table('coin_transactions')->delete();
|
||||
DB::table('coin_transaction_types')->delete();
|
||||
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::statement("DELETE FROM sqlite_sequence WHERE name = 'users'");
|
||||
DB::statement("DELETE FROM sqlite_sequence WHERE name = 'matches'");
|
||||
DB::statement("DELETE FROM sqlite_sequence WHERE name = 'games'");
|
||||
DB::statement("DELETE FROM sqlite_sequence WHERE name = 'coin_purchases'");
|
||||
DB::statement("DELETE FROM sqlite_sequence WHERE name = 'coin_transactions'");
|
||||
DB::statement("DELETE FROM sqlite_sequence WHERE name = 'coin_transaction_types'");
|
||||
} else {
|
||||
DB::statement('ALTER TABLE users AUTO_INCREMENT = 0');
|
||||
DB::statement('ALTER TABLE matches AUTO_INCREMENT = 0');
|
||||
DB::statement('ALTER TABLE games AUTO_INCREMENT = 0');
|
||||
DB::statement('ALTER TABLE coin_purchases AUTO_INCREMENT = 0');
|
||||
DB::statement('ALTER TABLE coin_transactions AUTO_INCREMENT = 0');
|
||||
DB::statement('ALTER TABLE coin_transaction_types AUTO_INCREMENT = 0');
|
||||
}
|
||||
|
||||
$this->command->info("-----------------------------------------------");
|
||||
|
||||
$this->call(TransactionTypesSeeder::class);
|
||||
$this->call(UsersSeeder::class);
|
||||
$this->call(InitialTransactionsSeeder::class);
|
||||
$this->call(GamesSeeder::class);
|
||||
$this->call(GamesTransactionsSeeder::class);
|
||||
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::statement('PRAGMA foreign_keys = ON');
|
||||
} else {
|
||||
DB::statement('SET foreign_key_checks=1');
|
||||
}
|
||||
|
||||
|
||||
|
||||
$this->command->info("-----------------------------------------------");
|
||||
$this->command->info("END of database seeder");
|
||||
$this->command->info("-----------------------------------------------");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Container\Attributes\Database;
|
||||
|
||||
class GamesSeeder extends Seeder
|
||||
{
|
||||
private $ratioStandaloneToMatch = 15;
|
||||
|
||||
private function calculateRandomSeconds($filteredCollection)
|
||||
{
|
||||
$totalPlayers = $filteredCollection->count() + 1;
|
||||
return (12 * 60 * 60) / $totalPlayers + rand(0, 2000);
|
||||
}
|
||||
|
||||
private function nextGameDateTime(&$d, $filteredPlayers, $withinSameMatch = false) {
|
||||
if ($withinSameMatch) {
|
||||
$deltaSegundos = rand(300, 900);
|
||||
} else {
|
||||
$deltaSegundos = $this->calculateRandomSeconds($filteredPlayers);
|
||||
}
|
||||
$d->addSeconds($deltaSegundos);
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info("Games seeder - Start");
|
||||
|
||||
$start = DB::table('users')->where('type', 'P')->min('created_at');
|
||||
|
||||
$allPlayers = DB::table('users')->where('type', 'P')->get();
|
||||
$sortedPlayers = $allPlayers->sortBy('created_at')->values();
|
||||
|
||||
$d = new \Carbon\Carbon($start);
|
||||
$d = $d->addDay();
|
||||
$now = \Carbon\Carbon::now();
|
||||
$this->command->info("Starting to create games");
|
||||
|
||||
$games = [];
|
||||
$matches = [];
|
||||
|
||||
$i = 0;
|
||||
|
||||
$filteredPlayers = null;
|
||||
$filteredPlayersIds = null;
|
||||
$nextCreatedAt = null;
|
||||
|
||||
while ($d->lte($now)) {
|
||||
$i++;
|
||||
if (($filteredPlayers === null) || ($nextCreatedAt === null) ||
|
||||
($d->gte($nextCreatedAt))
|
||||
) {
|
||||
$filteredPlayers = $allPlayers->filter(function ($value) use ($d) {
|
||||
return $d->gt($value->created_at);
|
||||
});
|
||||
$nextCreatedAtPlayer = $sortedPlayers->first(function ($value) use ($d) {
|
||||
return $d->lte($value->created_at);
|
||||
});
|
||||
$nextCreatedAt = $nextCreatedAtPlayer ? $nextCreatedAtPlayer->created_at : new \Carbon\Carbon('9999-12-31');
|
||||
$filteredPlayersIds = $filteredPlayers->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
// Only creates games or matches when there are enough players
|
||||
if ($filteredPlayersIds === null || empty($filteredPlayersIds) || count($filteredPlayersIds) <2) {
|
||||
$this->nextGameDateTime($d, $filteredPlayers);
|
||||
continue;
|
||||
}
|
||||
|
||||
$userIdKeys = array_rand($filteredPlayersIds, 2);
|
||||
$userIDPlayer1 = $filteredPlayersIds[$userIdKeys[0]];
|
||||
$userIDPlayer2 = $filteredPlayersIds[$userIdKeys[1]];
|
||||
|
||||
if ($userIDPlayer1 == $userIDPlayer2) {
|
||||
$this->nextGameDateTime($d, $filteredPlayers);
|
||||
continue;
|
||||
}
|
||||
$match = null;
|
||||
if (rand(1, $this->ratioStandaloneToMatch) === 1) {
|
||||
$match = $this->newMatch($filteredPlayers, $userIDPlayer1, $userIDPlayer2, $d);
|
||||
$playersMarks = [0, 0];
|
||||
$playersPoints = [0, 0];
|
||||
while($playersMarks[0] < 4 && $playersMarks[1] <4) {
|
||||
$newGame = $this->newGame($filteredPlayers, $match, $userIDPlayer1, $userIDPlayer2, $d);
|
||||
$games[] = $newGame;
|
||||
$playersPoints[0] += $newGame['player1_points'];
|
||||
$playersPoints[1] += $newGame['player2_points'];
|
||||
if ($newGame['player1_points'] > $newGame['player2_points']) {
|
||||
if ($newGame['player1_points'] >= 120) {
|
||||
$playersMarks[0]+= 4;
|
||||
} elseif ($newGame['player1_points'] >= 91) {
|
||||
$playersMarks[0] += 2;
|
||||
} else {
|
||||
$playersMarks[0]++;
|
||||
}
|
||||
} elseif ($newGame['player2_points'] > $newGame['player1_points']) {
|
||||
if ($newGame['player2_points'] >= 120) {
|
||||
$playersMarks[1]+= 4;
|
||||
} elseif ($newGame['player2_points'] >= 91) {
|
||||
$playersMarks[1] += 2;
|
||||
} else {
|
||||
$playersMarks[1]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->updateMatchWinner($match, $playersMarks[0], $playersMarks[1], $playersPoints[0], $playersPoints[1], $d);
|
||||
$matches[] = $match;
|
||||
} else {
|
||||
$newGame = $this->newGame($filteredPlayers, $match, $userIDPlayer1, $userIDPlayer2, $d);
|
||||
$games[] = $newGame;
|
||||
}
|
||||
|
||||
if ($i >= DatabaseSeeder::$dbInsertBlockSize) {
|
||||
if (!empty($matches)) {
|
||||
DB::table('matches')->insert($matches);
|
||||
$this->command->info("Saved " . count($matches) . " matches at date " . $d->format('Y-m-d H:i:s'));
|
||||
}
|
||||
if (!empty($games)) {
|
||||
DB::table('games')->insert($games);
|
||||
$this->command->info("Saved " . count($games) . " games at date " . $d->format('Y-m-d H:i:s'));
|
||||
}
|
||||
$i = 0;
|
||||
$games = [];
|
||||
$matches = [];
|
||||
}
|
||||
//$this->nextGameDateTime($d, $filteredPlayers);
|
||||
}
|
||||
if (!empty($matches)) {
|
||||
DB::table('matches')->insert($matches);
|
||||
$this->command->info("Saved " . count($matches) . " matches at date " . $d->format('Y-m-d H:i:s'));
|
||||
}
|
||||
if (!empty($games)) {
|
||||
DB::table('games')->insert($games);
|
||||
$this->command->info("Saved " . count($games) . " games at date " . $d->format('Y-m-d H:i:s'));
|
||||
}
|
||||
$this->command->info("Games seeder - End");
|
||||
}
|
||||
|
||||
private $matchID = 0;
|
||||
private function newMatch($filteredPlayers,$user1, $user2, $d)
|
||||
{
|
||||
$this->matchID++;
|
||||
$this->nextGameDateTime($d, $filteredPlayers);
|
||||
return [
|
||||
'id' => $this->matchID,
|
||||
'type' => random_int(1,2) == 1 ? '3' : '9',
|
||||
'player1_user_id' => $user1,
|
||||
'player2_user_id' => $user2,
|
||||
'winner_user_id' => null,
|
||||
'loser_user_id' => null,
|
||||
'status' => 'Ended',
|
||||
'stake' => random_int(1,4) > 1 ? 3 : random_int(4,100),
|
||||
'began_at' => $d->copy(),
|
||||
'ended_at' => null,
|
||||
'total_time' => null,
|
||||
'player1_marks' => null,
|
||||
'player2_marks' => null,
|
||||
'player1_points' => null,
|
||||
'player2_points' => null,
|
||||
'custom' => null
|
||||
];
|
||||
}
|
||||
|
||||
private function updateMatchWinner(&$match, $player1Marks, $player2Marks, $totalPlayers1, $totalPlayers2, $d)
|
||||
{
|
||||
$match['player1_marks'] = $player1Marks;
|
||||
$match['player2_marks'] = $player2Marks;
|
||||
$match['player1_points'] = $totalPlayers1;
|
||||
$match['player2_points'] = $totalPlayers2;
|
||||
$match['ended_at'] = $d->copy();
|
||||
$match['total_time'] = $match['began_at']->diffInSeconds($match['ended_at']);
|
||||
$match['winner_user_id'] = $player1Marks > $player2Marks ? $match['player1_user_id'] : ($player2Marks > $player1Marks ? $match['player2_user_id'] : null);
|
||||
$match['loser_user_id'] = $player1Marks > $player2Marks ? $match['player2_user_id'] : ($player2Marks > $player1Marks ? $match['player1_user_id'] : null);
|
||||
}
|
||||
|
||||
private $gameID = 0;
|
||||
private function newGame($filteredPlayers, $match, $user1, $user2, $d)
|
||||
{
|
||||
$this->gameID++;
|
||||
$this->nextGameDateTime($d, $filteredPlayers, $match != null);
|
||||
$begin_d = $d->copy();
|
||||
$pointsUser1 = 60;
|
||||
$pointsUser2 = 60;
|
||||
// if random == 1 it is a draw
|
||||
if (random_int(1,30) > 1) {
|
||||
$pointsUser1 = rand(0, 120);
|
||||
$pointsUser2 = 120 - $pointsUser1;
|
||||
}
|
||||
$duration = random_int(200, 900);
|
||||
$d->addSeconds($duration);
|
||||
|
||||
return [
|
||||
'id' => $this->gameID,
|
||||
'type' => $match ? $match['type'] : (random_int(1, 2) == 1 ? '3' : '9'),
|
||||
'match_id' => $match ? $match['id'] : null,
|
||||
'player1_user_id' => $user1,
|
||||
'player2_user_id' => $user2,
|
||||
'is_draw' => $pointsUser1 == $pointsUser2,
|
||||
'winner_user_id' => $pointsUser1 > $pointsUser2 ? $user1 : ($pointsUser2 > $pointsUser1 ? $user2 : null),
|
||||
'loser_user_id' => $pointsUser1 < $pointsUser2 ? $user1 : ($pointsUser2 < $pointsUser1 ? $user2 : null),
|
||||
'status' => 'Ended',
|
||||
'began_at' => $begin_d,
|
||||
'ended_at' => $d->copy(),
|
||||
'total_time' => $duration,
|
||||
'player1_points' => $pointsUser1,
|
||||
'player2_points' => $pointsUser2,
|
||||
'custom' => null
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GamesTransactionsSeeder extends Seeder
|
||||
{
|
||||
private $faker = null;
|
||||
private $users = null;
|
||||
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info("Create game related Transactions");
|
||||
$this->faker = \Faker\Factory::create(DatabaseSeeder::$seedLanguage);
|
||||
|
||||
$this->users = DB::table('users')->orderBy('id', 'asc')->get();
|
||||
|
||||
$games = DB::table('games')->whereNull('match_id')->orderBy('began_at', 'asc')->get();
|
||||
$matches = DB::table('matches')->orderBy('began_at', 'asc')->get();
|
||||
|
||||
$coin_purchases = [];
|
||||
$coin_transactions = [];
|
||||
$i = 0;
|
||||
while($games->count() > 0 || $matches->count() > 0) {
|
||||
$firstGame = $games->first();
|
||||
$firstMatch = $matches->first();
|
||||
$isGameFirst = false;
|
||||
if ($firstGame && $firstMatch) {
|
||||
$isGameFirst = $firstGame->began_at < $firstMatch->began_at;
|
||||
} elseif ($firstGame && !$firstMatch) {
|
||||
$isGameFirst = true;
|
||||
} elseif (!$firstGame && $firstMatch) {
|
||||
$isGameFirst = false;
|
||||
}
|
||||
if ($isGameFirst) {
|
||||
$this->handleGame($coin_purchases, $coin_transactions, $firstGame);
|
||||
$games->shift();
|
||||
} else {
|
||||
$this->handleMatch($coin_purchases, $coin_transactions, $firstMatch);
|
||||
$matches->shift();
|
||||
}
|
||||
|
||||
$i++;
|
||||
if ($i >= DatabaseSeeder::$dbInsertBlockSize) {
|
||||
$this->command->info("Block of coin purchases and transactions");
|
||||
if (!empty($coin_transactions)) {
|
||||
DB::table('coin_transactions')->insert($coin_transactions);
|
||||
$this->command->info("Saved " . count($coin_transactions) . " coin transactions");
|
||||
}
|
||||
if (!empty($coin_purchases)) {
|
||||
DB::table('coin_purchases')->insert($coin_purchases);
|
||||
$this->command->info("Saved " . count($coin_purchases) . " coin purchases");
|
||||
}
|
||||
$i = 0;
|
||||
$coin_purchases = [];
|
||||
$coin_transactions = [];
|
||||
}
|
||||
}
|
||||
$this->command->info("Last block of coin purchases and transactions");
|
||||
if (!empty($coin_transactions)) {
|
||||
DB::table('coin_transactions')->insert($coin_transactions);
|
||||
$this->command->info("Saved " . count($coin_transactions) . " coin transactions");
|
||||
}
|
||||
if (!empty($coin_purchases)) {
|
||||
DB::table('coin_purchases')->insert($coin_purchases);
|
||||
$this->command->info("Saved " . count($coin_purchases) . " coin purchases");
|
||||
}
|
||||
$this->updateAllUsersBalance();
|
||||
$this->command->info("Game related Transactions Created Successfully.");
|
||||
}
|
||||
|
||||
private function getTimeStampFromDB($strDateFromDB, $minusOneSecond = false)
|
||||
{
|
||||
if ($minusOneSecond) {
|
||||
return date("Y-m-d H:i:s", strtotime($strDateFromDB . " - 1 second"));
|
||||
}
|
||||
return date("Y-m-d H:i:s", strtotime($strDateFromDB));
|
||||
}
|
||||
|
||||
|
||||
private function getRandomPayment($user, &$type, &$ref): void
|
||||
{
|
||||
$type = $this->faker->randomElement(['MBWAY', 'PAYPAL', 'IBAN', 'MB', 'VISA']);
|
||||
switch ($type) {
|
||||
case 'MBWAY':
|
||||
$ref = rand(910000000, 999999999);
|
||||
break;
|
||||
case 'PAYPAL':
|
||||
$ref = $user->email;
|
||||
break;
|
||||
case 'IBAN':
|
||||
$ref = 'PT' . rand(5000000000000, 9999999999999) . rand(1000000000, 9999999999);
|
||||
break;
|
||||
case 'MB':
|
||||
$ref = rand(10000, 99999) . '-' . rand(100000000, 999999999);
|
||||
break;
|
||||
case 'VISA':
|
||||
$ref = rand(40000000, 49999999) . rand(10000000, 99999999);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private $purchaseID = null;
|
||||
private function addPurchase(&$coin_purchases, &$coin_transactions, &$user, $time, $eurosAmount)
|
||||
{
|
||||
$this->purchaseID++;
|
||||
$coins = $eurosAmount * 10;
|
||||
|
||||
$newCoinTransaction = $this->addCoinTransaction($coin_transactions, $user, $coins, 2, $time, null, null);
|
||||
|
||||
$this->getRandomPayment($user, $paymentType, $paymentRef);
|
||||
|
||||
$coin_purchases[] = [
|
||||
'id' => $this->purchaseID,
|
||||
'purchase_datetime' => $time,
|
||||
'user_id' => $user->id,
|
||||
'coin_transaction_id' => $newCoinTransaction['id'],
|
||||
'euros' => $eurosAmount,
|
||||
'payment_type' => $paymentType,
|
||||
'payment_reference' => $paymentRef,
|
||||
'custom' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private $transactionID = 0;
|
||||
private function addCoinTransaction(&$coin_transactions, &$user, $coins, $type, $time, $game_id = null, $match_id = null)
|
||||
{
|
||||
if ($this->transactionID == null) {
|
||||
$this->transactionID = DB::table('coin_transactions')->max('id');
|
||||
}
|
||||
|
||||
// Types of coin transactions:
|
||||
// 1 - Bonus
|
||||
// 2 - Coin purchase
|
||||
// 3 - Game fee - requires game_id
|
||||
// 4 - Match stake - requires match_id
|
||||
// 5 - Game payout - requires game_id
|
||||
// 6 - Match payout - requires match_id
|
||||
if(($type == 3 || $type == 5) && $game_id === null) {
|
||||
throw new \Exception("Game ID is required for coin transaction type $type");
|
||||
}
|
||||
if(($type == 4 || $type == 6) && $match_id === null) {
|
||||
throw new \Exception("Match ID is required for coin transaction type $type");
|
||||
}
|
||||
$this->transactionID++;
|
||||
|
||||
$coins = abs($coins);
|
||||
if (in_array($type, [3, 4])) {
|
||||
$coins *= -1;
|
||||
}
|
||||
|
||||
$newCoinTransaction = [
|
||||
'id' => $this->transactionID,
|
||||
'transaction_datetime' => $time,
|
||||
'user_id' => $user->id,
|
||||
'match_id' => $match_id, // nullable
|
||||
'game_id' => $game_id, // nullable
|
||||
'coin_transaction_type_id' => $type,
|
||||
'coins' => $coins,
|
||||
'custom' => null,
|
||||
];
|
||||
$coin_transactions[] = $newCoinTransaction;
|
||||
$user->coins_balance += $coins;
|
||||
return $newCoinTransaction;
|
||||
}
|
||||
|
||||
private function calculateGamePayouts($game)
|
||||
{
|
||||
$payouts = [0, 0];
|
||||
if ($game->player1_points == $game->player2_points) {
|
||||
$payouts = [1, 1];
|
||||
} elseif ($game->player1_points == 120) {
|
||||
$payouts = [6, 0];
|
||||
} elseif ($game->player2_points == 120) {
|
||||
$payouts = [0, 6];
|
||||
} elseif ($game->player1_points >= 91) {
|
||||
$payouts = [4, 0];
|
||||
} elseif ($game->player2_points >= 91) {
|
||||
$payouts = [0, 4];
|
||||
} elseif ($game->player1_points >= 61) {
|
||||
$payouts = [3, 0];
|
||||
} elseif ($game->player2_points >= 61) {
|
||||
$payouts = [0, 3];
|
||||
} else { // this condition should never be reached
|
||||
$payouts = [0, 0];
|
||||
}
|
||||
return $payouts;
|
||||
}
|
||||
|
||||
private function calculateMatchPayouts($match)
|
||||
{
|
||||
$payouts = [0, 0];
|
||||
if ($match->winner_user_id == $match->player1_user_id) {
|
||||
$payouts = [$match->stake * 2 - 1, 0];
|
||||
} elseif ($match->winner_user_id == $match->player2_user_id) {
|
||||
$payouts = [0, $match->stake * 2 - 1];
|
||||
}
|
||||
return $payouts;
|
||||
}
|
||||
|
||||
private function handleGame(&$coin_purchases, &$coin_transactions, $game)
|
||||
{
|
||||
$player1 = $this->users->where('id', $game->player1_user_id)->first();
|
||||
$player2 = $this->users->where('id', $game->player2_user_id)->first();
|
||||
// not enough coins to play - must purchase before the game
|
||||
$timeOneSecondBefore = $this->getTimeStampFromDB($game->began_at, true);
|
||||
if ($player1->coins_balance < 2) {
|
||||
$this->addPurchase($coin_purchases, $coin_transactions, $player1, $timeOneSecondBefore, random_int(1, 10));
|
||||
}
|
||||
// not enough coins to play - must purchase before the game
|
||||
if ($player2->coins_balance < 2) {
|
||||
$this->addPurchase($coin_purchases, $coin_transactions, $player2, $timeOneSecondBefore, random_int(1, 10));
|
||||
}
|
||||
$this->addCoinTransaction($coin_transactions, $player1, -2, 3, $this->getTimeStampFromDB($game->began_at, false), $game->id);
|
||||
$this->addCoinTransaction($coin_transactions, $player2, -2, 3, $this->getTimeStampFromDB($game->began_at, false), $game->id);
|
||||
$payouts = $this->calculateGamePayouts($game);
|
||||
if ($payouts[0] > 0) {
|
||||
$this->addCoinTransaction($coin_transactions, $player1, $payouts[0], 5, $this->getTimeStampFromDB($game->ended_at, false), $game->id);
|
||||
}
|
||||
if ($payouts[1] > 0) {
|
||||
$this->addCoinTransaction($coin_transactions, $player2, $payouts[1], 5, $this->getTimeStampFromDB($game->ended_at, false), $game->id);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleMatch(&$coin_purchases, &$coin_transactions, $match)
|
||||
{
|
||||
$player1 = $this->users->where('id', $match->player1_user_id)->first();
|
||||
$player2 = $this->users->where('id', $match->player2_user_id)->first();
|
||||
// not enough coins to play - must purchase before the match
|
||||
$timeOneSecondBefore = $this->getTimeStampFromDB($match->began_at, true);
|
||||
if ($player1->coins_balance < $match->stake) {
|
||||
$requiredCoins = $match->stake - $player1->coins_balance;
|
||||
$euros = ceil(($requiredCoins + 3) / 10);
|
||||
$this->addPurchase($coin_purchases, $coin_transactions, $player1, $timeOneSecondBefore, $euros);
|
||||
}
|
||||
// not enough coins to play - must purchase before the match
|
||||
if ($player2->coins_balance < $match->stake) {
|
||||
$requiredCoins = $match->stake - $player2->coins_balance;
|
||||
$euros = ceil(($requiredCoins + 3) / 10);
|
||||
$this->addPurchase($coin_purchases, $coin_transactions, $player2, $timeOneSecondBefore, $euros);
|
||||
}
|
||||
$this->addCoinTransaction($coin_transactions, $player1, $match->stake * -1, 4, $this->getTimeStampFromDB($match->began_at, false), null, $match->id);
|
||||
$this->addCoinTransaction($coin_transactions, $player2, $match->stake * -1, 4, $this->getTimeStampFromDB($match->began_at, false), null, $match->id);
|
||||
$payouts = $this->calculateMatchPayouts($match);
|
||||
if ($payouts[0] > 0) {
|
||||
$this->addCoinTransaction($coin_transactions, $player1, $payouts[0], 6, $this->getTimeStampFromDB($match->ended_at, false), null, $match->id);
|
||||
}
|
||||
if ($payouts[1] > 0) {
|
||||
$this->addCoinTransaction($coin_transactions, $player2, $payouts[1], 6, $this->getTimeStampFromDB($match->ended_at, false), null, $match->id);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateAllUsersBalance()
|
||||
{
|
||||
$this->command->info("Update users balances...");
|
||||
foreach ($this->users as $user) {
|
||||
DB::table('users')->where('id', $user->id)->update(['coins_balance' => $user->coins_balance]);
|
||||
}
|
||||
$this->command->info("All users balances updated.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use stdClass;
|
||||
|
||||
class InitialTransactionsSeeder extends Seeder
|
||||
{
|
||||
private $allPlayers;
|
||||
private $sizeOfSaveBlock = 200;
|
||||
private $valueOfInitialBonus = 10;
|
||||
public function run(): void
|
||||
{
|
||||
$this->allPlayers = DB::table('users')->where('type', 'P')->get();
|
||||
$this->command->info("Create initial Bonuses transactions");
|
||||
|
||||
$i = $this->sizeOfSaveBlock;
|
||||
$dataBlock = [];
|
||||
foreach ($this->allPlayers as $player) {
|
||||
if ($i <= 0) {
|
||||
$this->saveDataBlock($dataBlock);
|
||||
$i = $this->sizeOfSaveBlock;
|
||||
$dataBlock = [];
|
||||
}
|
||||
$dataBlock[] = $this->createBonusTransaction($player);
|
||||
$i--;
|
||||
}
|
||||
if (!empty($dataBlock)) {
|
||||
$this->saveDataBlock($dataBlock);
|
||||
}
|
||||
$this->updateAllPlayerBalances();
|
||||
}
|
||||
|
||||
private function saveDataBlock($dataBlock): void
|
||||
{
|
||||
$size = count($dataBlock);
|
||||
DB::table('coin_transactions')->insert($dataBlock);
|
||||
$this->command->info("Saved $size initial bonuses transactions");
|
||||
}
|
||||
|
||||
private function createBonusTransaction($player): array
|
||||
{
|
||||
return [
|
||||
'transaction_datetime' => $player->created_at,
|
||||
'user_id' => $player->id,
|
||||
'match_id' => null,
|
||||
'game_id' => null,
|
||||
'coin_transaction_type_id' => 1,
|
||||
'coins' => $this->valueOfInitialBonus,
|
||||
'custom' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function updateAllPlayerBalances(): void
|
||||
{
|
||||
DB::table('users')->where('type', 'P')->update(['coins_balance' => $this->valueOfInitialBonus]);
|
||||
$this->command->info("Updated all players initial Balance");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
trait SeederUtils
|
||||
{
|
||||
private static $used_emails = [];
|
||||
|
||||
public function randomString(int $length = 10): string
|
||||
{
|
||||
return substr(str_shuffle('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 0, $length);
|
||||
}
|
||||
|
||||
public function integerAsStringWithPadding(int $num, int $length = 10): string
|
||||
{
|
||||
return str_pad((string) $num, $length, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function randomIntegerAsStringWithPadding(int $min = 0, int $max = 100, int $length = 10): string
|
||||
{
|
||||
return $this->integerAsStringWithPadding($this->mt_rand($min, $max), $length);
|
||||
}
|
||||
|
||||
public function randomDecimal(float $min, float $max, int $decimals = 2): float
|
||||
{
|
||||
$factor = pow(10, $decimals);
|
||||
return mt_rand($min * $factor, $max * $factor) / $factor;
|
||||
}
|
||||
|
||||
|
||||
public function getFileNameFromString($str, $extension)
|
||||
{
|
||||
return str_replace(' ', '_', strtolower($this->stripAccents($str))) . ".$extension";
|
||||
}
|
||||
|
||||
public function stripAccents($stripAccents)
|
||||
{
|
||||
$from = 'àáâãäçèéêëìíîïñòóôõöùúûüýÿÀÁÂÃÄÇÈÉÊËÌÍÎÏÑÒÓÔÕÖÙÚÛÜÝ';
|
||||
$to = 'aaaaaceeeeiiiinooooouuuuyyAAAAACEEEEIIIINOOOOOUUUUY';
|
||||
$keys = array();
|
||||
$values = array();
|
||||
preg_match_all('/./u', $from, $keys);
|
||||
preg_match_all('/./u', $to, $values);
|
||||
$mapping = array_combine($keys[0], $values[0]);
|
||||
return strtr($stripAccents, $mapping);
|
||||
}
|
||||
|
||||
public function strtr_utf8($str, $from, $to)
|
||||
{
|
||||
$keys = array();
|
||||
$values = array();
|
||||
preg_match_all('/./u', $from, $keys);
|
||||
preg_match_all('/./u', $to, $values);
|
||||
$mapping = array_combine($keys[0], $values[0]);
|
||||
return strtr($str, $mapping);
|
||||
}
|
||||
|
||||
public function randomName($faker, &$gender, &$fullname, &$email, $allowRepeated = false)
|
||||
{
|
||||
$gender = $gender ?? $faker->randomElement(['male', 'female']);
|
||||
$firstname = $faker->firstName($gender);
|
||||
$lastname = $faker->lastName();
|
||||
$secondname = mt_rand(1, 3) == 2 ? "" : " " . $faker->firstName($gender);
|
||||
$number_middlenames = $faker->randomElement([0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 3]);
|
||||
$middlenames = "";
|
||||
for ($i = 0; $i < $number_middlenames; $i++) {
|
||||
$middlenames .= " " . $faker->lastName();
|
||||
}
|
||||
$fullname = $firstname . $secondname . $middlenames . " " . $lastname;
|
||||
$email = strtolower($this->stripAccents($firstname) . "." . $this->stripAccents($lastname) . "@mail.pt");
|
||||
if (!$allowRepeated) {
|
||||
$i = 2;
|
||||
while (in_array($email, self::$used_emails)) {
|
||||
$email = strtolower($this->stripAccents($firstname) . "." . $this->stripAccents($lastname) . "." . $i ."@mail.pt");
|
||||
$i++;
|
||||
if ($i > 10) {
|
||||
$this->command->error("Repeated email - not possible to create a unique email for $fullname!");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
self::$used_emails[] = $email;
|
||||
$gender = $gender == 'male' ? 'M' : 'F';
|
||||
}
|
||||
|
||||
public function ramdomPaymentMethod($email, &$paymentType, &$paymentReference)
|
||||
{
|
||||
$paymentType = $this->faker->randomElement(['Visa', 'PayPal', 'MB WAY']);
|
||||
$paymentReference = match ($paymentType) {
|
||||
'Visa' => mt_rand(4, 6) .
|
||||
$this->faker->randomNumber($nbDigits = 8, $strict = true) .
|
||||
$this->faker->randomNumber($nbDigits = 7, $strict = true),
|
||||
'PayPal' => $email,
|
||||
'MB WAY' => '9' . $this->faker->randomNumber($nbDigits = 8, $strict = true)
|
||||
};
|
||||
}
|
||||
|
||||
public function cleanStorageFolder($folder, $public = true)
|
||||
{
|
||||
$storagePath = $public ? storage_path("app/public/$folder") : storage_path("app/private/$folder");
|
||||
if (File::exists($storagePath)) {
|
||||
File::deleteDirectory($storagePath);
|
||||
}
|
||||
if (!File::exists(storage_path("app"))) {
|
||||
File::makeDirectory(storage_path("app"));
|
||||
}
|
||||
if ($public) {
|
||||
if (!File::exists(storage_path("app/public"))) {
|
||||
File::makeDirectory(storage_path("app/public"));
|
||||
}
|
||||
} else {
|
||||
if (!File::exists(storage_path("app/private"))) {
|
||||
File::makeDirectory(storage_path("app/private"));
|
||||
}
|
||||
}
|
||||
File::makeDirectory($storagePath);
|
||||
}
|
||||
|
||||
|
||||
public function copyFileToStorage($originalFolder, $originalFileName, $folder, $id = null, $public = true)
|
||||
{
|
||||
$originalFullName = database_path("seeders/$originalFolder") . '/' . $originalFileName;
|
||||
$storagePath = $public ? storage_path("app/public/$folder") : storage_path("app/private/$folder");
|
||||
$prefix = $id ? $this->integerAsStringWithPadding($id, 5) . '_' : '';
|
||||
$ext = pathinfo($originalFullName, PATHINFO_EXTENSION);
|
||||
$newFileName = $prefix . $this->randomString(10) . '.' . $ext;
|
||||
if (!File::exists($storagePath)) {
|
||||
File::makeDirectory($storagePath);
|
||||
}
|
||||
if (File::exists($originalFullName)) {
|
||||
File::copy($originalFullName, "$storagePath/$newFileName");
|
||||
return $newFileName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function directCopyFileToStorage($originalFolder, $originalFileName, $folder, $public = true)
|
||||
{
|
||||
$originalFullName = database_path("seeders/$originalFolder") . '/' . $originalFileName;
|
||||
$storagePath = $public ? storage_path("app/public/$folder") : storage_path("app/private/$folder");
|
||||
if (!File::exists($storagePath)) {
|
||||
File::makeDirectory($storagePath);
|
||||
}
|
||||
File::copy($originalFullName, "$storagePath/$originalFileName");
|
||||
return $originalFileName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TransactionTypesSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->line("Creating Coin Transaction Types.");
|
||||
|
||||
// Add default coin_transaction_types
|
||||
DB::table('coin_transaction_types')->insert([
|
||||
['id' => 1, 'name' => 'Bonus', 'type' => 'C'], // id = 1
|
||||
['id' => 2, 'name' => 'Coin purchase', 'type' => 'C'], // id = 2
|
||||
['id' => 3, 'name' => 'Game fee', 'type' => 'D'], // id = 3
|
||||
['id' => 4, 'name' => 'Match stake', 'type' => 'D'], // id = 4
|
||||
['id' => 5, 'name' => 'Game payout', 'type' => 'C'], // id = 5
|
||||
['id' => 6, 'name' => 'Match payout', 'type' => 'C'], // id = 6
|
||||
]);
|
||||
|
||||
$this->command->line("Coin Transaction Types Created Successfully.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Faker\Factory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use stdClass;
|
||||
|
||||
class UsersSeeder extends Seeder
|
||||
{
|
||||
use SeederUtils;
|
||||
|
||||
private $faker = null;
|
||||
private $files_M = [];
|
||||
private $files_F = [];
|
||||
public static $hashPasword = "";
|
||||
public static $dbUsers = null;
|
||||
|
||||
|
||||
public static $fixedUsers = [
|
||||
['type' => 'A', 'name' => 'First Administrator', 'email' => '[email protected]', 'gender' => 'M', 'softdelete' => false],
|
||||
['type' => 'A', 'name' => 'Second Administrator', 'email' => '[email protected]', 'gender' => 'F', 'softdelete' => false],
|
||||
['type' => 'A', 'name' => 'Third Administrator', 'email' => '[email protected]', 'gender' => 'M', 'softdelete' => false],
|
||||
['type' => 'A', 'name' => 'Forth Administrator', 'email' => '[email protected]', 'gender' => 'M', 'softdelete' => true],
|
||||
|
||||
['type' => 'P', 'name' => 'Player A', 'email' => '[email protected]', 'gender' => 'M', 'softdelete' => false],
|
||||
['type' => 'P', 'name' => 'Player B', 'email' => '[email protected]', 'gender' => 'M', 'softdelete' => false],
|
||||
['type' => 'P', 'name' => 'Player C', 'email' => '[email protected]', 'gender' => 'F', 'softdelete' => false],
|
||||
['type' => 'P', 'name' => 'Player D', 'email' => '[email protected]', 'gender' => 'M', 'softdelete' => true],
|
||||
['type' => 'P', 'name' => 'Player E', 'email' => '[email protected]', 'gender' => 'F', 'softdelete' => false],
|
||||
['type' => 'P', 'name' => 'Player F', 'email' => '[email protected]', 'gender' => 'M', 'softdelete' => true],
|
||||
];
|
||||
|
||||
public static $userTypes = [
|
||||
'A' => 10,
|
||||
'P' => 500];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->line("Creating Users.");
|
||||
self::$hashPasword = bcrypt('123');
|
||||
|
||||
//$this->faker = Factory::create('pt_PT');
|
||||
$this->faker = Factory::create(DatabaseSeeder::$seedLanguage);
|
||||
|
||||
$this->cleanStorageFolder('photos_avatars');
|
||||
|
||||
$this->addUsersToDatabase();
|
||||
$this->addPhotoFiles();
|
||||
DB::table('users')->where('type', 'A')->update(['nickname' => null]);
|
||||
self::$dbUsers = DB::table('users')->orderBy('id')->get();
|
||||
$this->command->line("Users Created Successfully.");
|
||||
}
|
||||
|
||||
private function addUsersToDatabase()
|
||||
{
|
||||
$this->command->line("Adding users to the database");
|
||||
$usersAdded = Self::$fixedUsers;
|
||||
foreach (self::$userTypes as $userType => $totalUsers) {
|
||||
for ($i = 0; $i < $totalUsers; $i++) {
|
||||
$gender = null;
|
||||
$name = null;
|
||||
$email = null;
|
||||
$this->randomName($this->faker, $gender, $name, $email, false);
|
||||
$usersAdded[] = [
|
||||
'type' => $userType,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'gender' => $gender,
|
||||
'softdelete' => random_int(1, 20) == 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$createdDate = DatabaseSeeder::$startDate->copy()->addMinutes(mt_rand(20000, 100000));
|
||||
foreach ($usersAdded as $key => $user) {
|
||||
$usersAdded[$key]['password'] = self::$hashPasword;
|
||||
$createdDate = $createdDate->copy()->addMinutes(mt_rand(10, 60));
|
||||
$usersAdded[$key]['created_at'] = $createdDate;
|
||||
$usersAdded[$key]['email_verified_at'] = $createdDate->copy()->addMinutes(mt_rand(1, 9));
|
||||
if (random_int(1, 7) > 1) {
|
||||
$usersAdded[$key]['updated_at'] = $createdDate->copy()->addMinutes(mt_rand(10, 10000));
|
||||
} else {
|
||||
$usersAdded[$key]['updated_at'] = $createdDate;
|
||||
}
|
||||
$usersAdded[$key]['nickname'] = ($user['gender'] == 'M' ? 'Mickey' : 'Minnie') . ($key + 1) ;
|
||||
$usersAdded[$key]['blocked'] = false;
|
||||
$usersAdded[$key]['photo_avatar_filename'] = null;
|
||||
$usersAdded[$key]['coins_balance'] = 0;
|
||||
$usersAdded[$key]['deleted_at'] = null;
|
||||
if ($usersAdded[$key]['softdelete']) {
|
||||
$usersAdded[$key]['deleted_at'] = $createdDate->copy()->addMinutes(mt_rand(10001, 100000));
|
||||
}
|
||||
}
|
||||
|
||||
$arrayToStore = [];
|
||||
foreach ($usersAdded as $user) {
|
||||
unset($user['gender']);
|
||||
unset($user['softdelete']);
|
||||
$arrayToStore[] = $user;
|
||||
|
||||
if (count($arrayToStore) >= DatabaseSeeder::$dbInsertBlockSize) {
|
||||
DB::table('users')->insert($arrayToStore);
|
||||
$this->command->line("Created " . count($arrayToStore) . " users");
|
||||
$arrayToStore = [];
|
||||
}
|
||||
}
|
||||
if (count($arrayToStore) >= 1) {
|
||||
DB::table('users')->insert($arrayToStore);
|
||||
$this->command->line("Created " . count($arrayToStore) . " users");
|
||||
}
|
||||
|
||||
$this->command->line("Total users created: " . DB::table('users')->count());
|
||||
self::$dbUsers = DB::table('users')->get();
|
||||
|
||||
print_r(self::$dbUsers);
|
||||
}
|
||||
|
||||
private function addPhotoFiles()
|
||||
{
|
||||
$this->command->line("Copying users' photos");
|
||||
$this->fillPhotoFilesNames();
|
||||
$total = count($this->files_M) + count($this->files_F);
|
||||
$sortedUsers = self::$dbUsers->sortBy(function (stdClass $user) {
|
||||
if ($user->id <= 10 ) {
|
||||
return $user->id;
|
||||
}
|
||||
return match($user->type) {
|
||||
'A' => 20 + $user->id,
|
||||
'P' => 1000 + $user->id,
|
||||
default => ($user->id < 50) ? 2000 + $user->id : 3000 + mt_rand(0, 100000),
|
||||
};
|
||||
});
|
||||
$i = 0;
|
||||
foreach($sortedUsers as $user) {
|
||||
$originalFilename = str_starts_with($user->nickname, 'Mickey') ? array_shift($this->files_M) : array_shift($this->files_F);
|
||||
if (!$originalFilename) {
|
||||
if ((count($this->files_M) == 0) && (count($this->files_F) == 0))
|
||||
break;
|
||||
}
|
||||
if ($originalFilename) {
|
||||
$originalFilename = basename($originalFilename);
|
||||
$newFileName = $this->copyFileToStorage('photos', $originalFilename, 'photos_avatars', $user->id);
|
||||
$user->photo_avatar_filename = $newFileName;
|
||||
DB::table('users')->where('id', $user->id)->update(['photo_avatar_filename' => $user->photo_avatar_filename]);
|
||||
$i++;
|
||||
if ($i % 10 == 0) {
|
||||
$this->command->line("User photo $i/$total copied");
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->command->line("Total of $total user's photos were copied!");
|
||||
$this->directCopyFileToStorage('photos', 'anonymous.png', 'photos_avatars');
|
||||
$this->command->line("Image for user with no associated photo was copied!");
|
||||
}
|
||||
|
||||
private function fillPhotoFilesNames()
|
||||
{
|
||||
$allFiles = collect(File::files(database_path('seeders/photos')));
|
||||
foreach ($allFiles as $f) {
|
||||
if (strpos($f->getPathname(), 'm_')) {
|
||||
$this->files_M[] = $f->getPathname();
|
||||
} else if (strpos($f->getPathname(), 'w_')) {
|
||||
$this->files_F[] = $f->getPathname();
|
||||
}
|
||||
}
|
||||
shuffle($this->files_M);
|
||||
shuffle($this->files_F);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 601 KiB |
|
After Width: | Height: | Size: 543 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 550 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 543 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 507 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 560 KiB |
|
After Width: | Height: | Size: 581 KiB |
|
After Width: | Height: | Size: 593 KiB |
|
After Width: | Height: | Size: 588 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 542 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 584 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 555 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 531 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 579 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 593 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 577 KiB |
|
After Width: | Height: | Size: 511 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 658 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 546 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
After Width: | Height: | Size: 555 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 494 KiB |
|
After Width: | Height: | Size: 605 KiB |
|
After Width: | Height: | Size: 623 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 519 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 604 KiB |
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 558 KiB |