diff --git a/README.md b/README.md index 255ae1f..d96ff83 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ pip install -r requirements.txt python manage.py makemigrations games python manage.py makemigrations profiles python manage.py makemigrations chat +python manage.py makemigrations tournament python manage.py migrate ``` - Start the developpement server diff --git a/chat/consumers.py b/chat/consumers.py index c230c6e..69a9d37 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -7,15 +7,17 @@ import time import json class ChatConsumer(WebsocketConsumer): + def connect(self): user = self.scope["user"] if (user.is_anonymous or not user.is_authenticated): return - channel_id : int = int(self.scope['url_route']['kwargs']['chat_id']) + self.room_group_name = f'chat{channel_id}' + if ChatMemberModel.objects.filter(member_id=user.pk, channel_id=int(channel_id)).count() != 1: return @@ -53,7 +55,7 @@ class ChatConsumer(WebsocketConsumer): channel_id : int = int(self.scope['url_route']['kwargs']['chat_id']) - if ChatMemberModel.objects.filter(member_id=user.pk, channel_id=channel_id).count() != 1: + if ChatMemberModel.objects.filter(member_id = user.pk, channel_id = channel_id).count() != 1: return if (self.channel_layer == None): @@ -84,7 +86,6 @@ class ChatConsumer(WebsocketConsumer): time = message_time ).save() - def chat_message(self, event): user = self.scope["user"] diff --git a/chat/models.py b/chat/models.py index 0ba4a7b..9710bc1 100644 --- a/chat/models.py +++ b/chat/models.py @@ -3,12 +3,10 @@ from django.db.models import IntegerField from django.contrib.auth.models import User from django.contrib import admin -from typing import List - # Create your models here. class ChatChannelModel(models.Model): - - def create(self, users_id: List[int]): + + def create(self, users_id: [int]): self.save() for user_id in users_id: ChatMemberModel(channel_id = self.pk, member_id = user_id).save() diff --git a/chat/serializers.py b/chat/serializers.py index f635b3e..14d3aec 100644 --- a/chat/serializers.py +++ b/chat/serializers.py @@ -3,7 +3,6 @@ from rest_framework import serializers from profiles.models import ProfileModel from .models import ChatChannelModel, ChatMessageModel -from typing import List class ChatChannelSerializer(serializers.ModelSerializer): @@ -14,7 +13,7 @@ class ChatChannelSerializer(serializers.ModelSerializer): fields = ["members_id", "pk"] def validate_members_id(self, value): - members_id: List[int] = value + members_id: [int] = value if len(members_id) < 2: raise serializers.ValidationError('Not enought members to create the channel') if len(set(members_id)) != len(members_id): diff --git a/chat/urls.py b/chat/urls.py index 8c25ecc..4dce2f3 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -5,5 +5,5 @@ from django.conf.urls.static import static from . import views urlpatterns = [ - path("", views.ChannelView.as_view(), name="chats_page"), + path("", views.ChannelView.as_view(), name="chats_page"), ] diff --git a/frontend/static/js/api/account.js b/frontend/static/js/api/account.js index db22a63..3e44d0e 100644 --- a/frontend/static/js/api/account.js +++ b/frontend/static/js/api/account.js @@ -33,7 +33,7 @@ class Account if (JSON.stringify(response_data) == JSON.stringify({'detail': 'Authentication credentials were not provided.'})) { - this.client._logged = false; + this.client._update_logged(false); return null; } if (response_data == "user deleted") diff --git a/frontend/static/js/api/chat/channel.js b/frontend/static/js/api/chat/channel.js index 672a71e..4ced5e6 100644 --- a/frontend/static/js/api/chat/channel.js +++ b/frontend/static/js/api/chat/channel.js @@ -35,7 +35,9 @@ class Channel { this.chatSocket.close(); } - updateMessages(messages) { + updateMessages(messages) + { + console.log(messages); messages = JSON.parse(messages); let new_messages = []; diff --git a/frontend/static/js/api/chat/channels.js b/frontend/static/js/api/chat/channels.js index 16ce742..4d6bcc4 100644 --- a/frontend/static/js/api/chat/channels.js +++ b/frontend/static/js/api/chat/channels.js @@ -9,8 +9,8 @@ class Channels { async createChannel(members_id, reload) { let null_id = false; - members_id.forEach(user_id => { - if (user_id == null) + members_id.forEach(member_id => { + if (member_id == null) null_id = true; }); if (null_id) @@ -28,6 +28,7 @@ class Channels { let messages = undefined; if (exit_code == 200) messages = data.messages; + return new Channel(this.client, data.channel_id, members_id, messages, reload); } diff --git a/frontend/static/js/api/client.js b/frontend/static/js/api/client.js index 630225e..8e20e56 100644 --- a/frontend/static/js/api/client.js +++ b/frontend/static/js/api/client.js @@ -3,6 +3,8 @@ import { MatchMaking } from "./matchmaking.js"; import { Profiles } from "./profiles.js"; import { Channels } from './chat/channels.js'; import { MyProfile } from "./MyProfile.js"; +import { navigateTo } from "../index.js" +import { Tourmanents } from "./tournament/tournaments.js"; function getCookie(name) { @@ -22,6 +24,7 @@ class Client this.account = new Account(this); this.profiles = new Profiles(this); this.matchmaking = new MatchMaking(this); + this.tournaments = new Tourmanents(this); this._logged = undefined; this.channels = new Channels(this); @@ -102,6 +105,10 @@ class Client this.me = new MyProfile(this); await this.me.init(); } + if (this.logged && !state) + { + navigateTo("/login"); + } this.logged = state; } diff --git a/frontend/static/js/api/matchmaking.js b/frontend/static/js/api/matchmaking.js index 6943077..e55e48c 100644 --- a/frontend/static/js/api/matchmaking.js +++ b/frontend/static/js/api/matchmaking.js @@ -8,30 +8,45 @@ class MatchMaking constructor(client) { /** - * @type {client} + * @type {Client} */ this.client = client + this.searching = false; } - async start(func) + async start(receive_func, disconnect_func, mode) { if (!await this.client.isAuthentificate()) return null; - let url = `wss://${window.location.host}/ws/matchmaking/`; + let url = `${window.location.protocol[4] === 's' ? 'wss' : 'ws'}://${window.location.host}/ws/matchmaking/${mode}`; + + this._socket = new WebSocket(url); - this._chatSocket = new WebSocket(url); - - this._chatSocket.onmessage = function (event) { + this.searching = true; + + this.receive_func = receive_func; + this.disconnect_func = disconnect_func; + + this._socket.onmessage = function (event) { const data = JSON.parse(event.data); - func(data.game_id) + receive_func(data); }; + + this._socket.onclose = this.onclose.bind(this); + } + + onclose(event) + { + this.stop(); + this.disconnect_func(event); } async stop() { - this._chatSocket.close() + this.searching = false; + this._socket.close() } } -export {MatchMaking} \ No newline at end of file +export {MatchMaking} diff --git a/frontend/static/js/api/profile.js b/frontend/static/js/api/profile.js index 1e94980..b52314d 100644 --- a/frontend/static/js/api/profile.js +++ b/frontend/static/js/api/profile.js @@ -20,6 +20,10 @@ class Profile async init(user_id) { let response = await this.client._get(`/api/profiles/${user_id}`); + + if (response.status === 404) + return 1; + let response_data = await response.json(); this.user_id = response_data.user_id; diff --git a/frontend/static/js/api/profiles.js b/frontend/static/js/api/profiles.js index 6845935..b8bfc10 100644 --- a/frontend/static/js/api/profiles.js +++ b/frontend/static/js/api/profiles.js @@ -28,7 +28,8 @@ class Profiles async getProfile(user_id) { let profile = new Profile(this.client); - await profile.init(user_id); + if (await profile.init(user_id)) + return null; return profile; } diff --git a/frontend/static/js/api/tournament/tournament.js b/frontend/static/js/api/tournament/tournament.js new file mode 100644 index 0000000..7e62508 --- /dev/null +++ b/frontend/static/js/api/tournament/tournament.js @@ -0,0 +1,98 @@ +import { Client } from "../client.js"; + +class Tourmanent +{ + /** + * @param {Client} client + */ + constructor(client, name = undefined, nb_players = undefined, nb_players_by_game = undefined, level = undefined, started = undefined, finished = undefined, levels = undefined, id = undefined) + { + /** + * @type {Client} + */ + this.client = client; + this.name = name || `${nb_players_by_game}x1, ${nb_players} players`; + this.nb_players = nb_players; + this.nb_players_by_game = nb_players_by_game; + this.level = level; + this.started = started; + this.finished = finished; + this.levels = levels; + this.state = this.get_state(); + this.id = id + + this.connected = false; + } + + get_state() + { + if (this.finished) + return "finished"; + if (this.started) + return "started"; + else + return "waiting"; + } + + async init(id) + { + let response = await this.client._get(`/api/tournaments/${id}`); + + if (response.status === 404) + return 1; + + let response_data = await response.json(); + + this.name = response_data.name || `${response_data.nb_players_by_game}x1, ${response_data.nb_players} players`; + this.nb_players = response_data.nb_players; + this.nb_players_by_game = response_data.nb_players_by_game; + this.level = response_data.level; + this.started = response_data.started; + this.finished = response_data.finished; + this.levels = response_data.levels; + this.id = response_data.id + this.state = this.get_state(); + } + + leave(event) + { + if (this.connected == false) + return + this.connected = false; + this._socket.close() + this.disconnect_func(event); + } + + toggle_participation() + { + if (!this.connected) + return + this._socket.send(JSON.stringify({participate: ""})); + } + + async join(receive_func, disconnect_func) + { + if (!await this.client.isAuthentificate()) + return null; + + let url = `${window.location.protocol[4] === 's' ? 'wss' : 'ws'}://${window.location.host}/ws/tournaments/${this.id}`; + + this._socket = new WebSocket(url); + + this.connected = true; + this.isParticipating = false; + + this.receive_func = receive_func; + this.disconnect_func = disconnect_func; + + this._socket.onmessage = function (event) { + const data = JSON.parse(event.data); + receive_func(data); + }; + + this._socket.onclose = this.leave.bind(this); + } + +} + +export { Tourmanent } diff --git a/frontend/static/js/api/tournament/tournaments.js b/frontend/static/js/api/tournament/tournaments.js new file mode 100644 index 0000000..8fb0489 --- /dev/null +++ b/frontend/static/js/api/tournament/tournaments.js @@ -0,0 +1,78 @@ +import { Client } from "../client.js"; +import { Tourmanent } from "./tournament.js"; + +class Tourmanents +{ + /** + * @param {Client} client + */ + constructor(client) + { + /** + * @type {Client} + */ + this.client = client + } + + async getTournament(id) + { + let tournament = new Tourmanent(this.client); + if (await tournament.init(id)) + return null; + return tournament; + } + + async createTournament(nb_players, nb_players_by_game, name = "") + { + let response = await this.client._post("/api/tournaments/", {nb_players: nb_players, nb_players_by_game: nb_players_by_game, name: name}); + + if (response.status === 403) + { + this.client._update_logged(false); + return null; + } + + let response_data = await response.json(); + return response_data; + } + + /** + * @param {string} state must be "finished", or "started", or "waiting". Any other return all elements + */ + + async search(state) + { + let response = await this.client._get(`/api/tournaments/search/${state}`); + let response_data = await response.json() + + if (response.status === 404) + { + this.client._update_logged(false); + return null; + } + + let tournaments = []; + + response_data.forEach(tournament_data => { + tournaments.push(new Tourmanent(this.client, + tournament_data.name, + tournament_data.nb_players, + tournament_data.nb_players_by_game, + tournament_data.level, + tournament_data.started, + tournament_data.finished, + tournament_data.levels, + tournament_data.id)); + }); + + return tournaments; + } + + async all() + { + return await this.search(""); + } + +} + +export { Tourmanents } \ No newline at end of file diff --git a/frontend/static/js/index.js b/frontend/static/js/index.js index 346eacb..db5c608 100644 --- a/frontend/static/js/index.js +++ b/frontend/static/js/index.js @@ -14,6 +14,9 @@ import AbstractRedirectView from "./views/abstracts/AbstractRedirectView.js"; import MeView from "./views/MeView.js"; import ProfilePageView from "./views/ProfilePageView.js"; import MatchMakingView from "./views/MatchMakingView.js"; +import TournamentPageView from "./views/TournamentPageView.js"; +import TournamentsView from "./views/TournamentsListView.js"; +import TournamentCreateView from "./views/TournamentCreateView.js"; let client = new Client(location.protocol + "//" + location.host) @@ -35,10 +38,26 @@ const navigateTo = async (uri) => { history.pushState(null, null, uri); }; +async function renderView(view) +{ + let content = await view.getHtml(); + if (content == null) + return 1; + + view.setTitle(); + document.querySelector("#app").innerHTML = content + + if (await view.postInit()) + renderView(new PageNotFoundView()); +} + const router = async (uri) => { const routes = [ { path: "/", view: Dashboard }, { path: "/profiles/:id", view: ProfilePageView }, + { path: "/tournaments/create", view: TournamentCreateView }, + { path: "/tournaments/:id", view: TournamentPageView }, + { path: "/tournaments/", view: TournamentsView }, { path: "/login", view: LoginView }, { path: "/logout", view: LogoutView }, { path: "/register", view: RegisterView }, @@ -80,14 +99,7 @@ const router = async (uri) => { lastView = view; await client.isAuthentificate(); - let content = await view.getHtml(); - if (content == null) - return 1; - - view.setTitle(); - document.querySelector("#app").innerHTML = content - - await view.postInit(); + renderView(view); return 0; }; diff --git a/frontend/static/js/views/MatchMakingView.js b/frontend/static/js/views/MatchMakingView.js index ca02363..eb084d3 100644 --- a/frontend/static/js/views/MatchMakingView.js +++ b/frontend/static/js/views/MatchMakingView.js @@ -1,24 +1,64 @@ import { client, navigateTo } from "../index.js"; -import AbstractView from "./abstracts/AbstractView.js"; +import { clear, fill_errors } from "../utils/formUtils.js"; +import AbstractAuthentifiedView from "./abstracts/AbstractAuthentifiedView.js"; -function game_found(game_id) -{ - navigateTo(`/games/${game_id}`) -} +export default class extends AbstractAuthentifiedView { + constructor(params) + { + super(params, "Matchmaking"); + } -export default class extends AbstractView { - constructor(params) { - super(params, "Dashboard"); + async press_button() + { + if (client.matchmaking.searching) + { + client.matchmaking.stop(); + document.getElementById("button").value = "Find a game" + } + else + { + let nb_players = document.getElementById("nb_players-input").value + + await client.matchmaking.start(this.onreceive.bind(this), this.ondisconnect.bind(this), nb_players); + + document.getElementById("button").value = "Stop matchmaking" + } + } + + ondisconnect(event) + { + if (event.code === 1000) + clear("innerText", ["detail"]) + document.getElementById("button").value = "Find a game" + } + + onreceive(data) + { + if (data.detail === "game_found") + { + navigateTo(`/games/${data.game_id}`); + return; + } + this.display_data(data) + } + + display_data(data) + { + clear("innerText", ["detail"]); + fill_errors(data, "innerText"); } async postInit() { - await client.matchmaking.start(game_found) + document.getElementById("button").onclick = this.press_button.bind(this) } async getHtml() { return ` -

