All checks were successful
PR checks / checks (pull_request) Successful in 2m55s
Nine captures tagged rust (D65): the server list, feed, leaderboard, live map, permissions and the RunicNPC profile editor on the web, and a server's feed, its map and the player's Rust account in the app, from an Android-app edge debug build. D80: every name in a capture is a role (admin, moderator, player...), from this phase on. D81: the Rust demo's players are mock data written by scripts/mockRustDemo.mjs; the server, its status, map, permission groups and RunicNPC sync state are the real Oxide test server's. The captions and /modules/rust/ and /app/ say which part is a demo cast. seedDemo.mjs gains --game rust (server row with a weekly wipe, public presence for the demo, two NPC profiles, a VIP group). captureScreens.mjs gains a click list per screen. The homepage shows the Rust live map beside the UO capture (D76); three Rust docs pages carry their screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
206 lines
11 KiB
JavaScript
206 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* mockRustDemo.mjs — the game data on the Rust demo's screenshots. PLAN.md §16 phase 18, D81.
|
|
*
|
|
* node scripts/mockRustDemo.mjs > rust-demo.sql
|
|
* docker exec -i <db container> mariadb -u<user> -p<password> <rust demo db> < rust-demo.sql
|
|
*
|
|
* Prints SQL; it connects to nothing. Run it after `seedDemo.mjs --game rust`, with the demo
|
|
* site connected to its server at least once, so the server row and its current wipe exist.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* WHY THIS IS MOCK DATA, WHEN THE UO CAPTURES ARE NOT
|
|
* ---------------------------------------------------------------------------------------
|
|
* D4 asks for real captures, and phase 9 got them from a real shard with a person signed in.
|
|
* The Rust rig has no players. Phase 18 first staged stand-in players on it with a rig-only
|
|
* plugin; that cost a morning of a server the update had left rebuilding its navmesh, and the
|
|
* org lead chose this instead (D81): the feed, the leaderboard and the linked Steam account
|
|
* are written into the demo's database here, in the module's own row shapes, and everything
|
|
* else on the screens is the real server's (its status, its world map, its sync state).
|
|
*
|
|
* Every name is a role (D80): player, player2 … player7, and moderator. The Steam ids sit in a
|
|
* range no real account uses.
|
|
*
|
|
* Re-running replaces what it wrote: it deletes the server's events, stats, weapon kills and
|
|
* these players first. Real events that arrived from the server are deleted with them.
|
|
*/
|
|
|
|
const SERVER = 'rust-oxide';
|
|
const ID_BASE = 76561199920000000n;
|
|
|
|
/** The cast. `n` makes the Steam id; `user` links one to a site account by username. */
|
|
const CAST = [
|
|
{ n: 1, name: 'player', user: 'player' },
|
|
{ n: 2, name: 'player2' },
|
|
{ n: 3, name: 'player3' },
|
|
{ n: 4, name: 'player4' },
|
|
{ n: 5, name: 'player5' },
|
|
{ n: 6, name: 'player6' },
|
|
{ n: 7, name: 'player7' },
|
|
{ n: 9, name: 'moderator' },
|
|
];
|
|
|
|
const steam = (n) => String(ID_BASE + BigInt(n));
|
|
const who = (n) => CAST.find((c) => c.n === n).name;
|
|
const now = Date.now();
|
|
const ago = (minutes) => now - Math.round(minutes * 60_000);
|
|
|
|
// ── The feed, oldest first ─────────────────────────────────────────────────────────────
|
|
//
|
|
// Each frame is the shape the bridge plugin emits (copied from frames the walk database
|
|
// holds), less `serverId`/`wipeId`/`t`/`type`, which `frame()` adds.
|
|
|
|
const death = (killer, victim, weapon, distance, grid, extra = {}) => ({
|
|
kind: 'player.death',
|
|
steamId: steam(victim),
|
|
name: who(victim),
|
|
attackerId: steam(killer),
|
|
attackerName: who(killer),
|
|
attackerType: 'player',
|
|
weapon,
|
|
distance,
|
|
grid,
|
|
sleeping: false,
|
|
...extra,
|
|
});
|
|
|
|
const world = (event, fields = {}) => ({ kind: 'world.event', event, ...fields });
|
|
|
|
const FEED = [
|
|
[150, { kind: 'player.connected', steamId: steam(1), name: who(1) }],
|
|
[148, { kind: 'player.connected', steamId: steam(2), name: who(2) }],
|
|
[146, { kind: 'player.connected', steamId: steam(3), name: who(3) }],
|
|
[141, world('cargo.arrived', { x: 2900, z: -2100 })],
|
|
[137, { kind: 'player.connected', steamId: steam(9), name: who(9) }],
|
|
[131, { kind: 'player.chat', steamId: steam(2), name: who(2), channel: 'Global', message: 'anyone selling scrap at outpost?' }],
|
|
[126, death(3, 4, 'bolt_rifle.entity', 187.4, 'AF10')],
|
|
[119, { kind: 'player.connected', steamId: steam(5), name: who(5) }],
|
|
[112, world('crate.hack.started', { steamId: steam(2), name: who(2), where: 'oilrig_small', x: 3358, z: 1721 })],
|
|
[104, death(1, 2, 'ak47u.entity', 22.4, 'R18')],
|
|
[98, world('heli.arrived', { x: 400, z: -900 })],
|
|
[96, world('crate.unlocked', { steamId: steam(5), name: who(5), where: 'oilrig_small', x: 3358, z: 1721 })],
|
|
[91, { kind: 'player.connected', steamId: steam(6), name: who(6) }],
|
|
[86, death(5, 3, 'lr300.entity', 64.9, 'H7')],
|
|
[80, world('heli.downed', {
|
|
x: 2453, z: -943,
|
|
credit: {
|
|
blow: steam(3), last: steam(1), most: steam(1),
|
|
damagers: [{ steamId: steam(1), damage: 5200 }, { steamId: steam(3), damage: 3900 }, { steamId: steam(6), damage: 900 }],
|
|
},
|
|
names: { [steam(1)]: who(1), [steam(3)]: who(3), [steam(6)]: who(6) },
|
|
creditRule: 'most',
|
|
})],
|
|
[74, { kind: 'player.chat', steamId: steam(9), name: who(9), channel: 'Global', message: 'wipe is Thursday 19:00, bases stay up till then' }],
|
|
[69, { kind: 'npc.boss.died', name: 'Gate Warden', killerName: who(6), killerId: steam(6), damagers: 3 }],
|
|
[63, death(6, 1, 'semi_auto_rifle.entity', 41.2, 'K12')],
|
|
[58, world('airdrop.looted', { steamId: steam(4), name: who(4), x: 164, z: -1507 })],
|
|
[52, { kind: 'player.connected', steamId: steam(7), name: who(7) }],
|
|
[47, death(2, 6, 'mp5.entity', 12.6, 'R17', { }) ],
|
|
[41, { kind: 'dt.won', steamId: steam(4), name: who(4), invaderCount: 2, invaders: [steam(4), steam(7)], x: 150, z: -800 }],
|
|
[36, world('bradley.destroyed', {
|
|
where: 'launch_site', x: -1200, z: 300,
|
|
credit: {
|
|
blow: steam(1), last: steam(1), most: steam(5),
|
|
damagers: [{ steamId: steam(5), damage: 630 }, { steamId: steam(1), damage: 385 }],
|
|
},
|
|
names: { [steam(1)]: who(1), [steam(5)]: who(5) },
|
|
creditRule: 'most',
|
|
})],
|
|
[31, death(1, 7, 'ak47u.entity', 96.3, 'J9')],
|
|
[25, { kind: 'gc.ended', steamId: steam(7), name: who(7), x: 200, z: -800 }],
|
|
[21, {
|
|
kind: 'raid.completed', base: 'raideasy7', difficulty: 'Easy', level: 0, pvp: false,
|
|
id: 'raid-demo-1', key: 'demo', ownerId: steam(6), ownerName: who(6), raiderCount: 1,
|
|
raiders: [{ steamId: steam(6), name: who(6) }], x: 280, z: -800,
|
|
}],
|
|
[16, death(3, 5, 'bolt_rifle.entity', 241.7, 'AB4', { })],
|
|
[12, { kind: 'player.chat', steamId: steam(1), name: who(1), channel: 'Global', message: 'gg on the heli' }],
|
|
[8, world('cargo.docking', { harbour: 'harbor_2', harbourName: 'Harbor', x: 2595, z: -2215 })],
|
|
[5, death(4, 2, 'thompson.entity', 18.9, 'Q14')],
|
|
[2, { kind: 'player.disconnected', steamId: steam(2), name: who(2), reason: 'Disconnected', sessionSec: 8760 }],
|
|
];
|
|
|
|
// ── The leaderboard ────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Consistent with the feed in spirit, not to the kill: a wipe is days long and the feed above
|
|
// is its last two hours.
|
|
const STATS = {
|
|
1: { kills: 31, deaths: 12, npc_kills: 58, headshots: 14, best_pvp_m: 212.4, structures: 340, playtime_sec: 61200, heli_kills: 2, apc_kills: 1, crates_hacked: 3, drops_looted: 5 },
|
|
3: { kills: 27, deaths: 9, npc_kills: 22, headshots: 19, best_pvp_m: 288.1, structures: 120, playtime_sec: 48900, heli_kills: 1, crates_hacked: 1, drops_looted: 2 },
|
|
5: { kills: 19, deaths: 15, npc_kills: 41, headshots: 6, best_pvp_m: 133.0, structures: 510, playtime_sec: 55800, apc_kills: 1, crates_hacked: 2, drops_looted: 7 },
|
|
6: { kills: 16, deaths: 11, npc_kills: 30, headshots: 5, best_pvp_m: 97.6, structures: 260, playtime_sec: 40100, raids_completed: 2, drops_looted: 3 },
|
|
4: { kills: 12, deaths: 18, npc_kills: 25, headshots: 3, best_pvp_m: 74.2, structures: 180, playtime_sec: 37400, treasures_won: 1, drops_looted: 4 },
|
|
2: { kills: 9, deaths: 21, npc_kills: 47, headshots: 2, best_pvp_m: 58.8, structures: 610, playtime_sec: 52300, crates_hacked: 2, drops_looted: 6 },
|
|
7: { kills: 6, deaths: 10, npc_kills: 13, headshots: 1, best_pvp_m: 96.3, structures: 90, playtime_sec: 21000, guarded_won: 1, drops_looted: 1 },
|
|
9: { kills: 2, deaths: 1, npc_kills: 4, headshots: 0, best_pvp_m: 35.0, structures: 40, playtime_sec: 9800 },
|
|
};
|
|
|
|
const WEAPON_KILLS = {
|
|
1: { 'ak47u.entity': 21, 'semi_auto_rifle.entity': 6, 'python.entity': 4 },
|
|
3: { 'bolt_rifle.entity': 22, 'lr300.entity': 5 },
|
|
5: { 'lr300.entity': 13, 'mp5.entity': 6 },
|
|
6: { 'semi_auto_rifle.entity': 10, 'ak47u.entity': 6 },
|
|
4: { 'thompson.entity': 8, 'mp5.entity': 4 },
|
|
2: { 'mp5.entity': 7, 'thompson.entity': 2 },
|
|
7: { 'ak47u.entity': 4, 'python.entity': 2 },
|
|
9: { 'lr300.entity': 2 },
|
|
};
|
|
|
|
// ── SQL ────────────────────────────────────────────────────────────────────────────────
|
|
|
|
const q = (value) =>
|
|
value === null || value === undefined ? 'NULL' : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
|
|
|
|
const ids = CAST.map((c) => q(steam(c.n))).join(', ');
|
|
const out = [];
|
|
|
|
out.push('-- mockRustDemo.mjs — PLAN.md §16 phase 18 (D81). Generated; do not edit by hand.');
|
|
out.push(`SET @server = ${q(SERVER)};`);
|
|
out.push(
|
|
"SET @wipe = (SELECT wipe_id FROM rust_wipes WHERE server_id = @server ORDER BY last_seen DESC LIMIT 1);",
|
|
);
|
|
out.push("SELECT IF(@wipe IS NULL, 'NO WIPE YET: connect the site to the server first', CONCAT('wipe ', @wipe)) AS mock;");
|
|
out.push('DELETE FROM rust_events WHERE server_id = @server;');
|
|
out.push('DELETE FROM rust_player_wipe_stats WHERE server_id = @server;');
|
|
out.push('DELETE FROM rust_weapon_kills WHERE server_id = @server;');
|
|
out.push(`DELETE FROM rust_account_links WHERE steam_id IN (${ids});`);
|
|
out.push(`DELETE FROM rust_players WHERE steam_id IN (${ids});`);
|
|
|
|
for (const c of CAST) {
|
|
out.push(
|
|
`INSERT INTO rust_players (steam_id, name, first_seen, last_seen) VALUES (${q(steam(c.n))}, ${q(c.name)}, NOW() - INTERVAL 6 DAY, NOW() - INTERVAL 2 MINUTE);`,
|
|
);
|
|
}
|
|
|
|
for (const [minutes, body] of FEED) {
|
|
const t = ago(minutes);
|
|
const raw = JSON.stringify({ ...body, serverId: SERVER, t, type: 'event', wipeId: '@@WIPE@@' });
|
|
const [before, after] = raw.split('"@@WIPE@@"');
|
|
out.push(
|
|
`INSERT INTO rust_events (server_id, wipe_id, kind, t, steam_id, raw, created_at) VALUES (@server, @wipe, ${q(body.kind)}, ${t}, ${q(body.steamId ?? null)}, CONCAT(${q(before)}, JSON_QUOTE(@wipe), ${q(after)}), FROM_UNIXTIME(${Math.floor(t / 1000)}));`,
|
|
);
|
|
}
|
|
|
|
for (const [n, row] of Object.entries(STATS)) {
|
|
const cols = Object.keys(row);
|
|
out.push(
|
|
`INSERT INTO rust_player_wipe_stats (server_id, wipe_id, steam_id, sessions, last_seen, ${cols.join(', ')}) VALUES (@server, @wipe, ${q(steam(Number(n)))}, 6, NOW() - INTERVAL 2 MINUTE, ${cols.map((k) => row[k]).join(', ')});`,
|
|
);
|
|
}
|
|
|
|
for (const [n, weapons] of Object.entries(WEAPON_KILLS)) {
|
|
for (const [weapon, kills] of Object.entries(weapons)) {
|
|
out.push(
|
|
`INSERT INTO rust_weapon_kills (server_id, wipe_id, steam_id, weapon, kills) VALUES (@server, @wipe, ${q(steam(Number(n)))}, ${q(weapon)}, ${kills});`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const c of CAST.filter((member) => member.user)) {
|
|
out.push(
|
|
`INSERT INTO rust_account_links (steam_id, user_id, name, server_id, linked_at) SELECT ${q(steam(c.n))}, id, ${q(c.name)}, @server, NOW() - INTERVAL 5 DAY FROM users WHERE username = ${q(c.user)};`,
|
|
);
|
|
}
|
|
|
|
console.log(out.join('\n'));
|