Files
Module-Rust/client/src/lib/mapGeometry.js
wtclaude 7eae4a1e62 feat(rust): NPC profiles and placements pages, the profile leaderboard (runicnpc stage 4)
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
2026-09-30 04:42:05 -05:00

102 lines
3.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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')}`
}