diff --git a/callsign.php b/callsign.php
index c2e323b..a8abc06 100644
--- a/callsign.php
+++ b/callsign.php
@@ -111,16 +111,17 @@ if (isset($_GET['submit'])) {
$lastCommitTime = $rs2[1];
if($isMember){
- $replaceWho = $_GET['who'];
- $sql = "SELECT ID, MissionID FROM pqs_queue WHERE `CallSign` like '$replaceWho-%' LIMIT 1";
- $result = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- $line = mysqli_fetch_array($result);
- $replaceID = $line[0];
- $missionid = $line[1];
-
- $sql = "UPDATE `pqs_queue` SET `CallSign`='$callsign',`Mech`='$mechname' WHERE `ID` = '$replaceID'";
- $result = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- mysqli_close($link);
+ // Atomically claim an unclaimed slot reserved by the group leader.
+ require_once("includes/queue.php");
+ $missionid = pqs_claim_group_slot($_GET['who'], $callsign, $mechname);
+ if ($missionid === null) {
+ echo "
";
+ echo "
";
+ echo " Sorry $_GET[callsign], there are no open slots left in that group.
Please register on your own or ask the group leader to reserve more slots.
| ";
+ echo " |
";
+ echo "";
+ exit;
+ }
$minToDeparture = $timePerGame*($missionid-$lastCommitedMissionID);
$d=strtotime("+$minToDeparture Minutes",$lastCommitTime);
@@ -136,50 +137,9 @@ if (isset($_GET['submit'])) {
if($isLeader){
- $sql = "SELECT MissionID, numplayers FROM pqs_mission WHERE Completed = 0 AND locked = 0 AND ($hm + numplayers) <= MissionSize ORDER BY MissionID ASC";
- $getid = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- $rs = mysqli_fetch_array($getid);
- $missionid = $rs[0];
- $numplayers = $rs[1];
- mysqli_close($link);
-
- if ($missionid == "") {
- $missionsize = $_SESSION['mechpods'];
- $link = mysqli_connect('localhost', 'pqs', 'pqs') or die('Could not connect: ' . mysqli_error($link));
- mysqli_select_db($link, 'pqs') or die('Could not select database');
- $sql = "INSERT INTO pqs_mission (MissionDate, MissionSize, numplayers, MissionTime) VALUES ($tdate, $missionsize, $hm, $ttime)";
- mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- $sql = "SELECT MissionID, numplayers FROM pqs_mission WHERE Completed = 0 AND locked = 0 AND numplayers <= MissionSize ORDER BY MissionID DESC";
- $getid = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- $rs1 = mysqli_fetch_array($getid);
- $missionid = $rs1[0];
- $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign','$mechname',$missionid)";
- $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link));
- $i = 2;
- while ($i <= $hm){
- $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign-$i','$mechname',$missionid)";
- $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link));
- $i ++;
- }
- mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- mysqli_close($link);
- } else {
- $newplayers = $numplayers + $hm;
- $link = mysqli_connect('localhost', 'pqs', 'pqs') or die('Could not connect: ' . mysqli_error($link));
- mysqli_select_db($link, 'pqs') or die('Could not select database');
- $sql = "UPDATE pqs_mission SET numplayers = $newplayers WHERE MissionID = $missionid";
- mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign','$mechname',$missionid)";
- $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link));
- $i = 2;
- while ($i <= $hm){
- $query = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign-$i','$mechname',$missionid)";
- $result = mysqli_query($link, $query) or die('Query failed: ' . mysqli_error($link));
- $i ++;
- }
-
- mysqli_close($link);
- }
+ // Atomically reserve $hm seats (leader + placeholders) in one mission.
+ require_once("includes/queue.php");
+ $missionid = pqs_enqueue_group($callsign, $mechname, $hm);
$minToDeparture = $timePerGame*($missionid-$lastCommitedMissionID);
$d=strtotime("+$minToDeparture Minutes",$lastCommitTime);
diff --git a/includes/queue.php b/includes/queue.php
index fb9c05c..a9200c3 100644
--- a/includes/queue.php
+++ b/includes/queue.php
@@ -2,95 +2,266 @@
/**
* Queue domain logic for PQS.
*
- * The core operation is "seat a player in the next open mission" — the thing
- * that races when the kiosk (callsign.php) and the staff console
- * (registration.php) add players at the same time. See docs/ARCHITECTURE.md §7.
+ * Every path that adds players to a mission goes through here so they all share
+ * one advisory lock and can't race each other into overfilling a mission or
+ * corrupting the numplayers counter. See docs/ARCHITECTURE.md §7.
+ *
+ * The kiosk (callsign.php) and the staff console (registration.php) are the
+ * concurrent writers; before this, each did its own read-check-insert.
*/
require_once __DIR__ . '/db.php';
/**
- * Atomically seat a player in the lowest-numbered open mission, creating a new
- * mission (sized to the current pod count) if none has room. Returns the
- * MissionID the player landed in.
- *
- * Concurrency safety comes from two layers:
- * 1. GET_LOCK serializes the whole find-or-create-insert critical section, so
- * no two registrations can interleave their capacity check and insert —
- * this is what prevents overfilling a mission or racing to create
- * duplicate missions.
- * 2. An InnoDB transaction makes the mission update + queue insert commit (or
- * roll back) as a unit, so a mid-way failure can't leave numplayers and
- * the queue out of sync.
- *
- * Capacity is checked against the real COUNT(*) of queued rows, and numplayers
- * is rewritten to that count after inserting, so the denormalized counter is
- * self-healing and can't drift the mission into over/under-fill.
+ * Run $fn while holding the global enqueue lock, guaranteeing release even if
+ * $fn throws. Serializes all seat-taking across every request/connection.
*/
-function pqs_enqueue_player(string $callsign, string $mech, int $merc = 0): int
+function pqs_with_queue_lock(callable $fn)
{
- $db = pqs_db();
-
if ((int) pqs_val("SELECT GET_LOCK('pqs_enqueue', 10)") !== 1) {
throw new RuntimeException('Could not acquire enqueue lock (timeout)');
}
-
try {
- $db->begin_transaction();
-
- // Lowest open mission that still has a free seat, judged by actual
- // occupancy rather than the cached counter.
- $mission = pqs_row(
- "SELECT MissionID, MissionSize
- FROM pqs_mission
- WHERE Completed = 0
- AND locked = 0
- AND (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = pqs_mission.MissionID) < MissionSize
- ORDER BY MissionID ASC
- LIMIT 1"
- );
-
- if ($mission === null) {
- // Nothing open: start a fresh mission sized to the current pods.
- $size = (int) pqs_val("SELECT numpods FROM pqs_gameconfig WHERE gamechoiceid = 1");
- if ($size < 1) {
- $size = 1;
- }
- // nummercs is NOT NULL without a default; set it explicitly (a new
- // mission has 0 mercs) so this works under strict SQL mode too.
- pqs_exec(
- "INSERT INTO pqs_mission (MissionDate, MissionSize, numplayers, nummercs, MissionTime)
- VALUES (?, ?, 0, 0, ?)",
- 'iii',
- [(int) date('Ymd'), $size, (int) date('Hi')]
- );
- $missionid = (int) $db->insert_id;
- } else {
- $missionid = (int) $mission['MissionID'];
- }
-
- pqs_exec(
- "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)",
- 'ssii',
- [$callsign, $mech, $missionid, $merc]
- );
-
- // Rewrite the cached counter from the truth, so it can never drift.
- pqs_exec(
- "UPDATE pqs_mission
- SET numplayers = (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = ?)
- WHERE MissionID = ?",
- 'ii',
- [$missionid, $missionid]
- );
-
- $db->commit();
- } catch (Throwable $e) {
- $db->rollback();
+ return $fn();
+ } finally {
pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')");
- throw $e;
}
-
- pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')");
- return $missionid;
+}
+
+/** Recompute a mission's numplayers from actual queue occupancy (self-healing). */
+function pqs_sync_numplayers(int $missionid): void
+{
+ pqs_exec(
+ "UPDATE pqs_mission
+ SET numplayers = (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = ?)
+ WHERE MissionID = ?",
+ 'ii',
+ [$missionid, $missionid]
+ );
+}
+
+/** Create a new mission sized to the current pod count; return its MissionID. */
+function pqs_create_mission(): int
+{
+ $size = (int) pqs_val("SELECT numpods FROM pqs_gameconfig WHERE gamechoiceid = 1");
+ if ($size < 1) {
+ $size = 1;
+ }
+ // nummercs is NOT NULL without a default; set it explicitly (0 for a new
+ // mission) so this works under strict SQL mode too.
+ pqs_exec(
+ "INSERT INTO pqs_mission (MissionDate, MissionSize, numplayers, nummercs, MissionTime)
+ VALUES (?, ?, 0, 0, ?)",
+ 'iii',
+ [(int) date('Ymd'), $size, (int) date('Hi')]
+ );
+ return (int) pqs_db()->insert_id;
+}
+
+/**
+ * Atomically seat one player in the lowest-numbered open mission that has room
+ * (creating one if none does). Returns the MissionID. Used by the kiosk's
+ * normal registration.
+ */
+function pqs_enqueue_player(string $callsign, string $mech, int $merc = 0): int
+{
+ return pqs_with_queue_lock(function () use ($callsign, $mech, $merc) {
+ $db = pqs_db();
+ $db->begin_transaction();
+ try {
+ $mission = pqs_row(
+ "SELECT MissionID
+ FROM pqs_mission
+ WHERE Completed = 0
+ AND locked = 0
+ AND (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = pqs_mission.MissionID) < MissionSize
+ ORDER BY MissionID ASC
+ LIMIT 1"
+ );
+ $missionid = $mission === null ? pqs_create_mission() : (int) $mission['MissionID'];
+
+ pqs_exec(
+ "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)",
+ 'ssii',
+ [$callsign, $mech, $missionid, $merc]
+ );
+ pqs_sync_numplayers($missionid);
+
+ $db->commit();
+ return $missionid;
+ } catch (Throwable $e) {
+ $db->rollback();
+ throw $e;
+ }
+ });
+}
+
+/**
+ * Atomically add a player to a SPECIFIC mission (staff "new" action). Does not
+ * pick a mission or enforce capacity — staff may intentionally overfill — but
+ * shares the lock so it can't race with kiosk registrations, and keeps
+ * numplayers accurate.
+ */
+function pqs_add_player_to_mission(int $missionid, string $callsign, string $mech, int $merc = 0): void
+{
+ pqs_with_queue_lock(function () use ($missionid, $callsign, $mech, $merc) {
+ $db = pqs_db();
+ $db->begin_transaction();
+ try {
+ pqs_exec(
+ "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)",
+ 'ssii',
+ [$callsign, $mech, $missionid, $merc]
+ );
+ pqs_sync_numplayers($missionid);
+ $db->commit();
+ } catch (Throwable $e) {
+ $db->rollback();
+ throw $e;
+ }
+ });
+}
+
+/**
+ * Staff "add to multiple missions": place $callsign in the next $count missions
+ * after $startmission, filling existing missions that have room and creating
+ * new ones at the tail as needed. Runs as one atomic unit under the shared lock.
+ */
+function pqs_add_player_to_missions(int $startmission, int $count, string $callsign, string $mech, int $merc = 0): void
+{
+ if ($count < 1) {
+ return;
+ }
+ pqs_with_queue_lock(function () use ($startmission, $count, $callsign, $mech, $merc) {
+ $db = pqs_db();
+ $db->begin_transaction();
+ try {
+ $placed = 0;
+ $mid = $startmission + 1;
+ while ($placed < $count) {
+ $maxmission = (int) pqs_val("SELECT MAX(MissionID) FROM pqs_mission");
+ if ($mid <= $maxmission) {
+ $row = pqs_row(
+ "SELECT MissionSize,
+ (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = ?) AS filled
+ FROM pqs_mission WHERE MissionID = ?",
+ 'ii',
+ [$mid, $mid]
+ );
+ if ($row !== null && (int) $row['filled'] < (int) $row['MissionSize']) {
+ pqs_exec(
+ "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)",
+ 'ssii',
+ [$callsign, $mech, $mid, $merc]
+ );
+ pqs_sync_numplayers($mid);
+ $placed++;
+ }
+ // full or missing: fall through and try the next mission
+ } else {
+ $mid = pqs_create_mission();
+ pqs_exec(
+ "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES (?, ?, ?, ?)",
+ 'ssii',
+ [$callsign, $mech, $mid, $merc]
+ );
+ pqs_sync_numplayers($mid);
+ $placed++;
+ }
+ $mid++;
+ }
+ $db->commit();
+ } catch (Throwable $e) {
+ $db->rollback();
+ throw $e;
+ }
+ });
+}
+
+/**
+ * Atomically reserve a group of $count seats in one mission for a leader:
+ * inserts the leader ($callsign) plus placeholder rows "$callsign-2" ..
+ * "$callsign-$count" that members later claim. Picks the lowest open mission
+ * that can fit the whole group, else creates one. Returns the MissionID.
+ */
+function pqs_enqueue_group(string $callsign, string $mech, int $count): int
+{
+ if ($count < 1) {
+ $count = 1;
+ }
+ return pqs_with_queue_lock(function () use ($callsign, $mech, $count) {
+ $db = pqs_db();
+ $db->begin_transaction();
+ try {
+ $mission = pqs_row(
+ "SELECT MissionID
+ FROM pqs_mission
+ WHERE Completed = 0
+ AND locked = 0
+ AND (SELECT COUNT(*) FROM pqs_queue WHERE MissionID = pqs_mission.MissionID) + ? <= MissionSize
+ ORDER BY MissionID ASC
+ LIMIT 1",
+ 'i',
+ [$count]
+ );
+ $missionid = $mission === null ? pqs_create_mission() : (int) $mission['MissionID'];
+
+ pqs_exec(
+ "INSERT INTO pqs_queue (CallSign, Mech, MissionID) VALUES (?, ?, ?)",
+ 'ssi',
+ [$callsign, $mech, $missionid]
+ );
+ for ($i = 2; $i <= $count; $i++) {
+ pqs_exec(
+ "INSERT INTO pqs_queue (CallSign, Mech, MissionID) VALUES (?, ?, ?)",
+ 'ssi',
+ ["$callsign-$i", $mech, $missionid]
+ );
+ }
+ pqs_sync_numplayers($missionid);
+
+ $db->commit();
+ return $missionid;
+ } catch (Throwable $e) {
+ $db->rollback();
+ throw $e;
+ }
+ });
+}
+
+/**
+ * Atomically claim one unclaimed placeholder slot reserved by leader $who,
+ * replacing it with this member's $callsign/$mech. Returns the MissionID the
+ * member landed in, or null if no free slot remained. Serialized so two members
+ * can't grab the same slot.
+ */
+function pqs_claim_group_slot(string $who, string $callsign, string $mech): ?int
+{
+ return pqs_with_queue_lock(function () use ($who, $callsign, $mech) {
+ $db = pqs_db();
+ $db->begin_transaction();
+ try {
+ // Unclaimed placeholders still look like "who-"; a claimed one has
+ // been renamed to the member's real callsign.
+ $slot = pqs_row(
+ "SELECT ID, MissionID FROM pqs_queue WHERE CallSign LIKE ? ORDER BY ID ASC LIMIT 1",
+ 's',
+ ["$who-%"]
+ );
+ if ($slot === null) {
+ $db->commit();
+ return null;
+ }
+ pqs_exec(
+ "UPDATE pqs_queue SET CallSign = ?, Mech = ? WHERE ID = ?",
+ 'ssi',
+ [$callsign, $mech, (int) $slot['ID']]
+ );
+ $db->commit();
+ return (int) $slot['MissionID'];
+ } catch (Throwable $e) {
+ $db->rollback();
+ throw $e;
+ }
+ });
}
diff --git a/registration.php b/registration.php
index ddb0d8b..e6237fe 100644
--- a/registration.php
+++ b/registration.php
@@ -86,67 +86,24 @@ if (isset($_POST['edit'])) {
}
if (isset($_POST['add'])) {
- if (isset($_POST['playerid'])) $playerid = $_POST['playerid'];
- if (isset($_POST['callsign'])) $callsign = str_replace("'", "'", $_POST['callsign']);
- if (isset($_POST['missionid'])) $missionid = $_POST['missionid'];
- if (isset($_POST['merc'])) $merc = $_POST['merc'];
- if (isset($_POST['mech'])) $mech = $_POST['mech'];
- if (isset($_POST['nummissions'])) $nummissions = $_POST['nummissions'];
-
- $link = mysqli_connect('localhost', 'pqs', 'pqs') or die('Could not connect: ' . mysqli_error($link));
- mysqli_select_db($link, 'pqs') or die('Could not select database');
-
- $sql = "SELECT MAX(MissionID) FROM pqs_mission";
- $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link));
- $rs = mysqli_fetch_array($result);
- $maxmission = $rs[0];
- $newmissionid = $missionid + 1;
-
- for ($i = 1; $i <= $nummissions;) {
- if ($newmissionid <= $maxmission) {
- $sql = "SELECT numplayers FROM pqs_mission WHERE MissionID = $newmissionid";
- $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link));
- $rs = mysqli_fetch_array($result);
- if ($rs[0] < $missionsize) {
- $sql = "UPDATE pqs_mission SET numplayers = numplayers + 1 WHERE MissionID = $newmissionid";
- $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link));
- $sql = "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES ('$callsign', '$mech', $newmissionid, $merc)";
- $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link));
- $i++;
- }
- } else {
- $sql = "INSERT INTO pqs_mission (MissionDate, MissionSize, numplayers, nummercs, MissionTime) VALUES ($tdate, $missionsize, 1, $merc, $ttime)";
- mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- $sql = "INSERT INTO pqs_queue (CallSign, Mech, MissionID, Merc) VALUES ('$callsign', '$mech', $newmissionid, $merc)";
- $result = mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link));
- $i++;
- }
- $newmissionid++;
- }
- mysqli_close($link);
+ // Add one player to the next N missions after $missionid (atomic).
+ require_once("includes/queue.php");
+ $callsign = isset($_POST['callsign']) ? $_POST['callsign'] : '';
+ $mech = isset($_POST['mech']) ? $_POST['mech'] : '';
+ $merc = isset($_POST['merc']) ? (int)$_POST['merc'] : 0;
+ $startmission = isset($_POST['missionid']) ? (int)$_POST['missionid'] : 0;
+ $nummissions = isset($_POST['nummissions']) ? (int)$_POST['nummissions'] : 0;
+ pqs_add_player_to_missions($startmission, $nummissions, $callsign, $mech, $merc);
}
if (isset($_POST['new'])) {
- if (isset($_POST['callsign'])) $callsign = str_replace("'", "''", $_POST['callsign']);
- if(is_null($callsign)){ $callsign = 'empty'; }
- if (isset($_POST['mech'])) $mech = $_POST['mech'];
- if (isset($_POST['missionid'])) $missionid = $_POST['missionid'];
-
- $link = mysqli_connect('localhost', 'pqs', 'pqs') or die('Could not connect: ' . mysqli_error($link));
- mysqli_select_db($link, 'pqs') or die('Could not select database');
-
- $sql = "SELECT numplayers FROM pqs_mission WHERE MissionID = '$missionid'";
- $getid = mysqli_query($link, $sql) or die('Query failed: ' . mysqli_error($link));
- $rs = mysqli_fetch_array($getid);
- $numplayers = $rs[0];
- $newplayers = $numplayers+1;
- $sql = "UPDATE pqs_mission SET numplayers='$newplayers' WHERE MissionID=$missionid";
- mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link));
-
- $sql = "INSERT INTO pqs_queue (callsign, mech, missionid) VALUES ('$callsign','$mech',$missionid)";
- mysqli_query($link, $sql) or die('Update player in Queue failed: ' . mysqli_error($link));
-
- mysqli_close($link);
+ // Add one player to a specific mission (atomic).
+ require_once("includes/queue.php");
+ $callsign = isset($_POST['callsign']) ? $_POST['callsign'] : '';
+ if ($callsign === '') { $callsign = 'empty'; }
+ $mech = isset($_POST['mech']) ? $_POST['mech'] : '';
+ $missionid = isset($_POST['missionid']) ? (int)$_POST['missionid'] : 0;
+ pqs_add_player_to_mission($missionid, $callsign, $mech);
}
if (isset($_POST['editgame'])) {