--- /dev/null
+/db/db.sqlite
+*.swp
+*~
--- /dev/null
+## Mise en place
+
+Dans le dossier db/ : `sqlite3 rpsls.sqlite` <br>
+Puis à l'invite de commande SQLite : `.read setup.sql`
+
+Ensuite, dans ce répertoire : `pip install -r requirements.txt`
+
+## Utilisation (en local)
+
+À la racine du projet dans un terminal : `python server.py`
+
+Naviguez ensuite sur [http://127.0.0.1:8000/](http://127.0.0.1:8000/)
--- /dev/null
+# TODO: utiliser hug https://www.hug.rest/website/quickstart
+# pour implémenter une API permettant d'obtenir :
+
+# - les parties d'un joueur donné / avec ou sans les coups ?
+# (contre un joueur précis, qu'il a gagnées/perdues, à la date D...)
+# - des joueurs par identifiant(s), ou par motifs sur le nom.
+
+# L'idée étant, par exemple, de pouvoir créer un site sur lequel
+# s'afficherait un "leaderboard" (comme sur fishrandom.io), et où
+# on pourrait rejouer des parties, etc.
+
+# => Requêtes plus complexes à construire ensuite ; exemples :
+# - nombre maximal de parties gagnées d'affilée ?
+# - pourcentage de victoires ?
+
+# On peut aussi imaginer écrire dans la base depuis l'API :
+# par exemple pour permettre de jouer des tournois.
+# --> création d'une table "Tournaments",
+# ajout d'un champ "tid" dans la table Games (NULL par défaut),
+# ajout d'un paramètre optionnel après #!/play : ID de partie
+# (permet de récupérer l'adversaire ID + nom via XHR ...etc)
+# (si adversaire absent à la connexion, le signaler et attendre)
+
+RPSLS_PATH = './' #edit if launched from elsewhere
+DB_PATH = RPSLS_PATH + 'db/rpsls.sqlite'
+# ...
+
+# Extension : on peut aussi permettre de choisir le nombre de rounds.
--- /dev/null
+Rock, Paper and Scissors images are from https://www.iconfinder.com/
+(they are still striped, as printed on the website, since I didn't pay for them)
+
+Spock image was found here http://all-free-download.com/free-vector/download/spock-star-trek-cartoon_52494.html
+
+Lizard image was found here http://www.symbols-n-emoticons.com/2014/07/likable-lizard.html
+
+favicon from here https://fr.findicons.com/icon/441883/applications_games
+
+GIF source: https://www.pinterest.com/pin/587930926353765433/
+(edited with https://ezgif.com/)
+
+Question mark: https://fr.m.wikipedia.org/wiki/Fichier:Question_mark_alternate.svg
--- /dev/null
+body {
+ text-align: center;
+ font-family: Merriweather, serif;
+ font-size: 24px;
+}
+
+input {
+ display: block;
+ margin: 50px auto;
+ line-height: 42px;
+ padding-left: 10px;
+ font-size: 24px;
+}
+
+button {
+ background-color: #4CAF50; /* Green */
+ border: none;
+ color: white;
+ padding: 15px 32px;
+ text-decoration: none;
+ font-size: 24px;
+ display: block;
+ margin: 50px auto;
+ cursor: pointer;
+}
+
+.options {
+ width: 800px;
+ margin: 0 auto 30px auto;
+}
+
+.options img {
+ display: inline-block;
+ width: 20%;
+ margin: 0;
+}
+
+.options img:hover {
+ cursor: pointer;
+ background-color: lightblue;
+}
+
+.choices {
+ width: 320px;
+ margin: 0 auto 30px auto;
+}
+
+.choices img {
+ display: inline-block;
+ width: 50%;
+ margin: 0;
+}
+
+img.animated {
+ display: block;
+ width: 200px;
+ margin: 50px auto;
+}
--- /dev/null
+-- A user may or may not play games
+create table Users(
+ id integer primary key,
+ name varchar(32) unique not null
+);
+
+create table Games(
+ id integer primary key,
+ created date
+);
+
+-- A player (ref. uid) is involved into a game (ref. gid)
+create table Players(
+ uid integer,
+ gid integer,
+ points integer not null default 0,
+ foreign key(uid) references Users(id),
+ foreign key(gid) references Games(id)
+);
+
+create table Moves(
+ uid integer,
+ gid integer,
+ choice character(1) not null, --'r', 'p', 's', 'l' or 'k'
+ mnum integer not null,
+ foreign key(uid) references Users(id),
+ foreign key(gid) references Games(id)
+);
--- /dev/null
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>rpsls</title>
+ <link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,700;1,400&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="assets/rpsls.css">
+ </head>
+ <body>
+ <script src="https://unpkg.com/mithril@2.0.4/mithril.min.js"></script>
+ <script src="https://unpkg.com/socket.io-client@4.3.2/dist/socket.io.min.js"></script>
+ <script src="rpsls.js"></script>
+ </body>
+</html>
--- /dev/null
+eventlet==0.32.0
+python-socketio==5.4.1
--- /dev/null
+const socket = io();
+
+const getV = (field) => localStorage.getItem(field);
+
+const Login = {
+ view: function() {
+ return m("input[type=text][placeholder=User name]", {
+ autofocus: true,
+ onchange(e) {Login.username = e.currentTarget.value},
+ onkeyup: Login.process
+ });
+ },
+ process: function(e) {
+ if (e.keyCode == 13) socket.emit("login", e.target.value);
+ }
+};
+
+let Seek = {
+ searching: false,
+ seekGame: function() {
+ // If not logged in, redirect
+ if (!getV("name")) m.route.set("/login");
+ else {
+ socket.emit("seek", {uid: getV("uid"), name: getV("name")});
+ Seek.searching = true;
+ }
+ },
+ view: function(vnode) {
+ if (Seek.searching)
+ return m("img.animated", {src: "assets/searching.gif"});
+ return m("button", {onclick: Seek.seekGame}, "Play");
+ }
+};
+
+const Choices =
+ {'r':"Rock",'p':"Paper",'s':"Scissors",'l':"Lizard",'k':"Spock",'?':"what"};
+const Win =
+ {'r':['s','l'], 'p':['r','k'], 's':['p','l'], 'l':['p','k'], 'k':['r','s']};
+const MAX_POINTS = 7;
+
+let Play = {
+ gid: 0,
+ mymove: "",
+ oppmove: "",
+ myselect: "?",
+ oppselect: "?",
+ mypoints: 0,
+ oppoints: 0,
+ mnum: 0,
+ oppid: 0,
+ oppname: "",
+ gameover: false,
+ initGame: function(msg) {
+ Play.gid = msg.gid;
+ Play.oppid = msg.oppid;
+ Play.oppname = msg.oppname;
+ Play.gameover = false;
+ for (const v of ["mymove","oppmove"]) Play[v] = "";
+ for (const v of ["myselect","oppselect"]) Play[v] = "?";
+ for (const v of ["mnum","mypoints","oppoints"]) Play[v] = 0;
+ },
+ compareMoves: function() {
+ Play.oppselect = Play.oppmove; //reveal opponent's move only now
+ if (Win[Play.mymove].includes(Play.oppmove)) {
+ if (++Play.mypoints == MAX_POINTS) Play.endGame(true);
+ }
+ else if (Win[Play.oppmove].includes(Play.mymove)) {
+ if (++Play.oppoints == MAX_POINTS) Play.endGame(false);
+ }
+ Play.mnum++;
+ Play.mymove = "";
+ Play.oppmove = "";
+ },
+ endGame: function(Iwin) {
+ Play.gameover = true;
+ setTimeout(() => {
+ if (Iwin) alert("Bravo t'as gagné !");
+ else alert("Pas de chance, t'as perdu...");
+ m.route.set("/seek");
+ }, 1000);
+ },
+ playMove: function(code) {
+ if (Play.mymove || Play.gameover)
+ // I already played, or game is over
+ return;
+ Play.mymove = code;
+ Play.myselect = code;
+ socket.emit("move", {
+ uid: getV("uid"),
+ gid: Play.gid,
+ choice: Play.mymove,
+ mnum: Play.mnum,
+ oppid: Play.oppid
+ });
+ if (Play.oppmove) Play.compareMoves();
+ else Play.oppselect = "?";
+ },
+ view: function() {
+ return m("div", {},
+ [m("h2", {},
+ `${getV("name")} [${Play.mypoints}] vs. ${Play.oppname} [${Play.oppoints}]`)]
+ .concat(
+ [m(".choices", {}, [
+ m("img", {src: "assets/" + Choices[Play.myselect] + ".png"}),
+ m("img", {src: "assets/" + Choices[Play.oppselect] + ".png"})
+ ])]).concat(
+ [m(".options",
+ {style: `opacity:${Play.mymove==''?'1':'0.5'}`},
+ ["r","p","s","l","k"].map((code) => {
+ return m("img", {
+ src: "assets/" + Choices[code] + ".png",
+ onclick: () => Play.playMove(code)
+ })
+ }))])
+ );
+ }
+};
+
+socket.on("login", (msg) => {
+ if (!msg.err) {
+ localStorage.setItem("name", msg.name);
+ localStorage.setItem("uid", msg.uid);
+ m.route.set("/seek");
+ }
+ else alert(msg.err);
+});
+socket.on("play", (msg) => {
+ Seek.searching = false;
+ if (msg.oppid == getV("uid")) {
+ alert("Cannot play against self!");
+ m.redraw(); //TODO: because no DOM interaction... ?
+ }
+ else {
+ Play.initGame(msg);
+ m.route.set("/play");
+ }
+});
+socket.on("move", (msg) => {
+ Play.oppmove = msg.choice;
+ Play.oppselect = "?"; //not showing opponent selection yet!
+ if (Play.mymove) Play.compareMoves();
+ else Play.myselect = "?";
+ m.redraw(); //TODO... (because no DOM interactions)
+});
+
+m.route(document.body, "/seek", {
+ "/seek": Seek,
+ "/play": Play,
+ "/login": Login
+});
--- /dev/null
+import socketio
+import eventlet
+import sqlite3
+import re
+from datetime import date
+import os
+
+# Create a Socket.IO server (CORS arg required on server, not locally)
+sio = socketio.Server()
+#sio = socketio.Server(cors_allowed_origins='URL or *')
+
+RPSLS_PATH = './' #edit if launched from elsewhere
+DB_PATH = RPSLS_PATH + 'db/rpsls.sqlite'
+
+searching = {} #someone seeks a game? (uid + sid)
+connected = {} #map uid --> sid (seek stage)
+
+@sio.event
+def disconnect(sid):
+ """ Triggered at page reload or tab close """
+ global connected, searching
+ try:
+ key_idx = list(connected.values()).index(sid)
+ del connected[list(connected.keys())[key_idx]]
+ except ValueError:
+ # If the user didn't seek, no key to find
+ pass
+ if searching and searching["sid"] == sid:
+ searching = {}
+
+@sio.event
+def login(sid, data):
+ """ When user sends name from /login page """
+ if not re.match(r"^[a-zA-Z]{3,}$", data):
+ sio.emit("login", {"err": "Name: letters only"}, room=sid)
+ return
+ con = sqlite3.connect(DB_PATH)
+ cur = con.cursor()
+ uid = 0
+ try:
+ # Always try to insert (new) Users row
+ cur.execute("insert into Users (name) values (?)", (data,))
+ uid = cur.lastrowid
+ except sqlite3.IntegrityError as err:
+ # If fails: user already exists, find its ID
+ if str(err) == "UNIQUE constraint failed: Users.name":
+ cur.execute("select id from Users where name = ?", (data,))
+ uid = cur.fetchone()[0]
+ else:
+ raise
+ con.commit()
+ con.close()
+ sio.emit("login", {"name": data, "uid": uid}, room=sid)
+
+@sio.event
+def seek(sid, data):
+ """ When user click on 'Play' button """
+ global connected, searching
+ connected[data["uid"]] = sid
+ if not searching:
+ searching = {"uid": data["uid"], "sid": sid, "name": data["name"]}
+ else:
+ # Active seek pending: create game
+ opponent = searching
+ searching = {}
+ con = sqlite3.connect(DB_PATH)
+ cur = con.cursor()
+ today = (date.today(),)
+ cur.execute("insert into Games (created) values (?)", today)
+ gid = cur.lastrowid
+ # To room == sid, opponent is me. To my room, it's him/her
+ sio.emit("play",
+ {"gid":gid, "oppid":opponent["uid"], "oppname":opponent["name"]},
+ room=sid)
+ sio.emit("play",
+ {"gid":gid, "oppid":data["uid"], "oppname":data["name"]},
+ room=opponent["sid"])
+ id_list = [(data["uid"],gid), (opponent["uid"],gid)]
+ cur.executemany("insert into Players (uid,gid) values (?,?)", id_list)
+ con.commit()
+ con.close()
+
+@sio.event
+def move(sid, data):
+ """ New move to DB + transmit to opponent """
+ sio.emit("move", data, room=connected[data["oppid"]])
+ con = sqlite3.connect(DB_PATH)
+ cur = con.cursor()
+ cur.execute("insert into Moves (uid,gid,choice,mnum) values (?,?,?,?)",
+ (data["uid"],data["gid"],data["choice"],data["mnum"]))
+ con.commit()
+ con.close()
+
+static_files = {
+ '/': RPSLS_PATH + 'index.html',
+ '/rpsls.js': RPSLS_PATH + 'rpsls.js',
+ '/favicon.ico': RPSLS_PATH + 'favicon.ico',
+ '/assets': RPSLS_PATH + 'assets'
+}
+
+PORT = os.getenv('RPSLS_PORT')
+if PORT is None:
+ PORT = "8000"
+PORT = int(PORT)
+
+# Wrap with a WSGI application
+app = socketio.WSGIApp(sio, static_files=static_files)
+eventlet.wsgi.server(eventlet.listen(('127.0.0.1', PORT)), app)