Files
Module-Rust/server/model/map/map.db.js
wtclaude 0cb9bdd1f0
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s
feat(rust): the live map (phase 14, protocol 11)
PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 01:06:10 -05:00

148 lines
5.8 KiB
JavaScript

// ── SQL for the map ───────────────────────────────────────────────────────
//
// Three things: the picture each server's map is drawn on (`rust_map_images`),
// the per-server overrides of the map's switches (`rust_map_overrides`), and the
// two reads the own-and-mates view needs — which Steam accounts a website user
// holds, and who shares a clan with them on one server.
//
// Nothing here stores a POSITION. Where people are is asked for while somebody
// is looking and kept in memory for seconds (D111, `mapLive.js`).
const core = require('../../core')
const IMAGES = 'rust_map_images'
const OVERRIDES = 'rust_map_overrides'
const LINKS = 'rust_account_links'
const CLANS = 'rust_clans'
const MEMBERS = 'rust_clan_members'
/** The columns every read but the picture's own wants — everything except the bytes. */
const META = `server_id AS serverId, map_key AS mapKey, sha256, source, width, height,
ocean_margin AS oceanMargin, world_size AS worldSize, grid_cells AS gridCells,
grid_cell_size AS gridCellSize, background, derivation, monuments,
OCTET_LENGTH(bytes) AS byteCount, fetched_at AS fetchedAt`
/** One server's picture row without the bytes, or undefined. */
async function getMeta(serverId) {
const rows = await core.query(`SELECT ${META} FROM ${IMAGES} WHERE server_id = ?`, [serverId])
return rows[0]
}
/** Every server's picture row without the bytes, for the admin card. */
async function listMeta() {
return core.query(`SELECT ${META} FROM ${IMAGES}`)
}
/**
* The picture itself, but only if it is still the one named. A URL carries the
* hash it was minted for, and a picture replaced since must not be served under
* it: the response is cached as immutable.
*/
async function getBytes(serverId, sha256) {
const rows = await core.query(
`SELECT bytes FROM ${IMAGES} WHERE server_id = ? AND sha256 = ? AND bytes IS NOT NULL`,
[serverId, sha256],
)
return rows[0] ? rows[0].bytes : null
}
/**
* Replace one server's row whole, in ONE statement. A new map's picture and its
* geometry arrive together or not at all; a reader never sees the new bytes
* under the old monuments.
*/
async function putImage(row) {
await core.query(
`REPLACE INTO ${IMAGES}
(server_id, map_key, sha256, source, width, height, ocean_margin, world_size,
grid_cells, grid_cell_size, background, derivation, monuments, bytes, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[
row.serverId, row.mapKey, row.sha256 || null, row.source, row.width, row.height, row.oceanMargin,
row.worldSize, row.gridCells, row.gridCellSize, row.background || null, row.derivation,
JSON.stringify(row.monuments || []), row.bytes || null,
],
)
}
/**
* Re-derive a row's geometry and monuments without touching its picture — the
* `DERIVATION_VERSION` path, and a map whose picture is unchanged but whose
* source moved (a render replaced by the Rust+ cache of the same bytes).
*/
async function putGeometry(row) {
await core.query(
`UPDATE ${IMAGES}
SET source = ?, width = ?, height = ?, ocean_margin = ?, world_size = ?, grid_cells = ?,
grid_cell_size = ?, background = ?, derivation = ?, monuments = ?
WHERE server_id = ? AND map_key = ?`,
[
row.source, row.width, row.height, row.oceanMargin, row.worldSize, row.gridCells, row.gridCellSize,
row.background || null, row.derivation, JSON.stringify(row.monuments || []), row.serverId, row.mapKey,
],
)
}
/** Every override for one server, as `{ setting: value }` rows. */
async function getOverrides(serverId) {
return core.query(`SELECT setting, value FROM ${OVERRIDES} WHERE server_id = ?`, [serverId])
}
/** Every override in the fleet, for the admin card. */
async function listOverrides() {
return core.query(`SELECT server_id AS serverId, setting, value FROM ${OVERRIDES}`)
}
/** Set (a word) or clear (`null`) one server's override of one switch. */
async function setOverride(serverId, setting, value, userId = null) {
if (value === null) {
await core.query(`DELETE FROM ${OVERRIDES} WHERE server_id = ? AND setting = ?`, [serverId, setting])
return
}
await core.query(
`INSERT INTO ${OVERRIDES} (server_id, setting, value, updated_by, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP`,
[serverId, setting, value, userId],
)
}
/** Every Steam id one website user has linked. A link reaches every server (D28). */
async function steamIdsForUser(userId) {
const rows = await core.query(`SELECT steam_id AS steamId FROM ${LINKS} WHERE user_id = ?`, [userId])
return rows.map((r) => String(r.steamId))
}
/**
* Every Steam id sharing a first-party clan with any of `steamIds` on ONE
* server (D115, D117) — the viewer's own accounts included, since they are
* members too. A clan the board no longer carries (`gone_at`) is nobody's clan.
*/
async function clanMatesOn(serverId, steamIds) {
if (!steamIds.length) return []
const rows = await core.query(
`SELECT DISTINCT m2.steam_id AS steamId
FROM ${MEMBERS} m1
JOIN ${CLANS} c ON c.external_id = m1.external_id
JOIN ${MEMBERS} m2 ON m2.external_id = m1.external_id
WHERE c.server_id = ? AND c.gone_at IS NULL
AND m1.steam_id IN (${steamIds.map(() => '?').join(', ')})`,
[serverId, ...steamIds],
)
return rows.map((r) => String(r.steamId))
}
module.exports = {
getMeta,
listMeta,
getBytes,
putImage,
putGeometry,
getOverrides,
listOverrides,
setOverride,
steamIdsForUser,
clanMatesOn,
}