Admin: Rust NPC profiles (the form RunicNPC reads, per server, shared or fleet, each server's push state and refusals, replaced profiles with Restore) and Rust NPC placements (the live map: click to place, pins for every placement; edit, rename, respawn, remove). The live map takes a pick handler and pins, unchanged for the public page. Public: the leaderboard ranks by a profile's kills (D250), and opening a row shows that player's kills by profile (D252); the killfeed names a RunicNPC NPC by its own name. Player: your own kills by profile. Titles: a rule on a profile's kills picks the profile. npcs.test.js covers the profile checks, adoption, the push, the picker and verb, the triggers, kill crediting, titles and placements (481 server tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
102 lines
3.8 KiB
JavaScript
102 lines
3.8 KiB
JavaScript
// ── How a world position reaches a pixel ──────────────────────────────────
|
||
//
|
||
// PLAN.md §30.3, in the one place the page computes it. Rust's world is centred
|
||
// on the origin, x east and z north; the picture is the world at a scale, with
|
||
// an ocean margin around it that is measured in PIXELS and is not scaled (the
|
||
// rig's 3000 map is 3000 × 0.5 + 2 × 500 = 2500 pixels). So:
|
||
//
|
||
// s = (width − 2 × margin) / worldSize
|
||
// px = (x + worldSize / 2) × s + margin
|
||
// py = (z + worldSize / 2) × s + margin measured UP from the bottom edge
|
||
//
|
||
// Up from the bottom because Leaflet's `CRS.Simple` has y growing north, which
|
||
// is Rust's z — so the picture's bounds are `[[0, 0], [height, width]]` and a
|
||
// position is `[py, px]` with no flip anywhere.
|
||
//
|
||
// The grid is the GAME's (D119): `gridCells` cells of `gridCellSize` metres per
|
||
// side, lettered from the west and numbered from the north, `A0` at the
|
||
// north-west corner. Both numbers come from the plugin, which asks the game's own
|
||
// `MapHelper`; nothing here assumes a cell size.
|
||
//
|
||
// Pure, and free of Leaflet, so it is tested in Node.
|
||
|
||
/** Pixels per metre, or 0 for a geometry that cannot place anything. */
|
||
export function scaleOf(g) {
|
||
if (!g || !(g.worldSize > 0) || !(g.width > 0)) return 0
|
||
return (g.width - 2 * (g.oceanMargin || 0)) / g.worldSize
|
||
}
|
||
|
||
/** A world position as `[lat, lng]` in the map's pixel space. */
|
||
export function toLatLng(g, x, z) {
|
||
const s = scaleOf(g)
|
||
const half = g.worldSize / 2
|
||
const m = g.oceanMargin || 0
|
||
return [(Number(z) + half) * s + m, (Number(x) + half) * s + m]
|
||
}
|
||
|
||
/** The inverse of `toLatLng`: a point on the picture as world `{ x, z }` in metres (a click on the map). */
|
||
export function fromLatLng(g, lat, lng) {
|
||
const s = scaleOf(g)
|
||
if (!(s > 0)) return null
|
||
const half = g.worldSize / 2
|
||
const m = g.oceanMargin || 0
|
||
return { x: (Number(lng) - m) / s - half, z: (Number(lat) - m) / s - half }
|
||
}
|
||
|
||
/** The picture's bounds in the same space. */
|
||
export function boundsOf(g) {
|
||
return [[0, 0], [g.height, g.width]]
|
||
}
|
||
|
||
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
|
||
export function column(index) {
|
||
let name = ''
|
||
let n = index + 1
|
||
while (n > 0) {
|
||
const r = (n - 1) % 26
|
||
name = String.fromCharCode(65 + r) + name
|
||
n = Math.floor((n - 1) / 26)
|
||
}
|
||
return name
|
||
}
|
||
|
||
/** The grid label for a world position, the way the in-game map writes it. */
|
||
export function gridLabel(g, x, z) {
|
||
if (!g || !(g.gridCells > 0) || !(g.gridCellSize > 0)) return null
|
||
const half = g.worldSize / 2
|
||
const clamp = (v) => Math.max(0, Math.min(g.gridCells - 1, v))
|
||
const col = clamp(Math.floor((Number(x) + half) / g.gridCellSize))
|
||
const row = clamp(Math.floor((half - Number(z)) / g.gridCellSize))
|
||
return `${column(col)}${row}`
|
||
}
|
||
|
||
/**
|
||
* The grid as lines and labels in world metres: `lines` are `[[x1, z1], [x2,
|
||
* z2]]` pairs, `labels` sit at each cell's north-west corner.
|
||
*/
|
||
export function grid(g) {
|
||
if (!g || !(g.gridCells > 0) || !(g.gridCellSize > 0)) return { lines: [], labels: [] }
|
||
const half = g.worldSize / 2
|
||
const n = g.gridCells
|
||
const c = g.gridCellSize
|
||
const lines = []
|
||
for (let i = 0; i <= n; i += 1) {
|
||
const at = -half + i * c
|
||
lines.push([[at, half], [at, half - n * c]])
|
||
lines.push([[-half, half - i * c], [-half + n * c, half - i * c]])
|
||
}
|
||
const labels = []
|
||
for (let col = 0; col < n; col += 1) {
|
||
for (let row = 0; row < n; row += 1) {
|
||
labels.push({ text: `${column(col)}${row}`, x: -half + col * c, z: half - row * c })
|
||
}
|
||
}
|
||
return { lines, labels }
|
||
}
|
||
|
||
/** Seconds as `m:ss`, for a locked crate's hack. */
|
||
export function countdown(seconds) {
|
||
const s = Math.max(0, Math.round(Number(seconds) || 0))
|
||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||
}
|