From 6a4be5ae1b89a4e79a83239f184f394efcabeeba Mon Sep 17 00:00:00 2001
From: Tor Andersson <tor@ccxvii.net>
Date: Fri, 24 Feb 2023 17:24:38 +0100
Subject: Invite player system.

---
 public/common/play.js |   2 +-
 public/join.js        |  72 +++++++++++++++++++++++++++++-----
 public/style.css      |   5 ++-
 schema.sql            |   1 +
 server.js             | 104 +++++++++++++++++++++++++++++---------------------
 views/join.pug        |  15 ++++++++
 6 files changed, 143 insertions(+), 56 deletions(-)

diff --git a/public/common/play.js b/public/common/play.js
index 054343c..964889b 100644
--- a/public/common/play.js
+++ b/public/common/play.js
@@ -343,7 +343,7 @@ function remove_resign_menu() {
 }
 
 function goto_rematch() {
-	window.location = "/rematch/" + params.game_id + "/" + params.role
+	window.location = "/rematch/" + params.game_id
 }
 
 function goto_replay() {
diff --git a/public/join.js b/public/join.js
index 5bb8b9c..72da646 100644
--- a/public/join.js
+++ b/public/join.js
@@ -3,6 +3,7 @@
 let start_status = game.status
 let evtsrc = null
 let timer = 0
+let invite_role = null
 
 function confirm_delete() {
 	let warning = `Are you sure you want to DELETE this game?`
@@ -39,6 +40,25 @@ function kick(role) {
 		post(`/part/${game.game_id}/${encodeURIComponent(role)}`)
 }
 
+function accept(role) {
+	post(`/accept/${game.game_id}/${encodeURIComponent(role)}`)
+}
+
+function send_invite() {
+	let invite_user = document.getElementById("invite_user").value
+	post(`/invite/${game.game_id}/${encodeURIComponent(invite_role)}/${encodeURIComponent(invite_user)}`)
+	document.getElementById("invite").close()
+}
+
+function show_invite(role) {
+	invite_role = role
+	document.getElementById("invite").showModal()
+}
+
+function hide_invite() {
+	document.getElementById("invite").close()
+}
+
 let blink_title = document.title
 let blink_timer = 0
 
@@ -115,6 +135,14 @@ function is_solo() {
 	return players.every(p => p.user_id === players[0].user_id)
 }
 
+function play_link(player) {
+	return `<a href="/${game.title_id}/play.html?game=${game.game_id}&role=${encodeURIComponent(player.role)}">${player.name}</a>`
+}
+
+function action_link(player, action, color, text) {
+	return `<a class="${color}" href="javascript:${action}('${player.role}')">${text}</a>`
+}
+
 function update() {
 	for (let i = 0; i < roles.length; ++i) {
 		let role = roles[i]
@@ -125,29 +153,47 @@ function update() {
 		let player = players.find(p => p.role === role)
 		let element = document.getElementById(role_id)
 		if (player) {
+			element.classList.remove("is_invite")
 			switch (game.status) {
 			case 2:
 				if (player.user_id === user_id)
-					element.innerHTML = `<a href="/${game.title_id}/play.html?game=${game.game_id}&role=${encodeURIComponent(role)}">${player.name}</a>`
+					element.innerHTML = play_link(player)
 				else
 					element.innerHTML = player.name
 				break
 			case 1:
 				element.classList.toggle("is_active", is_active(player, role))
 				if (player.user_id === user_id)
-					element.innerHTML = `<a href="/${game.title_id}/play.html?game=${game.game_id}&role=${encodeURIComponent(role)}">${player.name}</a><a class="red" href="javascript:part('${role}')">\u274c</a>`
+					element.innerHTML = play_link(player) + action_link(player, "part", "red", "\u274c")
 				else if (game.owner_id === user_id)
-					element.innerHTML = `${player.name}<a class="red" href="javascript:kick('${role}')">\u274c</a>`
+					element.innerHTML = player.name + action_link(player, "kick", "red", "\u274c")
 				else
 					element.innerHTML = player.name
 				break
 			case 0:
-				if (player.user_id === user_id)
-					element.innerHTML = `${player.name}<a class="red" href="javascript:part('${role}')">\u274c</a>`
-				else if (game.owner_id === user_id)
-					element.innerHTML = `${player.name}<a class="red" href="javascript:kick('${role}')">\u274c</a>`
-				else
-					element.innerHTML = player.name
+				if (player.is_invite) {
+					element.classList.add("is_invite")
+					if (player.user_id === user_id)
+						element.innerHTML = player.name + " ?" +
+							action_link(player, "part", "red", "\u274c") +
+							action_link(player, "accept", "green", "\u2714")
+					else if (player.user_id === user_id)
+						element.innerHTML = player.name + " ?" + action_link(player, "part", "red", "\u274c")
+					else if (game.owner_id === user_id)
+						element.innerHTML = player.name + " ?" + action_link(player, "kick", "red", "\u274c")
+					else
+						element.innerHTML = player.name + " ?"
+				} else {
+					element.classList.remove("is_invite")
+					if (player.user_id === user_id && player.is_invite)
+						element.innerHTML = player.name + action_link(player, "part", "red", "\u274c")
+					else if (player.user_id === user_id)
+						element.innerHTML = player.name + action_link(player, "part", "red", "\u274c")
+					else if (game.owner_id === user_id)
+						element.innerHTML = player.name + action_link(player, "kick", "red", "\u274c")
+					else
+						element.innerHTML = player.name
+				}
 				break
 			}
 			element.classList.toggle("friend", is_friend(player))
@@ -157,14 +203,20 @@ function update() {
 			else
 				element.title = ""
 		} else {
+			element.classList.remove("is_invite")
 			switch (game.status) {
 			case 2:
 				element.innerHTML = `<i>Empty</i>`
 				break
 			case 1:
-			case 0:
 				element.innerHTML = `<a class="join" href="javascript:join('${role}')">Join</a>`
 				break
+			case 0:
+				if (game.owner_id === user_id)
+					element.innerHTML = `<a class="join" href="javascript:join('${role}')">Join</a><a class="green" href="javascript:show_invite('${role}')">\u{2795}</a>`
+				else
+					element.innerHTML = `<a class="join" href="javascript:join('${role}')">Join</a>`
+				break
 			}
 			element.classList.remove("friend")
 			element.classList.remove("enemy")
diff --git a/public/style.css b/public/style.css
index c4b0b9e..e530ef3 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1,9 +1,9 @@
 html, input, textarea {
-	font-family: "Source Serif", "Georgia", "Dingbats", "Noto Emoji", serif;
+	font-family: "Source Serif", "Georgia", "Noto Emoji", "Dingbats", serif;
 	font-size: 16px;
 }
 button, select {
-	font-family: "Source Sans", "Verdana", "Dingbats", "Noto Emoji", sans-serif;
+	font-family: "Source Sans", "Verdana", "Noto Emoji", "Dingbats", sans-serif;
 	font-size: 16px;
 }
 
@@ -156,6 +156,7 @@ article hr + p { font-style: italic; }
 .game_item a:not(:hover) { text-decoration: none; color: black; }
 .game_item a.command { text-decoration: underline; font-weight: bold }
 .game_info .is_active { text-decoration: underline }
+.game_info .is_invite { opacity: 0.5 }
 .game_info div {
 	text-indent: -20px;
 	padding-left: 20px;
diff --git a/schema.sql b/schema.sql
index e2ca8a4..a3c0e2b 100644
--- a/schema.sql
+++ b/schema.sql
@@ -322,6 +322,7 @@ create table if not exists players (
 	game_id integer,
 	role text,
 	user_id integer,
+	is_invite integer,
 	primary key (game_id, role)
 ) without rowid;
 
diff --git a/server.js b/server.js
index 6ca6692..9942cd7 100644
--- a/server.js
+++ b/server.js
@@ -267,6 +267,7 @@ const SQL_SELECT_LOGIN_BY_NAME = SQL("SELECT * FROM user_login_view WHERE name=?
 const SQL_SELECT_USER_PROFILE = SQL("SELECT * FROM user_profile_view WHERE name=?")
 const SQL_SELECT_USER_DYNAMIC = SQL("select * from user_dynamic_view where user_id=?")
 const SQL_SELECT_USER_NAME = SQL("SELECT name FROM users WHERE user_id=?").pluck()
+const SQL_SELECT_USER_ID = SQL("SELECT user_id FROM users WHERE name=?").pluck()
 
 const SQL_OFFLINE_USER = SQL("SELECT * FROM user_view NATURAL JOIN user_last_seen WHERE user_id=? AND julianday() > atime + ?")
 
@@ -1054,6 +1055,9 @@ function get_game_roles(title_id, scenario, options) {
 }
 
 function is_game_ready(title_id, scenario, options, players) {
+	for (let p of players)
+		if (p.is_invite)
+			return false
 	return get_game_roles(title_id, scenario, options).length === players.length
 }
 
@@ -1093,11 +1097,12 @@ const SQL_SELECT_GAME_HAS_TITLE_AND_STATUS = SQL("SELECT 1 FROM games WHERE game
 
 const SQL_SELECT_PLAYERS_ID = SQL("SELECT DISTINCT user_id FROM players WHERE game_id=?").pluck()
 const SQL_SELECT_PLAYERS = SQL("SELECT * FROM players NATURAL JOIN user_view WHERE game_id=?")
-const SQL_SELECT_PLAYERS_JOIN = SQL("SELECT role, user_id, name FROM players NATURAL JOIN users WHERE game_id=?")
+const SQL_SELECT_PLAYERS_JOIN = SQL("SELECT role, user_id, name, is_invite FROM players NATURAL JOIN users WHERE game_id=?")
+const SQL_UPDATE_PLAYER_ACCEPT = SQL("UPDATE players SET is_invite=0 WHERE game_id=? AND role=? AND user_id=?")
+const SQL_UPDATE_PLAYER_ROLE = SQL("UPDATE players SET role=? WHERE game_id=? AND role=? AND user_id=?")
 const SQL_SELECT_PLAYER_ROLE = SQL("SELECT role FROM players WHERE game_id=? AND user_id=?").pluck()
-const SQL_INSERT_PLAYER_ROLE = SQL("INSERT OR IGNORE INTO players (game_id,role,user_id) VALUES (?,?,?)")
+const SQL_INSERT_PLAYER_ROLE = SQL("INSERT OR IGNORE INTO players (game_id,role,user_id,is_invite) VALUES (?,?,?,?)")
 const SQL_DELETE_PLAYER_ROLE = SQL("DELETE FROM players WHERE game_id=? AND role=?")
-const SQL_UPDATE_PLAYER_ROLE = SQL("UPDATE players SET role=? WHERE game_id=? AND role=? AND user_id=?")
 
 const SQL_AUTHORIZE_GAME_ROLE = SQL("SELECT 1 FROM players NATURAL JOIN games WHERE title_id=? AND game_id=? AND role=? AND user_id=?").pluck()
 
@@ -1109,13 +1114,15 @@ const SQL_INSERT_REMATCH = SQL(`
 	INSERT INTO games
 		(owner_id, title_id, scenario, options, is_private, is_random, description)
 	SELECT
-		$user_id, title_id, scenario, options, is_private, is_random, $magic
+		$user_id, title_id, scenario, options, is_private, 0, $magic
 	FROM games
 	WHERE game_id = $game_id AND NOT EXISTS (
 		SELECT * FROM games WHERE description=$magic
 	)
 `)
 
+const SQL_INSERT_REMATCH_PLAYERS = SQL("insert into players (game_id, user_id, role, is_invite) select ?, user_id, role, user_id!=? from players where game_id=?")
+
 const QUERY_LIST_PUBLIC_GAMES = SQL(`
 	SELECT * FROM game_view
 	WHERE is_private=0 AND status = ?
@@ -1224,10 +1231,14 @@ function annotate_game(game, user_id, unread) {
 			your_count++
 			if ((p_is_active || p_is_owner) && game.is_ready)
 				game.your_turn = true
+			if (p.is_invite)
+				game.your_turn = true
 		}
 
 		let link
-		if (p_is_active || p_is_owner)
+		if (p.is_invite)
+			link = `<span class="is_invite"><a href="/user/${p.name}">${p.name}?</a></span>`
+		else if (p_is_active || p_is_owner)
 			link = `<span class="is_active"><a href="/user/${p.name}">${p.name}</a></span>`
 		else
 			link = `<a href="/user/${p.name}">${p.name}</a>`
@@ -1403,44 +1414,19 @@ app.get('/delete/:game_id', must_be_logged_in, function (req, res) {
 	res.redirect('/'+title_id)
 })
 
-function join_rematch(req, res, game_id, role) {
-	try {
-		let is_random = SQL_SELECT_GAME_RANDOM.get(game_id)
-		if (is_random) {
-			let role = SQL_SELECT_PLAYER_ROLE.get(game_id, req.user.user_id)
-			if (!role) {
-				for (let i = 1; i <= 6; ++i) {
-					let info = SQL_INSERT_PLAYER_ROLE.run(game_id, 'Random ' + i, req.user.user_id)
-					if (info.changes === 1) {
-						update_join_clients_players(game_id)
-						break
-					}
-				}
-			}
-		} else {
-			let info = SQL_INSERT_PLAYER_ROLE.run(game_id, role, req.user.user_id)
-			if (info.changes === 1)
-				update_join_clients_players(game_id)
-		}
-	} catch (err) {
-		console.log(err)
-	}
-	return res.redirect('/join/'+game_id)
-}
-
-app.get('/rematch/:old_game_id/:role', must_be_logged_in, function (req, res) {
+app.get('/rematch/:old_game_id', must_be_logged_in, function (req, res) {
 	let old_game_id = req.params.old_game_id | 0
-	let role = req.params.role
 	let magic = "\u{1F503} " + old_game_id
 	let new_game_id = 0
+	console.log("FOO", old_game_id, magic)
 	let info = SQL_INSERT_REMATCH.run({user_id: req.user.user_id, game_id: old_game_id, magic: magic})
-	if (info.changes === 1)
+	if (info.changes === 1) {
 		new_game_id = info.lastInsertRowid
-	else
+		SQL_INSERT_REMATCH_PLAYERS.run(new_game_id, req.user.user_id, old_game_id)
+	} else {
 		new_game_id = SQL_SELECT_REMATCH.get(magic)
-	if (new_game_id)
-		return join_rematch(req, res, new_game_id, role)
-	return res.status(404).send("Can't create or find rematch game!")
+	}
+	return res.redirect('/join/'+new_game_id)
 })
 
 var join_clients = {}
@@ -1499,9 +1485,12 @@ app.get('/join/:game_id', must_be_logged_in, function (req, res) {
 	let players = SQL_SELECT_PLAYERS_JOIN.all(game_id)
 	let whitelist = SQL_SELECT_CONTACT_WHITELIST.all(req.user.user_id)
 	let blacklist = SQL_SELECT_CONTACT_BLACKLIST.all(req.user.user_id)
+	let friends = null
+	if (game.owner_id === req.user.user_id)
+		friends = SQL_SELECT_CONTACT_FRIEND_NAMES.all(req.user.user_id)
 	let ready = (game.status === 0) && is_game_ready(game.title_id, game.scenario, game.options, players)
 	res.render('join.pug', {
-		user: req.user, game, roles, players, ready, whitelist, blacklist
+		user: req.user, game, roles, players, ready, whitelist, blacklist, friends
 	})
 })
 
@@ -1539,9 +1528,7 @@ app.get('/join-events/:game_id', must_be_logged_in, function (req, res) {
 	res.write("data: " + JSON.stringify(players) + "\n\n")
 })
 
-app.post('/join/:game_id/:role', must_be_logged_in, function (req, res) {
-	let game_id = req.params.game_id | 0
-	let role = req.params.role
+function do_join(res, game_id, role, user_id, is_invite) {
 	let game = SQL_SELECT_GAME.get(game_id)
 	let roles = get_game_roles(game.title_id, game.scenario, game.options)
 	if (game.is_random && game.status === 0) {
@@ -1552,12 +1539,43 @@ app.post('/join/:game_id/:role', must_be_logged_in, function (req, res) {
 		if (!roles.includes(role))
 			return res.status(404).send("Invalid role.")
 	}
-	let info = SQL_INSERT_PLAYER_ROLE.run(game_id, role, req.user.user_id)
+	let info = SQL_INSERT_PLAYER_ROLE.run(game_id, role, user_id, is_invite)
+	if (info.changes === 1) {
+		update_join_clients_players(game_id)
+		res.send("SUCCESS")
+	} else {
+		if (is_invite)
+			res.send("Could not invite.")
+		else
+			res.send("Could not join game.")
+	}
+}
+
+app.post('/join/:game_id/:role', must_be_logged_in, function (req, res) {
+	let game_id = req.params.game_id | 0
+	let role = req.params.role
+	do_join(res, game_id, role, req.user.user_id, 0)
+})
+
+app.post('/invite/:game_id/:role/:user', must_be_logged_in, function (req, res) {
+	let game_id = req.params.game_id | 0
+	let role = req.params.role
+	let user_id = SQL_SELECT_USER_ID.get(req.params.user)
+	if (user_id)
+		do_join(res, game_id, role, user_id, 1)
+	else
+		res.send("User not found.")
+})
+
+app.post('/accept/:game_id/:role', must_be_logged_in, function (req, res) {
+	let game_id = req.params.game_id | 0
+	let role = req.params.role
+	let info = SQL_UPDATE_PLAYER_ACCEPT.run(game_id, role, req.user.user_id)
 	if (info.changes === 1) {
 		update_join_clients_players(game_id)
 		res.send("SUCCESS")
 	} else {
-		res.send("Could not join game.")
+		res.send("Could not accept invite.")
 	}
 })
 
diff --git a/views/join.pug b/views/join.pug
index 90f0db7..abbaf2c 100644
--- a/views/join.pug
+++ b/views/join.pug
@@ -11,11 +11,13 @@ html
 			table { min-width: 0; }
 			th,td { border: 1px solid black; }
 			td a.red { text-decoration: none; color: brown; font-size: 15px; float: right; }
+			td a.green { text-decoration: none; color: green; font-size: 15px; float: right; }
 			td a { text-decoration: underline; color: blue; }
 			th { white-space: nowrap; background-color: gainsboro; }
 			td { width: 180px; background-color: white; }
 			#message { background-color: whitesmoke; }
 			.hide { display: none; }
+			td.is_invite { color: gray }
 			td.enemy { background-color: #f66 }
 			td.enemy::before { content: "\1f6ab    "; color: #000; font-size: 15px; }
 		script.
@@ -25,6 +27,7 @@ html
 			let user_id = !{ user.user_id }
 			let whitelist = !{ JSON.stringify(whitelist) }
 			let blacklist = !{ JSON.stringify(blacklist) }
+			let friends = !{ JSON.stringify(friends) }
 			let ready = !{ ready }
 		script(src="/join.js")
 	body
@@ -50,6 +53,18 @@ html
 
 			br(clear="left")
 
+			dialog(id="invite")
+				| Invite a friend:
+				br
+				input(id="invite_user" type="text" list="friends" onchange="send_invite()")
+				datalist(id="friends")
+					if friends
+						each who in friends
+							option= who
+				br
+				button(onclick="send_invite()") Invite
+				button(onclick="hide_invite()") Cancel
+
 			p
 			table
 				tbody
-- 
cgit v1.2.3