- All staff paths (build, lock, edit, editgame, delete, add, search) and the render SELECTs now use db.php helpers with bound params. Removes SQL injection via playerid/missionid/callsign/newtype/newmap/buildmissions. - Delete routes through new pqs_remove_player() (shared lock + transaction, resyncs numplayers from real occupancy instead of a raw numplayers-1). - Drop a stray buggy line ($callsign from an undefined $line) and guard $_SERVER['SERVER_ADDR']. build now sets nummercs=0 (strict-mode safe). Verified on the stack: build/edit/delete/editgame/lock/search all work; delete resyncs numplayers; no errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
292 lines
10 KiB
PHP
292 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* Queue domain logic for PQS.
|
|
*
|
|
* 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';
|
|
|
|
/**
|
|
* Run $fn while holding the global enqueue lock, guaranteeing release even if
|
|
* $fn throws. Serializes all seat-taking across every request/connection.
|
|
*/
|
|
function pqs_with_queue_lock(callable $fn)
|
|
{
|
|
if ((int) pqs_val("SELECT GET_LOCK('pqs_enqueue', 10)") !== 1) {
|
|
throw new RuntimeException('Could not acquire enqueue lock (timeout)');
|
|
}
|
|
try {
|
|
return $fn();
|
|
} finally {
|
|
pqs_val("SELECT RELEASE_LOCK('pqs_enqueue')");
|
|
}
|
|
}
|
|
|
|
/** 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 remove a player from the queue and resync their mission's
|
|
* numplayers from actual occupancy. Under the shared lock so it can't race a
|
|
* concurrent registration/commit.
|
|
*/
|
|
function pqs_remove_player(int $playerid): void
|
|
{
|
|
pqs_with_queue_lock(function () use ($playerid) {
|
|
$db = pqs_db();
|
|
$db->begin_transaction();
|
|
try {
|
|
$row = pqs_row("SELECT MissionID FROM pqs_queue WHERE ID = ?", 'i', [$playerid]);
|
|
pqs_exec("DELETE FROM pqs_queue WHERE ID = ?", 'i', [$playerid]);
|
|
if ($row !== null && $row['MissionID'] !== null) {
|
|
pqs_sync_numplayers((int) $row['MissionID']);
|
|
}
|
|
$db->commit();
|
|
} 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-<n>"; 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;
|
|
}
|
|
});
|
|
}
|