finding

+

Select mode

+ + + `; } diff --git a/frontend/static/js/views/ProfilePageView.js b/frontend/static/js/views/ProfilePageView.js index 82bd1f2..4ff4103 100644 --- a/frontend/static/js/views/ProfilePageView.js +++ b/frontend/static/js/views/ProfilePageView.js @@ -9,6 +9,11 @@ export default class extends AbstractView { async postInit() { + let profile = await client.profiles.getProfile(this.user_id); + + if (profile === null) + return 1; + this.profile = await client.profiles.getProfile(this.user_id); this.info = document.getElementById("info"); diff --git a/frontend/static/js/views/TournamentCreateView.js b/frontend/static/js/views/TournamentCreateView.js new file mode 100644 index 0000000..ef93428 --- /dev/null +++ b/frontend/static/js/views/TournamentCreateView.js @@ -0,0 +1,52 @@ +import {client, navigateTo} from "../index.js"; +import { clear, fill_errors } from "../utils/formUtils.js"; +import AbstractAuthentifiedView from "./abstracts/AbstractAuthentifiedView.js"; + +export default class extends AbstractAuthentifiedView +{ + constructor(params) + { + super(params, "Create tournament"); + this.id = params.id; + } + + async create() + { + let name = document.getElementById("name-input").value; + let nb_players = document.getElementById("nb_players-input").value; + let nb_players_by_game = document.getElementById("nb_players_by_game-input").value + + let response_data = await client.tournaments.createTournament(nb_players, nb_players_by_game, name); + + if (response_data === null) + return; + + let id = response_data["id"] + if (id !== undefined) + { + navigateTo(`/tournaments/${id}`); + return; + } + + clear("innerHTML", ["name", "nb_players", "nb_players_by_game"]); + fill_errors(response_data, "innerHTML"); + } + + async postInit() + { + document.getElementById("create-button").onclick = this.create; + } + + async getHtml() + { + return ` + + + + + + + + ` + } +} diff --git a/frontend/static/js/views/TournamentPageView.js b/frontend/static/js/views/TournamentPageView.js new file mode 100644 index 0000000..0e7d5c3 --- /dev/null +++ b/frontend/static/js/views/TournamentPageView.js @@ -0,0 +1,98 @@ +import {client, navigateTo} from "../index.js"; +import AbstractAuthentifiedView from "./abstracts/AbstractAuthentifiedView.js"; + +export default class extends AbstractAuthentifiedView +{ + constructor(params) + { + super(params, "Tournament"); + this.id = params.id; + } + + pressButton() + { + this.tournament.toggle_participation(); + } + + async receive(data) + { + if (data.detail === "nb_participants" || data.detail === "update_participants") + document.getElementById("nb_participants").innerText = `${data.nb_participants} / ${this.tournament.nb_players}` + if (data.detail === "go_to") + navigateTo(data.url); + if (data.detail === "is_participant") + this.updateParticipating(data.is_participant) + if (data.detail === "error") + document.getElementById("display").innerText = data.error_message + } + + async updateParticipating(state) + { + document.getElementById("button").value = state ? `Leave ${this.tournament.name}` : `Join ${this.tournament.name}`; + document.getElementById("display").innerText = state ? "You are a particpant" : "You are not a participant"; + } + + async ondisconnect(event) + { + } + + async postInit() + { + this.tournament = await client.tournaments.getTournament(this.id); + + if (this.tournament === null) + return 1; + + this.tournament.join(this.receive.bind(this), this.ondisconnect.bind(this)); + + let button = document.getElementById("button") + + button.onclick = this.pressButton.bind(this); + + document.getElementById("name").innerText = this.tournament.name; + document.getElementById("nb_players").innerText = this.tournament.nb_players; + document.getElementById("nb_players_by_game").innerText = this.tournament.nb_players_by_game; + document.getElementById("level").innerText = this.tournament.level; + document.getElementById("state").innerText = this.tournament.state; + + if (this.tournament.state === "waiting") + button.disabled = false; + } + + async getHtml() + { + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Loading...
Number of playersLoading...
Number of players by gameLoading...
Number of roundLoading...
Number of playerLoading...
statusLoading...
+ + + ` + } +} diff --git a/frontend/static/js/views/TournamentsListView.js b/frontend/static/js/views/TournamentsListView.js new file mode 100644 index 0000000..12794ca --- /dev/null +++ b/frontend/static/js/views/TournamentsListView.js @@ -0,0 +1,133 @@ +import {client} from "../index.js"; +import AbstractAuthentifiedView from "./abstracts/AbstractAuthentifiedView.js"; + +export default class extends AbstractAuthentifiedView +{ + constructor(params) + { + super(params, "Tournament"); + this.id = params.id; + } + + async external_search() + { + let state = document.getElementById("state-select").value; + this.tournaments = await client.tournaments.search(state); + } + + add_nb_player_by_game_selector() + { + let nb_players_by_game_list = new Set() + this.tournaments.forEach(tournament => { + nb_players_by_game_list.add(tournament.nb_players_by_game); + }); + + let select = document.getElementById("nb-players-by-game-select"); + + let new_children = [] + + const opt = document.createElement("option"); + opt.value = "all"; + opt.text = "All"; + + new_children.push(opt); + + nb_players_by_game_list.forEach(nb_players_by_game => { + const opt = document.createElement("option"); + opt.value = nb_players_by_game; + opt.text = nb_players_by_game; + new_children.push(opt); + }) + select.replaceChildren(...new_children); + } + + internal_search() + { + let nb_players_by_game = document.getElementById("nb-players-by-game-select").value; + + this.display_tournaments = []; + this.tournaments.forEach(tournament => { + if (nb_players_by_game === "all" || nb_players_by_game == tournament.nb_players_by_game) + this.display_tournaments.push(tournament); + }); + } + + display_result() + { + const tournaments_list = document.getElementById("tournaments-list"); + + const new_children = [] + + this.display_tournaments.forEach(tournament => { + + let tr = document.createElement("tr"); + + // name + let td = document.createElement("td"); + td.innerText = tournament.name; + tr.appendChild(td); + + // state + td = document.createElement("td"); + td.innerText = tournament.state; + tr.appendChild(td); + + // nb_players + td = document.createElement("td"); + td.innerText = tournament.nb_players; + tr.appendChild(td); + + // nb_players_by_game + td = document.createElement("td"); + td.innerText = tournament.nb_players_by_game; + tr.appendChild(td); + + new_children.push(tr); + }); + tournaments_list.replaceChildren(...new_children); + } + + async update_query() + { + this.internal_search(); + this.display_result(); + } + + async update_search() + { + await this.external_search(); + this.add_nb_player_by_game_selector(); + this.update_query(); + } + + async postInit() + { + await this.update_search() + document.getElementById("state-select").onchange = this.update_search.bind(this); + document.getElementById("nb-players-by-game-select").onchange = this.update_query.bind(this); + } + + async getHtml() + { + return ` + + + + + + + + + + + +
NameStatusMax numbers of playersMax numbers of players by game
+ ` + } +} diff --git a/games/models.py b/games/models.py index a2d8315..36d5394 100644 --- a/games/models.py +++ b/games/models.py @@ -2,13 +2,20 @@ from django.db import models # Create your models here. class GameModel(models.Model): + + finished = models.BooleanField(default = False) + started = models.BooleanField(default = False) + winner_id = models.IntegerField(default = -1) - def create(self, users_id: [int]): + def create(self, players_id: [int]): self.save() - for user_id in users_id: - GameMembersModel(game_id=self.pk, member_id=user_id) + for player_id in players_id: + GameMembersModel(game_id = self.pk, player_id = player_id).save() return self.pk + + def get_players_id(self): + return [game_member.member_id for game_member in GameMembersModel.objects.filter(game_id = self.pk)] class GameMembersModel(models.Model): game_id = models.IntegerField() - member_id = models.IntegerField() \ No newline at end of file + player_id = models.IntegerField() \ No newline at end of file diff --git a/games/serializers.py b/games/serializers.py new file mode 100644 index 0000000..5bd2fc6 --- /dev/null +++ b/games/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers +from .models import GameModel, GameMembersModel + +class GameSerializer(serializers.ModelSerializer): + + players_id = serializers.SerializerMethodField() + winner_id = serializers.ReadOnlyField() + state = serializers.SerializerMethodField() + started = serializers.ReadOnlyField() + finished = serializers.ReadOnlyField() + + class Meta: + model = GameModel + fields = ["id", "winner_id", "state", "started", "finished", "players_id"] + + def get_state(self, instance: GameModel): + if (instance.finished): + return "finished" + if (instance.started): + return "started" + return "waiting" + + def get_players_id(self, instance: GameModel): + players_id = [player_game.member_id for player_game in GameMembersModel.objects.filter(game_id=instance.pk)] \ No newline at end of file diff --git a/manage.py b/manage.py index dd64115..9294fec 100755 --- a/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trancendence.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'transcendence.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/matchmaking/consumers.py b/matchmaking/consumers.py index dfa65e8..819ca40 100644 --- a/matchmaking/consumers.py +++ b/matchmaking/consumers.py @@ -6,8 +6,7 @@ from games.models import GameModel import json -queue_id: [int] = [] -queue_ws: [WebsocketConsumer] = [] +from .models import Waiter, WaitingRoom, WaitingRoomManager, normal class MatchMaking(WebsocketConsumer): @@ -24,25 +23,19 @@ class MatchMaking(WebsocketConsumer): self.channel_layer.group_add(self.group_name, self.channel_name) - self.accept() + self.mode = int(self.scope['url_route']['kwargs']['mode']) + self.group_name = self.mode - global queue_id, queue_ws - queue_id.append(user.pk) - queue_ws.append(self) + waiting_room: WaitingRoom = normal.get(self.mode) + waiting_room.append(Waiter(user.pk, self)) + waiting_room.broadcast(f"{len(waiting_room)} / {waiting_room.mode}") + if (len(waiting_room) == waiting_room.mode): + game_id: int = GameModel().create(waiting_room.get_users_id()) + waiting_room.broadcast("game_found", {"game_id": game_id}) + waiting_room.clear() - if len(set(queue_id)) == 2: - game_id: int = GameModel().create(set(queue_id)) - event = {"game_id": game_id} - for ws in queue_ws: - ws.send(text_data=json.dumps({'game_id': game_id})) - queue_id.clear() - queue_ws.clear() - - def disconnect(self, close_code): - user: User = self.scope["user"] - global queue_id, queue_ws - if (user.pk in queue_id): - queue_ws.pop(queue_id.index(user.pk)) - queue_id.remove(user.pk) - self.channel_layer.group_discard(self.group_name, self.channel_name) \ No newline at end of file + waiting_room: WaitingRoom = normal.get(self.mode) + waiter: Waiter = waiting_room.get_member_by_socket(self) + if (waiter is not None): + waiting_room.remove(waiter, 1016) \ No newline at end of file diff --git a/matchmaking/models.py b/matchmaking/models.py index 71a8362..8ce235d 100644 --- a/matchmaking/models.py +++ b/matchmaking/models.py @@ -1,3 +1,39 @@ from django.db import models +from channels.generic.websocket import WebsocketConsumer +import json + +from transcendence.abstract.AbstractRoom import AbstractRoom +from transcendence.abstract.AbstractRoomManager import AbstractRoomManager +from transcendence.abstract.AbstractRoomMember import AbstractRoomMember + # Create your models here. +class Waiter(AbstractRoomMember): + pass + +class WaitingRoom(AbstractRoom): + + def __init__(self, room_manager,mode): + super().__init__(room_manager) + self.mode = mode + + def append(self, waiter: Waiter): + tmp: Waiter = self.get_member_by_user_id(waiter.user_id) + if (tmp is not None): + tmp.send("Connection close: Another connection open with the same user id.") + self.remove(tmp) + waiter.accept() + self._member_list.append(waiter) + +class WaitingRoomManager(AbstractRoomManager): + + def get(self, mode: int): + for waiting_room in self._room_list: + waiting_room: WaitingRoom + if (waiting_room.mode == mode): + return waiting_room + tmp: WaitingRoom = WaitingRoom(self, mode) + super().append(tmp) + return tmp + +normal: WaitingRoomManager = WaitingRoomManager() \ No newline at end of file diff --git a/matchmaking/routing.py b/matchmaking/routing.py index eed5072..417779b 100644 --- a/matchmaking/routing.py +++ b/matchmaking/routing.py @@ -2,5 +2,5 @@ from django.urls import re_path from . import consumers websocket_urlpatterns = [ - re_path(r'ws/matchmaking/', consumers.MatchMaking.as_asgi()) + re_path(r'ws/matchmaking/(?P\d+)$', consumers.MatchMaking.as_asgi()) ] diff --git a/profiles/tests.py b/profiles/tests.py index cf8d9fa..087957c 100644 --- a/profiles/tests.py +++ b/profiles/tests.py @@ -7,8 +7,8 @@ class ProfileTest(TestCase): def setUp(self): self.user: User = User.objects.create(username='bozo', password='password') self.user.save() - self.expected_response = {"name": "bozo", - "title": ""} + self.expected_response = {'avatar_url': '/static/avatars/default.avif', 'user_id': 1, 'username': 'bozo'} + self.url = "/api/profiles/" def test_profile_create_on_user_created(self): diff --git a/profiles/viewsets.py b/profiles/viewsets.py index 445e2c7..6e73ad8 100644 --- a/profiles/viewsets.py +++ b/profiles/viewsets.py @@ -31,9 +31,6 @@ class ProfileViewSet(viewsets.ModelViewSet): profile["avatar_url"] = profile["avatar_url"][profile["avatar_url"].find("static") - 1:] return Response(serializer.data) - def perform_create(self, serializer): - serializer.save(user=self.request.user) - class MyProfileViewSet(viewsets.ModelViewSet): permission_classes = (permissions.IsAuthenticated,) diff --git a/trancendence/__init__.py b/tournament/__init__.py similarity index 100% rename from trancendence/__init__.py rename to tournament/__init__.py diff --git a/tournament/admin.py b/tournament/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/tournament/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/tournament/apps.py b/tournament/apps.py new file mode 100644 index 0000000..15ea9fb --- /dev/null +++ b/tournament/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TournamentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tournament' diff --git a/tournament/consumers.py b/tournament/consumers.py new file mode 100644 index 0000000..ea2bb46 --- /dev/null +++ b/tournament/consumers.py @@ -0,0 +1,43 @@ +from channels.generic.websocket import WebsocketConsumer + +from django.contrib.auth.models import User + +from games.models import GameModel + +import json + +from .models import tournament_manager, TournamentMember, TournamentRoom, TournamentRoomManager + +class TournamentWebConsumer(WebsocketConsumer): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.channel_name = "tournament" + self.group_name = "tournament" + + def connect(self): + + self.user: User = self.scope["user"] + if (self.user.is_anonymous or not self.user.is_authenticated): + return + + self.channel_layer.group_add(self.group_name, self.channel_name) + + self.tournament_id = int(self.scope['url_route']['kwargs']['tournament_id']) + + self.room = tournament_manager.get(self.tournament_id) + self.member = TournamentMember(self.user.pk, self, self.room) + + if (self.room is None): + self.member.send("Tournament not found") + self.disconnect(1017) + + self.room.append(self.member) + + def receive(self, text_data: str = None, bytes_data: bytes = None): + self.member.receive(text_data, bytes_data) + + def disconnect(self, close_code): + member = self.room.get_member_by_socket(self) + if (member is not None): + self.room.remove(self.member, close_code) \ No newline at end of file diff --git a/tournament/models.py b/tournament/models.py new file mode 100644 index 0000000..912ec62 --- /dev/null +++ b/tournament/models.py @@ -0,0 +1,154 @@ +from django.db import models + +from channels.generic.websocket import WebsocketConsumer + +from games.models import GameModel + +import json + +from transcendence.abstract.AbstractRoomMember import AbstractRoomMember +from transcendence.abstract.AbstractRoom import AbstractRoom +from transcendence.abstract.AbstractRoomManager import AbstractRoomManager + +# Create your models here.tu +class TournamentModel(models.Model): + + name = models.CharField(max_length = 100) + nb_players = models.IntegerField() + nb_players_by_game = models.IntegerField() + level = models.IntegerField() + started = models.BooleanField(default = False) + finished = models.BooleanField(default = False) + + def create_game(self, level, players_id): + game_id = GameModel().create(players_id = players_id) + TournamentGamesModel(game_id = game_id, tournament_id = self.pk, tournament_level = level).save() + return game_id + + def get_games_id_by_level(self, level): + tmp = TournamentGamesModel.objects.filter(tournament_id = self.pk, tournament_level = level) + return [instance.game_id for instance in tmp] + + def get_games_id(self): + return [tournament_game.game_id for tournament_game in TournamentGamesModel.objects.filter(tournament_id = self.pk)] + + def get_players_id(self): + return [model.participant_id for model in TournamentParticipantsModel.objects.filter(tournament_id=self.pk)] + + def is_a_participant(self, participant_id: int): + return TournamentParticipantsModel.objects.filter(participant_id = participant_id, tournament_id = self.pk).exists() + + def add_participants(self, participants_id: [int]): + for participant_id in participants_id: + TournamentParticipantsModel(tournament_id = self.pk, participant_id = participant_id).save() + + def start(self, participants_id: [int]): + self.started = True + self.add_participants(participants_id) + games_id = [int] + for i in range(0, len(participants_id), self.nb_players_by_game): + game_id = self.create_game(0, participants_id[i : i + self.nb_players_by_game]) + games_id.append(game_id) + self.save() + return games_id + +class TournamentParticipantsModel(models.Model): + tournament_id = models.IntegerField() + participant_id = models.IntegerField() + +class TournamentGamesModel(models.Model): + + tournament_id = models.IntegerField() + tournament_level = models.IntegerField() + game_id = models.IntegerField() + +class TournamentMember(AbstractRoomMember): + + def __init__(self, user_id: int, socket: WebsocketConsumer, room): + super().__init__(user_id, socket) + self.participate = False + self.room = room + + def receive(self, text_data: str = None, byte_dates: bytes = None): + + if (text_data is None): + return + + data: dict = json.loads(text_data) + + if (data.get("participate") is not None): + self.room.update_participants(self) + + def send_error_message(self, message: str): + self.send("error", {"error_message": message}) + + def go_to(self, url: str): + self.send("go_to", {"url": url}) + + def send_participating(self): + self.send("is_participant", {"is_participant": self.participate}) + +class TournamentRoom(AbstractRoom): + + def __init__(self, room_manager, tournament_id: int): + super().__init__(room_manager) + self.tournament_id = tournament_id + self.tournament = TournamentModel.objects.get(pk = tournament_id) + + def start(self): + self.broadcast("tournament_start") + games_id = self.tournament.start(self.get_participants_id()) + for i, participant in enumerate(self.get_participants()): + participant: TournamentMember + participant.go_to(f"games/{games_id[i // self.tournament.nb_players_by_game]}") + + def update_participants(self, member: TournamentMember): + if (self.tournament.started): + member.send_error_message("Tournament already started") + return + member.participate = not member.participate + nb_participants = self.get_nb_participants() + self.broadcast("update_participants", {"nb_participants": nb_participants}) + member.send_participating() + if (nb_participants == self.tournament.nb_players): + self.start() + + def get_nb_participants(self): + if (self.tournament.started): + return self.tournament.nb_players + nb_participants = 0 + for member in self._member_list: + member: TournamentMember + if (member.participate): + nb_participants += 1 + return nb_participants + + def get_participants(self): + return [member for member in self._member_list if member.participate] + + def get_participants_id(self): + return [member.user_id for member in self._member_list if member.participate] + + def append(self, member: TournamentMember): + super().append(member) + if self.tournament.started: + member.participate = self.tournament.is_a_participant(member.user_id) + member.send_participating() + member.send("nb_participants", {"nb_participants": self.get_nb_participants()}) + +class TournamentRoomManager(AbstractRoomManager): + + def get(self, tournament_id: int): + + for room in self._room_list: + if (room.tournament_id == tournament_id): + return room + + if (TournamentModel.objects.filter(pk = tournament_id).exists()): + room = TournamentRoom(self, tournament_id) + self.append(room) + return room + + return None + +tournament_manager: TournamentRoomManager = TournamentRoomManager() \ No newline at end of file diff --git a/tournament/routing.py b/tournament/routing.py new file mode 100644 index 0000000..7271972 --- /dev/null +++ b/tournament/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r'ws/tournaments/(?P\d+)$', consumers.TournamentWebConsumer.as_asgi()) +] diff --git a/tournament/serializers.py b/tournament/serializers.py new file mode 100644 index 0000000..c1aba61 --- /dev/null +++ b/tournament/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers +from .models import TournamentModel +from games.serializers import GameSerializer + +class TournamentSerializer(serializers.ModelSerializer): + + levels = serializers.SerializerMethodField(read_only=True, required=False) + level = serializers.ReadOnlyField() + started = serializers.ReadOnlyField() + finished = serializers.ReadOnlyField() + name = serializers.CharField(default="") + + class Meta: + model = TournamentModel + fields = ["name", "nb_players", "nb_players_by_game", "level", "started", "finished", "levels", "id"] + + def get_levels(self, instance: TournamentModel): + levels: [[int]] = [] + for i in range(instance.level): + games_id: [int] = instance.get_games_id_by_level(i) + if (games_id == []): + break + levels.append(games_id) + return levels + + def validate_nb_players(self, value: int): + if (value < 2): + raise serializers.ValidationError("The numbers of players must be greather than 2.") + return value + def validate_nb_players_by_game(self, value: int): + if (value < 2): + raise serializers.ValidationError("The numbers of players by game must be greather than 2.") + nb_players: str = self.initial_data.get("nb_players") + if (nb_players is not None and nb_players.isnumeric()): + nb_players: int = int(nb_players) + if (value > nb_players): + raise serializers.ValidationError("The numbers of players by game must be smaller than the numbers of players.") + return value \ No newline at end of file diff --git a/tournament/test.py b/tournament/test.py new file mode 100644 index 0000000..3da31cb --- /dev/null +++ b/tournament/test.py @@ -0,0 +1,44 @@ +from django.test import TestCase + +# Create your tests here. +from django.test.client import Client +from django.http import HttpResponse +from django.contrib.auth.models import User + +import json +import uuid + +class CreateTest(TestCase): + def setUp(self): + self.client = Client() + + self.url = "/api/tournaments/" + + self.username = str(uuid.uuid4()) + self.password = str(uuid.uuid4()) + + self.nb_players_by_game = 2 + self.nb_players = 8 + + user: User = User.objects.create_user(username=self.username, password=self.password) + self.client.login(username=self.username, password=self.password) + + def test_normal(self): + response: HttpResponse = self.client.post(self.url, {"nb_players_by_game": self.nb_players_by_game, "nb_players": self.nb_players}) + response_data: dict = json.loads(response.content) + self.assertDictContainsSubset({"name": ""}, response_data) + + def test_too_small_nb_players_by_game(self): + response: HttpResponse = self.client.post(self.url, {"nb_players_by_game": 1, "nb_players": self.nb_players}) + response_data = json.loads(response.content) + self.assertDictEqual(response_data, {'nb_players_by_game': ['The numbers of players by game must be greather than 2.']}) + + def test_too_small_nb_players(self): + response: HttpResponse = self.client.post(self.url, {"nb_players_by_game": self.nb_players_by_game, "nb_players": 1}) + response_data = json.loads(response.content) + self.assertDictEqual(response_data, {'nb_players': ['The numbers of players must be greather than 2.'], 'nb_players_by_game': ['The numbers of players by game must be smaller than the numbers of players.']}) + + def test_nb_players_smaller_nb_players_by_game(self): + response: HttpResponse = self.client.post(self.url, {"nb_players_by_game": 5, "nb_players": 3}) + response_data = json.loads(response.content) + self.assertDictEqual(response_data, {'nb_players_by_game': ['The numbers of players by game must be smaller than the numbers of players.']}) \ No newline at end of file diff --git a/tournament/tests.py b/tournament/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/tournament/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tournament/urls.py b/tournament/urls.py new file mode 100644 index 0000000..3aeecb5 --- /dev/null +++ b/tournament/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, re_path +from django.conf import settings +from django.conf.urls.static import static + +from .viewset import TournamentViewSet + +urlpatterns = [ + path("", TournamentViewSet.as_view({"get": "retrieve"}), name="tournament_page"), + path("", TournamentViewSet.as_view({"post": "create"}), name="tournament_page"), + re_path(r"search/(?P\w*)", TournamentViewSet.as_view({"get": "list", }), name="tournaments"), +] \ No newline at end of file diff --git a/tournament/viewset.py b/tournament/viewset.py new file mode 100644 index 0000000..bc27f11 --- /dev/null +++ b/tournament/viewset.py @@ -0,0 +1,56 @@ +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework import permissions, status +from rest_framework.authentication import SessionAuthentication + +from django.http import HttpRequest +from django.contrib.auth import login +from django.db.models import QuerySet + +from .models import TournamentModel +from .serializers import TournamentSerializer + +# Create your views here. +class TournamentViewSet(viewsets.ModelViewSet): + + queryset = TournamentModel.objects.all + serializer_class = TournamentSerializer + permission_classes = (permissions.IsAuthenticated,) + authentication_classes = (SessionAuthentication,) + + def perform_create(self, serializer: TournamentSerializer): + + nb_players = serializer.validated_data["nb_players"] + nb_players_by_game = serializer.validated_data["nb_players_by_game"] + level = 1 + number: int = nb_players + while (number != nb_players_by_game): + number = number // 2 + (number % 2) + level += 1 + + tournament = serializer.save(level = level) + + return Response(self.serializer_class(tournament).data, status=status.HTTP_201_CREATED) + + def list(self, request: HttpRequest, state: str = ""): + query: QuerySet + match state: + case "started": + query = TournamentModel.objects.filter(started=True, finished=False) + case "finished": + query = TournamentModel.objects.filter(finished=True) + case "waiting": + query = TournamentModel.objects.filter(started=False, finished=False) + case _: + query = TournamentModel.objects.all() + serializer = TournamentSerializer(query, many=True) + return Response(serializer.data) + + def retrieve(self, request: HttpRequest, pk): + + if (not TournamentModel.objects.filter(pk=pk).exists()): + return Response({"detail": "Tournament not found."}, status=status.HTTP_404_NOT_FOUND) + + tournament = TournamentModel.objects.get(pk=pk) + + return Response(self.serializer_class(tournament).data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/transcendence/__init__.py b/transcendence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/transcendence/abstract/AbstractRoom.py b/transcendence/abstract/AbstractRoom.py new file mode 100644 index 0000000..7c1c87f --- /dev/null +++ b/transcendence/abstract/AbstractRoom.py @@ -0,0 +1,51 @@ +from channels.generic.websocket import WebsocketConsumer + +from .AbstractRoomMember import AbstractRoomMember + +class AbstractRoom: + + def __init__(self, room_manager): + self._member_list: [AbstractRoomMember] = [] + self.room_manager = room_manager + + def broadcast(self, detail: str, data: dict = {}): + for member in self._member_list: + member: AbstractRoomMember + member.send(detail, data) + + def clear(self): + self._member_list.clear() + + def get_member_by_socket(self, socket: WebsocketConsumer): + for member in self._member_list: + member: AbstractRoomMember + if (member.socket is socket): + return member + return None + + def get_member_by_user_id(self, user_id: int): + for member in self._member_list: + member: AbstractRoomMember + if (member.user_id == user_id): + return member + return None + + + def append(self, member: AbstractRoomMember): + self._member_list.append(member) + member.accept() + + def remove(self, member: AbstractRoomMember, code: int = 1000): + self._member_list.remove(member) + member.disconnect(code) + + def empty(self): + for _ in self._member_list: + return False + return True + + def get_users_id(self): + return [member.user_id for member in self._member_list] + + def __len__(self): + return len(self._member_list) diff --git a/transcendence/abstract/AbstractRoomManager.py b/transcendence/abstract/AbstractRoomManager.py new file mode 100644 index 0000000..a4bf09a --- /dev/null +++ b/transcendence/abstract/AbstractRoomManager.py @@ -0,0 +1,12 @@ +from .AbstractRoom import AbstractRoom + +class AbstractRoomManager: + + def __init__(self): + self._room_list: [AbstractRoom] = [] + + def remove(self, room: AbstractRoom): + self._room_list.remove(room) + + def append(self, room: AbstractRoom): + self._room_list.append(room) diff --git a/transcendence/abstract/AbstractRoomMember.py b/transcendence/abstract/AbstractRoomMember.py new file mode 100644 index 0000000..0a89b98 --- /dev/null +++ b/transcendence/abstract/AbstractRoomMember.py @@ -0,0 +1,20 @@ +from channels.generic.websocket import WebsocketConsumer + +import json + +class AbstractRoomMember: + + def __init__(self, user_id: int, socket: WebsocketConsumer): + self.user_id: int = user_id + self.socket: WebsocketConsumer = socket + + def send(self, detail: str, data: dict = {}): + raw_data: dict = {"detail": detail} + raw_data.update(data) + self.socket.send(text_data=json.dumps(raw_data)) + + def accept(self): + self.socket.accept() + + def disconnect(self, code: int = 1000): + self.socket.disconnect(code) \ No newline at end of file diff --git a/trancendence/asgi.py b/transcendence/asgi.py similarity index 85% rename from trancendence/asgi.py rename to transcendence/asgi.py index fa7ffa8..939a8ed 100644 --- a/trancendence/asgi.py +++ b/transcendence/asgi.py @@ -13,6 +13,7 @@ from channels.auth import AuthMiddlewareStack import chat.routing import matchmaking.routing +import tournament.routing from django.core.asgi import get_asgi_application @@ -23,7 +24,8 @@ application = ProtocolTypeRouter({ 'websocket':AuthMiddlewareStack( URLRouter( chat.routing.websocket_urlpatterns + - matchmaking.routing.websocket_urlpatterns + matchmaking.routing.websocket_urlpatterns + + tournament.routing.websocket_urlpatterns ) ) }) diff --git a/trancendence/settings.py b/transcendence/settings.py similarity index 94% rename from trancendence/settings.py rename to transcendence/settings.py index 411aca1..b75cedb 100644 --- a/trancendence/settings.py +++ b/transcendence/settings.py @@ -1,5 +1,5 @@ """ -Django settings for trancendence project. +Django settings for transcendence project. Generated by 'django-admin startproject' using Django 4.2.6. @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'channels', 'daphne', + 'tournament.apps.TournamentConfig', 'matchmaking.apps.MatchmakingConfig', 'games.apps.GamesConfig', 'accounts.apps.AccountsConfig', @@ -60,7 +61,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', ] -ASGI_APPLICATION = 'trancendence.asgi.application' +ASGI_APPLICATION = 'transcendence.asgi.application' CHANNEL_LAYERS = { 'default' :{ @@ -80,7 +81,7 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] -ROOT_URLCONF = 'trancendence.urls' +ROOT_URLCONF = 'transcendence.urls' TEMPLATES = [ { @@ -98,7 +99,7 @@ TEMPLATES = [ }, ] -WSGI_APPLICATION = 'trancendence.wsgi.application' +WSGI_APPLICATION = 'transcendence.wsgi.application' # Database diff --git a/trancendence/urls.py b/transcendence/urls.py similarity index 94% rename from trancendence/urls.py rename to transcendence/urls.py index 46a790f..38eb6d0 100644 --- a/trancendence/urls.py +++ b/transcendence/urls.py @@ -22,5 +22,6 @@ urlpatterns = [ path('api/profiles/', include('profiles.urls')), path('api/accounts/', include('accounts.urls')), path('api/chat/', include('chat.urls')), + path('api/tournaments/', include('tournament.urls')), path('', include('frontend.urls')), ] diff --git a/trancendence/wsgi.py b/transcendence/wsgi.py similarity index 100% rename from trancendence/wsgi.py rename to transcendence/wsgi.py