Skip to content
Snippets Groups Projects
Unverified Commit 2cc58d41 authored by Maxime FRIESS's avatar Maxime FRIESS :blue_heart:
Browse files

Merge branch 'release/1.3.0'

parents c08649ad a9ff2833
Branches
Tags 1.1.0
No related merge requests found
Pipeline #107188 passed with stages
in 57 seconds
Showing
with 415 additions and 53 deletions
......@@ -9,6 +9,7 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
BROADCAST_DRIVER=log
CACHE_DRIVER=file
......
......@@ -6,6 +6,8 @@
use App\Http\Requests\LoginRequest;
use App\Http\Requests\RecoverAccountRequest;
use App\Http\Requests\RegisterRequest;
use App\Http\Requests\ResetPasswordRequest;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
......@@ -108,8 +110,6 @@ public function register(RegisterRequest $request)
'password' => Hash::make($request->password),
]);
$token = Auth::guard('client')->login($user);
event(new Registered($user));
return response()->noContent();
......@@ -224,4 +224,60 @@ public function recover(RecoverAccountRequest $request)
], 429);
}
}
/**
* @OA\Post(
* tags={"Clients:Authentication"},
* path="/auth/reset-password",
* summary="Recover an account",
* @OA\RequestBody(
* ref="#/components/requestBodies/ResetPassword"
* ),
* @OA\Response(
* response="401",
* ref="#/components/responses/401"
* ),
* @OA\Response(
* response="422",
* ref="#/components/responses/422"
* ),
* @OA\Response(
* response="429",
* description="Too Many Requests",
* @OA\JsonContent(
* @OA\Property(
* property="message",
* type="string",
* ),
* )
* ),
* @OA\Response(
* response="204",
* description="OK",
* ),
* )
*/
public function reset_password(ResetPasswordRequest $request)
{
$status = Password::reset(
$request->only('email', 'password', 'token'),
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password)
]);
$user->save();
event(new PasswordReset($user));
}
);
if ($status == Password::INVALID_USER || $status == Password::INVALID_TOKEN) {
return response()->json([
"message" => __($status)
], 401);
} else if ($status == Password::PASSWORD_RESET) {
return response()->noContent();
}
}
}
......@@ -173,7 +173,7 @@ public function profile()
public function heartbeat(ServerHeartbeatRequest $request)
{
$server = Auth::guard('server')->user();
$server->heartbeat($request->input("ip"), $request->input("port"), $request->input("pubkey"));
$server->heartbeat($request->input("ip"), $request->input("port"), $request->input("pubkey"), $request->input("team"));
return response()->noContent();
}
......
......@@ -40,7 +40,7 @@ class GameController extends Controller
public function index()
{
return response()->json([
"games" => Game::all()
"games" => Game::with("users:id")->get()
]);
}
......@@ -229,13 +229,17 @@ public function store(CreateGameRequest $request)
DB::beginTransaction();
$game = Game::create([
"server_id" => $server->id
"server_id" => $server->id,
"mode" => $request->input("mode"),
]);
foreach ($request->input("users") as $user) {
$user = User::findOrFail($user["id"]);
foreach ($request->input("users") as $u) {
$user = User::findOrFail($u["id"]);
if ($user->server_id === $server->id && $user->server_state === 'connected') {
$game->users()->attach($user->id, []);
if ($game->mode == "team")
$game->users()->attach($user->id, ['team_id' => $u['team_id']]);
else
$game->users()->attach($user->id, ['team_id' => null]);
} else {
DB::rollBack();
return response()->json([
......@@ -246,6 +250,8 @@ public function store(CreateGameRequest $request)
DB::commit();
$game->start();
return response()->json([
"game" => $game
], 201);
......@@ -304,17 +310,18 @@ public function update(UpdateGameRequest $request, $id)
{
abort(404);
}
if ($game->state === 'created' && $request->input('state') === 'playing')
{
$game->start();
return response()->json([
"game" => $game
], 200);
}
if ($game->state === 'playing' && $request->input('state') === 'finished')
{
foreach($request->input('users') as $user) {
if ($game->users()->find($user["id"]) == null) {
return response()->json([
"message" => "Forbidden access to user " . $user["id"]
], 403);
}
}
$game->end($request->input('users'));
return response()->json([
"game" => $game
......
......@@ -40,7 +40,7 @@ class ServerController extends Controller
public function index()
{
return response()->json([
"servers" => Server::where('public', 1)->get()
"servers" => Server::all()
]);
}
......@@ -300,6 +300,7 @@ public function update(UpdateServerRequest $request, $id)
public function destroy($id)
{
$server = Server::findOrFail($id);
$server->stop();
if ($server->owner_id !== Auth::user()->id) {
return response()->json(['message' => 'Not authorized.'], 403);
}
......
......@@ -9,16 +9,28 @@
* request="CreateGame",
* required=true,
* @OA\JsonContent(
* required={"users"},
* required={"users","mode"},
* @OA\Property(
* property="mode",
* type="string",
* description="Mode of the game",
* enum={"ffa", "team"}
* ),
* @OA\Property(
* property="users",
* type="array",
* description="Users who participated in the game",
* @OA\Items(
* required={"id","team_id"},
* @OA\Property(
* property="id",
* type="int",
* description="ID of the user"
* ),
* @OA\Property(
* property="team_id",
* type="int",
* description="ID of the team of the user"
* )
* )
* ),
......@@ -46,7 +58,9 @@ public function rules()
{
return [
"users" => "required|array|min:2",
"users.*.id" => "required|exists:users,id|distinct"
"users.*.id" => "required|exists:users,id|distinct",
"users.*.team_id" => "required_if:mode,team|numeric|integer",
"mode" => "required|string|in:ffa,team"
];
}
}
......@@ -20,11 +20,6 @@
* type="string",
* description="Password of the server",
* ),
* @OA\Property(
* property="public",
* type="boolean",
* description="Whether or not the server should show up in server listing",
* ),
* ),
* ),
*/
......@@ -49,8 +44,7 @@ public function rules()
{
return [
'login' => 'required|string|max:255|unique:servers',
'password' => 'required|string|min:6',
'public' => 'required|boolean'
'password' => 'required|string|min:6'
];
}
}
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\RequestBody(
* request="ResetPassword",
* required=true,
* @OA\JsonContent(
* required={"token", "email", "password"},
* @OA\Property(
* property="token",
* type="string",
* description="Recovery token",
* ),
* @OA\Property(
* property="email",
* type="string",
* format="email",
* description="Email of the user",
* ),
* @OA\Property(
* property="password",
* type="string",
* description="New password of the user",
* ),
* ),
* ),
*/
class ResetPasswordRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'token' => 'required',
'email' => 'required|email',
'password' => 'required|string|min:6',
];
}
}
......@@ -16,12 +16,17 @@
* format="ip",
* description="IP of the server",
* ),
* @OA\Property(
* @OA\Property(
* property="port",
* type="integer",
* description="Port of the server",
* ),
* @OA\Property(
* property="team",
* type="boolean",
* description="True if the server is in teams mode, false if server is in FFA mode",
* ),
* @OA\Property(
* property="pubkey",
* type="string",
* description="Public RSA key of the server",
......@@ -53,6 +58,7 @@ public function rules()
return [
'ip' => 'required|ip',
'port' => 'required|integer|numeric',
'team' => 'required|boolean',
'pubkey' => ['required', 'string', 'max:1024', 'regex:/^-----BEGIN PUBLIC KEY-----(\n|\r|\r\n)([0-9a-zA-Z\+\/=]{64}(\n|\r|\r\n))*([0-9a-zA-Z\+\/=]{1,63}(\n|\r|\r\n))?-----END PUBLIC KEY-----$/']
];
}
......
......@@ -13,17 +13,28 @@
* @OA\Property(
* property="state",
* type="string",
* pattern="^playing|finished$"
* enum={"playing", "finished"}
* ),
* @OA\Property(
* property="users",
* type="array",
* description="Users who participated in the game. Only needed when state == finished",
* @OA\Items(
* required={"id","winner", "rank"},
* @OA\Property(
* property="id",
* type="int",
* description="ID of the user"
* ),
* @OA\Property(
* property="winner",
* type="boolean",
* description="Wether the user won or not (can have multiple winners in team mode)"
* ),
* @OA\Property(
* property="rank",
* type="int",
* description="Rank of the user (from 1 to 4). In team mode, the winning team is 1 and the losing team is 2"
* )
* )
* ),
......@@ -50,9 +61,10 @@ public function authorize()
public function rules()
{
return [
'state' => 'required|string|in:playing,finished',
'state' => 'required|string|in:finished',
"users" => "required_if:state,finished|array|min:2",
"users.*.id" => "required_with:users|exists:users,id|distinct"
"users.*.id" => "required_with:users|exists:users,id|distinct",
"users.*.rank" => "required_with:users|numeric|integer",
];
}
}
......@@ -19,11 +19,6 @@
* type="string",
* description="Password of the user",
* ),
* @OA\Property(
* property="public",
* type="boolean",
* description="Whether or not the server should show up in server listing",
* ),
* ),
* ),
*/
......@@ -48,8 +43,7 @@ public function rules()
{
return [
'login' => 'sometimes|required|string|max:255|unique:servers',
'password' => 'sometimes|required|string|min:6',
'public' => 'sometimes|required|boolean'
'password' => 'sometimes|required|string|min:6'
];
}
}
......@@ -6,7 +6,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Builder;
/**
* @OA\Schema(
......@@ -14,6 +13,7 @@
* type="object",
* @OA\Property(type="integer", property="id"),
* @OA\Property(type="integer", property="server_id"),
* @OA\Property(type="number", property="elo"),
* @OA\Property(type="string", property="created_at", format="date-time"),
* @OA\Property(type="string", property="updated_at", format="date-time"),
* )
......@@ -26,7 +26,7 @@ protected static function booted(): void
{
/*
static::addGlobalScope('finished', function (Builder $builder) {
$builder->where("state", "finished");
$builder->where("state", "finished");
});
*/
}
......@@ -38,6 +38,7 @@ protected static function booted(): void
*/
protected $fillable = [
'server_id',
'mode',
];
protected $hidden = [
......@@ -46,7 +47,7 @@ protected static function booted(): void
public function users()
{
return $this->belongsToMany(user::class, 'users_games', 'game_id', 'user_id')->using(UserGame::class);
return $this->belongsToMany(User::class, 'users_games', 'game_id', 'user_id')->using(UserGame::class)->withPivot("team_id", "rank", "elo");
}
public function server(): BelongsTo
......@@ -61,10 +62,134 @@ public function start()
$this->save();
}
private function calculate_ranks($user_data)
{
// We first do the initial sorting to remove all oddities
// that the server could have added
// (we sort in reverse because the server sorts the leaderboard in reverse)
usort($user_data, function ($a, $b) {
return $b["rank"] <=> $a["rank"];
});
// We set the rank to a value between [1;4] and add the team id
$sorted_user_data = [];
foreach ($user_data as $k => $user) {
$u = $this->users()->findOrFail($user["id"]);
$sorted_user_data[] = [
"id" => $user["id"],
"rank" => $k + 1,
"team_id" => $u->pivot->team_id,
"elo" => $u->elo,
"game_elo" => 0
];
}
$user_data = $sorted_user_data;
if (strcmp($this->mode, "team") == 0) {
$winning_team = $user_data[0]["team_id"];
// We sort again but this time applying team logic
// We cut the players in two sets, wining team and losing team
// We sort woth sets and add them back together
// Exemple: (id, team_id, rank): (1, 0, 1), (2, 1, 2), (3, 1, 3), (4, 0, 4)
// Will be sorted (1, 0, 1), (4, 0, 4), (2, 1, 2), (3, 1, 3)
usort($user_data, function ($a, $b) use ($winning_team) {
if ($a["team_id"] == $winning_team && $b["team_id"] == $winning_team) {
return $a["rank"] <=> $b["rank"];
} else if ($a["team_id"] == $winning_team && $b["team_id"] != $winning_team) {
return -1;
} else if ($a["team_id"] != $winning_team && $b["team_id"] == $winning_team) {
return 1;
}
return $a["rank"] <=> $b["rank"];
});
// We then have to re-apply the rank-setting function to set the ranks in stone
$sorted_user_data = [];
foreach ($user_data as $k => $user) {
$u = $this->users()->findOrFail($user["id"]);
$user["rank"] = $k + 1;
$sorted_user_data[] = $user;
}
$user_data = $sorted_user_data;
}
return $user_data;
}
private function probability_function($users, $user, $D, $N)
{
// We first calculate the probabilit of winning for the player.
// For player A agains all players i, R_x being the current ELO of x
// and N the number of players in a game, we have
// E_A = { sum from {1 <= i <= N, i <> A} {1 over {1 + 10^{(R_i-R_a) / D}}}} over { {N cdot (N - 1)} over 2 }
$E_A = 0;
foreach($users as $other) {
if ($user == $other)
continue;
$E_A += 1 / (1 + 10**(($other["elo"] - $user["elo"]) / $D));
}
return $E_A / ($N * ($N - 1) / 2);
}
private function score_function($user, $alpha, $N) {
// This function calculates the gain from the rank of the player
// S^exp_A ( p_A, %alpha ) = { a^{N - p_A} - 1 } over { sum from i = 1 to N {a^{N-i} - 1} }; %alpha in [ 0;+infinity ]
$top = ($alpha ** ($N - $user["rank"]) - 1);
$bottom = 0;
for($i = 1; $i <= $N; $i++) {
$bottom += $alpha ** ($N - $i) - 1;
}
return $top / $bottom;
}
private function calculate_elo($user_data)
{
// Taken from https://towardsdatascience.com/developing-a-generalized-elo-rating-system-for-multiplayer-games-b9b495e87802
$D = 400;
$K = 32;
$alpha = 2;
$N = count($user_data);
// We first calculate the probabilit of winning for each players.
// For player A agains all players i, R_x being the current ELO of x
// and N the number of players in a game, we have
// E_A = { sum from {1 <= i <= N, i <> A} {1 over {1 + 10^{(R_i-R_a) / D}}}} over { {N cdot (N - 1)} over 2 }
foreach($user_data as &$user)
{
$user["E_A"] = $this->probability_function($user_data, $user, $D, $N);
$user["new_elo"] = $user["elo"] + $K * ($N - 1) * ($this->score_function($user, $alpha, $N) - $user["E_A"]);
$user["game_elo"] = $user["new_elo"] - $user["elo"];
}
return $user_data;
}
public function end($user_data)
{
$user_data = $this->calculate_ranks($user_data);
if ($this->server->official)
{
$user_data = $this->calculate_elo($user_data);
}
foreach ($user_data as $user) {
$this->users()->updateExistingPivot($user['id'], ["rank" => $user["rank"], "elo" => $user["game_elo"]]);
$u = User::find($user["id"]);
if ($u !== null) {
$u->elo = $user["new_elo"];
$u->save();
}
}
$this->finished_at = Carbon::now();
$this->state = 'finished';
$this->save();
$this->push();
}
}
}
\ No newline at end of file
......@@ -18,8 +18,9 @@
* @OA\Property(type="integer", property="id"),
* @OA\Property(type="integer", property="owner_id"),
* @OA\Property(type="string", property="login"),
* @OA\Property(type="boolean", property="public"),
* @OA\Property(type="boolean", property="official"),
* @OA\Property(type="boolean", property="online"),
* @OA\Property(type="boolean", property="team"),
* @OA\Property(type="string", property="created_at", format="date-time"),
* @OA\Property(type="string", property="updated_at", format="date-time")
* )
......@@ -37,7 +38,6 @@ class Server extends Authenticatable implements JWTSubject
'owner_id',
'login',
'password',
'public',
];
/**
......@@ -54,8 +54,9 @@ class Server extends Authenticatable implements JWTSubject
];
protected $casts = [
'public' => 'boolean',
'official' => 'boolean',
'online' => 'boolean',
'team' => 'boolean',
'last_heartbeat_at' => 'datetime'
];
......@@ -94,13 +95,17 @@ public function getJWTCustomClaims()
return [];
}
public function heartbeat(string $ip, int $port, string $pubkey)
public function heartbeat(string $ip, int $port, string $pubkey, bool $team)
{
if ($pubkey !== $this->pubkey)
$this->stop();
$this->last_heartbeat_at = Carbon::now();
$this->online = true;
$this->ip = $ip;
$this->port = $port;
$this->pubkey = $pubkey;
$this->team = $team;
$this->save();
}
......@@ -125,7 +130,7 @@ public function stop()
// We fetch all the game that were running, detach their users (thus deleting the pivot data)
// and delete the game. This makes it as the game never happened.
$this->games->whereIn("state", ["created", "playing"])->each(function($game) {
$this->games->whereIn("state", ["created", "playing"])->each(function ($game) {
$game->users()->detach();
$game->delete();
});
......
......@@ -11,6 +11,7 @@
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject;
use Illuminate\Support\Facades\DB;
/**
* @OA\Schema(
......@@ -19,6 +20,8 @@
* @OA\Property(type="integer", property="id"),
* @OA\Property(type="string", property="username"),
* @OA\Property(type="integer", property="server_id", nullable=true),
* @OA\Property(type="number", property="elo"),
* @OA\Property(type="integer", property="rank"),
* @OA\Property(type="string", property="created_at", format="date-time"),
* @OA\Property(type="string", property="updated_at", format="date-time"),
* )
......@@ -50,6 +53,10 @@ class User extends Authenticatable implements JWTSubject, MustVerifyEmail, CanRe
'email'
];
protected $appends = [
'rank'
];
/**
* Get the identifier that will be stored in the subject claim of the JWT.
*
......@@ -103,4 +110,8 @@ public function connecting(Server $server, string $token)
$this->server_id = $server->id;
$this->save();
}
public function getRankAttribute() {
return User::select('id')->orderByDesc('elo')->orderBy('id')->pluck('id')->search($this->id) + 1;
}
}
......@@ -14,10 +14,27 @@
* type="object",
* @OA\Property(type="integer", property="user_id"),
* @OA\Property(type="integer", property="game_id"),
* @OA\Property(type="integer", property="team_id"),
* @OA\Property(type="integer", property="rank"),
* )
* )
*/
class UserGame extends Pivot
{
use HasFactory;
}
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'team_id',
'rank',
'elo',
];
protected $casts = [
'elo' => 'float'
];
}
\ No newline at end of file
......@@ -4,7 +4,7 @@
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"license": "MIT",
"version": "1.2.0",
"version": "1.3.0",
"require": {
"php": "^8.0.2",
"darkaonline/l5-swagger": "^8.4",
......
......@@ -89,7 +89,7 @@
|
*/
'ttl' => env('JWT_TTL', 60),
'ttl' => env('JWT_TTL', null),
/*
|--------------------------------------------------------------------------
......@@ -108,7 +108,7 @@
|
*/
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160),
'refresh_ttl' => env('JWT_REFRESH_TTL', null),
/*
|--------------------------------------------------------------------------
......@@ -138,7 +138,6 @@
'required_claims' => [
'iss',
'iat',
'exp',
'nbf',
'sub',
'jti',
......
......@@ -18,7 +18,6 @@ public function up()
$table->foreignId('owner_id')->references('id')->on('users');
$table->string('login')->unique();
$table->string('password');
$table->boolean('public');
$table->timestamps();
});
}
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('servers', function (Blueprint $table) {
$table->boolean("team")->default(false);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn("team");
});
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('servers', function (Blueprint $table) {
$table->boolean("official")->default(false);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn("official");
});
}
};
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment