+
+function qw_status_cached(string $host, int $port, int $maxAge = 30): ?array {
+ $cacheFile = sys_get_temp_dir() . "/qwstatus_{$host}_{$port}.json";
+
+ // If we have a fresh cached result, use it — don't hit the game server.
+ if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $maxAge) {
+ $cached = json_decode(file_get_contents($cacheFile), true);
+ if ($cached !== null) return $cached;
+ }
+
+ // Cache stale or missing — query the server once, then store it.
+ $status = qw_status($host, $port);
+ if ($status !== null) {
+ file_put_contents($cacheFile, json_encode($status));
+ }
+ return $status;
+}
+
+
+function qw_status(string $host, int $port = 27500, float $timeout = 2.0): ?array {
+ $sock = @stream_socket_client(
+ "udp://{$host}:{$port}",
+ $errno, $errstr,
+ $timeout
+ );
+ if (!$sock) {
+ return null;
+ }
+ stream_set_timeout($sock, (int)$timeout);
+
+ // status 23 = serverinfo + players + spectators + spec frag marker
+ fwrite($sock, "\xFF\xFF\xFF\xFFstatus 23\n");
+
+ $response = fread($sock, 8192);
+ fclose($sock);
+
+ if (!$response || strlen($response) < 5) {
+ return null;
+ }
+
+ // Strip 4-byte 0xFF header + 1-byte response type ('n')
+ $payload = substr($response, 5);
+ $lines = explode("\n", $payload);
+
+ // First line: serverinfo key/value string
+ $infoLine = ltrim(array_shift($lines), '\\');
+ $parts = explode('\\', $infoLine);
+ $info = [];
+ for ($i = 0; $i + 1 < count($parts); $i += 2) {
+ $info[$parts[$i]] = $parts[$i + 1];
+ }
+
+ // Remaining lines: players AND spectators
+ $players = [];
+ $spectators = [];
+ foreach ($lines as $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+
+ // Standard format:
+ // id frags time ping "name" "skin" topcolor bottomcolor
+ // Spectator format (mvdsv with flag 4+8):
+ // id "S" time ping "\s\name" "skin" topcolor bottomcolor
+ if (preg_match(
+ '/^(\d+)\s+(-?\d+|"S")\s+(\d+)\s+(\d+)\s+"([^"]*)"\s+"([^"]*)"\s+(\d+)\s+(\d+)/',
+ $line, $m
+ )) {
+ $isSpec = ($m[2] === '"S"') || str_starts_with($m[5], '\\s\\');
+ $name = $m[5];
+ if (str_starts_with($name, '\\s\\')) {
+ $name = substr($name, 3); // strip the \s\ prefix
+ }
+
+ $entry = [
+ 'id' => (int)$m[1],
+ 'frags' => $isSpec ? null : (int)$m[2],
+ 'time' => (int)$m[3],
+ 'ping' => (int)$m[4],
+ 'name' => $name,
+ 'skin' => $m[6],
+ 'top' => (int)$m[7],
+ 'bottom' => (int)$m[8],
+ ];
+
+ if ($isSpec) {
+ $spectators[] = $entry;
+ } else {
+ $players[] = $entry;
+ }
+ }
+ }
+
+ return [
+ 'info' => $info,
+ 'players' => $players,
+ 'spectators' => $spectators,
+ ];
+}
+
+
+function draw_dynamic_table($room = 'mvdsv')
+{
+ /* ---------------------------------------------------------------------
+ 1. YOUR DATA — edit only this block.
+ Two rooms. Keys are the UTC start time of the slot:
+ "00:00", "02:00", "04:00" ... "22:00". Leave a slot out if it's free.
+ ------------------------------------------------------------------- */
+ $reservations = array(
+ 'mvdsv' => array(
+ 'Monday' => array(),
+ 'Tuesday' => array('16:00' => 'upkeep'),
+ 'Wednesday' => array(),
+ 'Thursday' => array(),
+ 'Friday' => array('04:00' => 'deauth pckt'),
+ 'Saturday' => array(),
+ 'Sunday' => array(),
+ ),
+ 'fte' => array(
+ 'Monday' => array(),
+ 'Tuesday' => array('16:00' => 'upkeep'),
+ 'Wednesday' => array(),
+ 'Thursday' => array(),
+ 'Friday' => array(),
+ 'Saturday' => array(),
+ 'Sunday' => array(),
+ ),
+ );
+
+ /* ---------------------------------------------------------------------
+ 2. Machinery — no need to touch anything below.
+ ------------------------------------------------------------------- */
+
+ if (!isset($reservations[$room])) {
+ echo '<!-- draw_dynamic_table: unknown room -->';
+ return;
+ }
+ $book = $reservations[$room];
+
+ $days = array('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday');
+ $slot_minutes = 120; // 2 hours per row
+ $slot_count = 1440 / $slot_minutes; // 12 rows
+
+ /* The visitor's UTC offset in minutes, planted by optional-get-time.js.
+ No cookie (or a junk one) means no JavaScript: fall back to UTC+0. */
+ $offset = 0;
+ if (isset($_COOKIE['tzo']) && preg_match('/^-?\d{1,4}$/', $_COOKIE['tzo'])) {
+ $candidate = (int) $_COOKIE['tzo'];
+ if ($candidate >= -720 && $candidate <= 840) { // UTC-12 .. UTC+14
+ $offset = $candidate;
+ }
+ }
+
+ $wrap_day = function ($minutes) {
+ return (($minutes % 1440) + 1440) % 1440;
+ };
+
+ $clock = function ($minutes) use ($wrap_day) {
+ $m = $wrap_day($minutes);
+ return sprintf('%02d:%02d', intdiv($m, 60), $m % 60);
+ };
+
+ $zone_label = function ($minutes) {
+ $sign = $minutes < 0 ? '-' : '+';
+ $abs = abs($minutes);
+ $h = intdiv($abs, 60);
+ $m = $abs % 60;
+ return 'UTC' . $sign . $h . ($m ? sprintf(':%02d', $m) : '');
+ };
+
+ /* Each UTC row, translated into the visitor's day. day_shift says whether
+ that UTC slot lands on the previous, same, or next local day. Rows keep
+ their UTC alignment (so no booking gets split across two cells) and are
+ sorted by local start time. */
+ $rows = array();
+ for ($slot = 0; $slot < $slot_count; $slot++) {
+ $utc_start = $slot * $slot_minutes;
+ $shifted = $utc_start + $offset;
+ $rows[] = array(
+ 'utc_start' => $utc_start,
+ 'local_start' => $wrap_day($shifted),
+ 'day_shift' => (int) floor($shifted / 1440),
+ );
+ }
+ usort($rows, function ($a, $b) {
+ return $a['local_start'] - $b['local_start'];
+ });
+
+ /* "Now" in the visitor's zone, so today's column can be marked. */
+ $now_shifted = ((int) gmdate('G')) * 60 + ((int) gmdate('i')) + $offset;
+ $now_minutes = $wrap_day($now_shifted);
+ $now_day = ((((int) gmdate('N')) - 1) + (int) floor($now_shifted / 1440) + 7) % 7;
+
+ /* ---- output ------------------------------------------------------- */
+
+ $time_column_width = '8.5em';
+
+ echo '<table class="reservation-table" style="table-layout:fixed;width:100%">';
+
+ /* Fixed layout: the time column gets a set width, the seven day columns
+ split the rest equally. */
+ echo '<colgroup><col style="width:' . $time_column_width . '">'
+ . str_repeat('<col>', count($days))
+ . '</colgroup>';
+
+ /* Header: timezone in the corner, then the seven days. */
+ echo '<thead><tr>';
+ echo '<th class="rt-zone" scope="col" style="text-align:center">'
+ . htmlspecialchars($zone_label($offset), ENT_QUOTES, 'UTF-8')
+ . '</th>';
+ foreach ($days as $index => $day) {
+ echo '<th class="rt-day' . ($index === $now_day ? ' rt-today' : '') . '" scope="col">'
+ . htmlspecialchars($day, ENT_QUOTES, 'UTF-8')
+ . '</th>';
+ }
+ echo '</tr></thead>';
+
+ /* Body: one row per two-hour slot. */
+ echo '<tbody>';
+ foreach ($rows as $row) {
+ echo '<tr>';
+
+ echo '<td class="rt-time" style="text-align:center;white-space:nowrap">'
+ . htmlspecialchars(
+ $clock($row['local_start']) . ' to ' . $clock($row['local_start'] + $slot_minutes - 5),
+ ENT_QUOTES, 'UTF-8')
+ . '</td>';
+
+ $is_now_row = ($now_minutes >= $row['local_start'])
+ && ($now_minutes < $row['local_start'] + $slot_minutes);
+
+ foreach ($days as $index => $day) {
+ $utc_day = $days[($index - $row['day_shift'] + 7) % 7];
+ $key = $clock($row['utc_start']);
+ $name = isset($book[$utc_day][$key]) ? $book[$utc_day][$key] : '';
+
+ $classes = array('rt-slot', $name !== '' ? 'rt-booked' : 'rt-open');
+ if ($index === $now_day) {
+ $classes[] = 'rt-today';
+ if ($is_now_row) {
+ $classes[] = 'rt-now';
+ }
+ }
+
+ $safe = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
+ echo '<td class="' . implode(' ', $classes) . '"'
+ . ' style="overflow:hidden;white-space:nowrap;text-overflow:ellipsis"'
+ . ($name !== '' ? ' title="' . $safe . '"' : '')
+ . '>' . $safe . '</td>';
+ }
+
+ echo '</tr>';
+ }
+ echo '</tbody></table>';
+}
+
+