Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7483e131ef | |||
| 59db88d006 | |||
| 19ad9279fa | |||
| 22da0026ca | |||
| ef9596ce67 | |||
| 25a698e86e | |||
| 3c0fcb0003 | |||
| 38f854d580 | |||
| 48aa62a07a | |||
| e0d6c46850 | |||
| 594acb5351 | |||
| 5104e0dbcd | |||
| b52b8a8f80 | |||
| 9c99aceb65 | |||
| 454ce53937 | |||
| 92915f7de6 | |||
| 4e83def0dd | |||
| 7b1eabcd8b | |||
| 72f51c3d9e | |||
| 8e8553c589 | |||
| 7c9c8bdacb | |||
| 49a677e3fb | |||
| 491760d817 | |||
| 8fdc374ab3 | |||
| 51de1b492f | |||
| adb9df6a2e | |||
| 6be14c7270 | |||
| e3dc918400 | |||
| 3db35cdd02 | |||
| 66fbbd6b57 | |||
| cc7b2b2a0e | |||
| 6952fbf123 |
@@ -30,7 +30,7 @@
|
|||||||
#
|
#
|
||||||
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
|
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
|
||||||
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers
|
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers
|
||||||
# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the
|
# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`, `RunicGatewayRaids.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the
|
||||||
# manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to
|
# manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to
|
||||||
# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each
|
# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each
|
||||||
# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never
|
# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
# committed file keeps its placeholder; the manifest records the commit.
|
# committed file keeps its placeholder; the manifest records the commit.
|
||||||
# Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168,
|
# Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168,
|
||||||
# D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs`
|
# D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs`
|
||||||
# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own
|
# for ZoneDomes, PLAN_REDESIGNS D194, and `RunicGatewayRaids.cs` for RaidableBases,
|
||||||
|
# D204) keeps its own
|
||||||
# version: it does one job, changes rarely, and the bridge reads its version at
|
# version: it does one job, changes rarely, and the bridge reads its version at
|
||||||
# hello to know which job it does.
|
# hello to know which job it does.
|
||||||
#
|
#
|
||||||
@@ -81,7 +82,7 @@ env:
|
|||||||
PLUGIN: overlay/oxide/plugins/RunicGateway.cs
|
PLUGIN: overlay/oxide/plugins/RunicGateway.cs
|
||||||
# The helpers shipped beside the bridge (D168, D182). Optional at runtime —
|
# The helpers shipped beside the bridge (D168, D182). Optional at runtime —
|
||||||
# the bridge loads without them — and installed by default.
|
# the bridge loads without them — and installed by default.
|
||||||
HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs
|
HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs overlay/oxide/plugins/RunicGatewayRaids.cs
|
||||||
SEED_VERSION: "0.1.0"
|
SEED_VERSION: "0.1.0"
|
||||||
INSTALLER_REPO: RunicGateway/installer
|
INSTALLER_REPO: RunicGateway/installer
|
||||||
|
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -37,6 +37,7 @@ unauthenticated command channel on the network.
|
|||||||
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
||||||
overlay/oxide/plugins/RunicGatewayZones.cs → <server>/oxide/plugins/RunicGatewayZones.cs
|
overlay/oxide/plugins/RunicGatewayZones.cs → <server>/oxide/plugins/RunicGatewayZones.cs
|
||||||
overlay/oxide/plugins/RunicGatewayDomes.cs → <server>/oxide/plugins/RunicGatewayDomes.cs
|
overlay/oxide/plugins/RunicGatewayDomes.cs → <server>/oxide/plugins/RunicGatewayDomes.cs
|
||||||
|
overlay/oxide/plugins/RunicGatewayRaids.cs → <server>/oxide/plugins/RunicGatewayRaids.cs
|
||||||
```
|
```
|
||||||
|
|
||||||
`RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager
|
`RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager
|
||||||
@@ -52,6 +53,17 @@ helper patches the first with Harmony and tells the bridge when ZoneDomes has st
|
|||||||
domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is
|
domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is
|
||||||
missing. `rgd.status` in the server console says whether it patched.
|
missing. `rgd.status` in the server console says whether it patched.
|
||||||
|
|
||||||
|
`RunicGatewayRaids.cs` is the **RaidableBases helper** (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D204–D207,
|
||||||
|
D387). RaidableBases' API spawns nothing and despawns nothing, so an event's base cannot be the event's from the
|
||||||
|
outside. The helper reaches its internals by reflection and patches its spawn queue, `OpenEvent` and each base's
|
||||||
|
`BypassUseOwners()` with Harmony: it tags the base a run asked for at birth, applies that spawn's own PvP, NPCs,
|
||||||
|
despawn time and lock on a copy of its profile, places one at a spot after checking it, despawns that one base at
|
||||||
|
the run's end, and reloads RaidableBases' profiles when the site edits them. It serves both editions, free and
|
||||||
|
paid, and reads every difficulty, profile and base name from the installed plugin. It is optional: without it the
|
||||||
|
bridge still reports raid bases on the feed and the map, and a step can still ask RaidableBases for a base where it
|
||||||
|
picks, but that base despawns on its own timer, and the site says so. `rgr.status` in the server console says
|
||||||
|
whether it patched, which edition's spawn call it found, and whether the plugin's spawn grid is built.
|
||||||
|
|
||||||
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
|
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -101,6 +113,68 @@ Clans on the website come from Rust's **own** clan system, not from the uMod `Cl
|
|||||||
is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
|
is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
|
||||||
only the game's own clans become Teams on the site.
|
only the game's own clans become Teams on the site.
|
||||||
|
|
||||||
|
Wulf's `Economics` is optional too, and not in `overlay.toml`. Where it is loaded the site can pay
|
||||||
|
coins from an event, show balances and a richest column, and let staff adjust a balance; where it is
|
||||||
|
not, each of those says "Economics is not installed on this server".
|
||||||
|
|
||||||
|
WhiteThunder's `Backpacks` is optional in the same way. Where it is loaded staff can open a player's
|
||||||
|
backpack from the site, a player can see their own, and an event can lend a bigger one for its run;
|
||||||
|
the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own `backpacks.size.N`
|
||||||
|
permissions, pushed like any other grant.
|
||||||
|
|
||||||
|
nivex's `TruePVE` is optional in the same way. Where it is loaded an event's zone can turn off PvP
|
||||||
|
protection inside it, or use one of the operator's own TruePVE rulesets by name; where it is not, the
|
||||||
|
zone step says "TruePVE is not installed on this server". The bridge only ever adds and removes the
|
||||||
|
mappings of zones it opened, and removes each one with its zone, even when TruePVE was away at the time.
|
||||||
|
|
||||||
|
HunterZ's `Dynamic PVP` (which needs ZoneManager and TruePVE) is optional too. Where it is loaded, the
|
||||||
|
PvP zones it opens around a downed helicopter, a Bradley, cargo and the rest reach the site's feed and
|
||||||
|
its map. The bridge only reads them: it never makes, maps or erases one of Dynamic PVP's zones.
|
||||||
|
|
||||||
|
nivex's `Dangerous Treasures` is optional as well. Where it is loaded, its treasure events reach the
|
||||||
|
site's feed, its map and the *Treasure Hunter* title, and an event can open one (`dt.start`) through
|
||||||
|
DT's own `dtevent` console command; DT picks the spot and despawns its own chest. For about ten minutes
|
||||||
|
after DT loads it is still finding its spots, and a start then is answered "not ready yet". A framework
|
||||||
|
that cannot run another plugin's console command from code (`rg.world` says `consolePlugins=False`)
|
||||||
|
cannot open one.
|
||||||
|
|
||||||
|
Bazz3l's `Guarded Crate` is optional too, and needs `NpcSpawn` (a free Codefling download, not shipped
|
||||||
|
here) for its guards. Where both are loaded, a guarded crate landing, being cleared and timing out
|
||||||
|
reach the site's feed, its map and the *Warden* title, and an event can place one (`gc.place`) at a
|
||||||
|
monument or a point through Guarded Crate's own `gcrate position` console command. A placed crate is
|
||||||
|
kept when the event ends: Guarded Crate has no way to end one event alone. Without NpcSpawn a placement
|
||||||
|
is refused, because the crate would land unguarded; and, as for Dangerous Treasures, a framework with
|
||||||
|
`consolePlugins=False` cannot place one.
|
||||||
|
|
||||||
|
nivex's `RaidableBases` is optional, free or paid, and needs `CopyPaste` to paste its bases. Where it is
|
||||||
|
loaded, a base appearing, being raided, bought or locked to a player reach the site's feed, its map's own
|
||||||
|
*Raid bases* layer and the *Warlord* title, and an event can spawn one (`raid.spawn`). With
|
||||||
|
`RunicGatewayRaids.cs` (above) the base is the event's: at a spot or where RaidableBases picks, with its own
|
||||||
|
settings, despawned when the event ends; and the site can edit RaidableBases' profiles from its configuration
|
||||||
|
page, reloading them under watch. For up to ten minutes after RaidableBases loads it builds its spawn grid and
|
||||||
|
refuses every spawn, and a spawn then is answered "not ready". Every difficulty, profile and base name is read
|
||||||
|
from the installed plugin.
|
||||||
|
|
||||||
|
nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is loaded. Its levels are
|
||||||
|
never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an
|
||||||
|
event lends one as it lends any other permission, and it takes effect at once.
|
||||||
|
|
||||||
|
An event can also run console commands an admin typed (`command.run`): once, as a step, or once for each
|
||||||
|
recipient of a reward who is online, with `{steamid}` and `{name}` filled in (the name as one quoted
|
||||||
|
argument). That is how a plugin with no support of its own here is used from an event, for example
|
||||||
|
RaidableBases' `rbe`. There is no allowlist: the site's switchboard is the switch, and `EventsEnabled`
|
||||||
|
here is the server's. Each request runs at most once: its key is written to
|
||||||
|
`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A step's
|
||||||
|
teardown commands are kept with its key too, so the site can have them run even for a step that failed
|
||||||
|
part-way or whose answer was lost (`teardownOf`). A command the
|
||||||
|
server does not know stops the request there as `unknown-command`. On a framework with
|
||||||
|
`consolePlugins=False`, that is every plugin's command.
|
||||||
|
|
||||||
|
Rust's own world events need no plugin at all. The patrol helicopter, Bradley, the cargo ship, the
|
||||||
|
Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea reach the
|
||||||
|
site's feed and its event triggers on every server, and an event can call one of them (`world.call`).
|
||||||
|
What a call starts is kept when the event ends; `EventsMaxWorldCalls` caps how many one run may call.
|
||||||
|
|
||||||
## Diagnosing it
|
## Diagnosing it
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
14
overlay.toml
14
overlay.toml
@@ -26,15 +26,23 @@
|
|||||||
# the same change that alters the emitters, exactly as the sidecar bumps
|
# the same change that alters the emitters, exactly as the sidecar bumps
|
||||||
# PROTOCOL_VERSION and the module bumps its own constant.
|
# PROTOCOL_VERSION and the module bumps its own constant.
|
||||||
#
|
#
|
||||||
# Current: 13 — the transport, the read path, identity, the permission mirror,
|
# Current: 14 — the transport, the read path, identity, the permission mirror,
|
||||||
# configuration from the site, first-party clans, a raid frame that names who
|
# configuration from the site, first-party clans, a raid frame that names who
|
||||||
# lives there, the event leases, the world verbs events place and give back, and
|
# lives there, the event leases, the world verbs events place and give back, and
|
||||||
# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16),
|
# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16),
|
||||||
# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the
|
# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the
|
||||||
# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18),
|
# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18),
|
||||||
# and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration
|
# and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration
|
||||||
# save that answers at once and reports its reload as a `config.outcome` frame.
|
# save that answers at once and reports its reload as a `config.outcome` frame,
|
||||||
protocol = 13
|
# and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward
|
||||||
|
# messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3):
|
||||||
|
# `economics.pay`, `economics.balances`, `economics.adjust`, the tally's
|
||||||
|
# `balance` and the `economics.wiped` event; and Backpacks (PROTOCOL.md §20.4):
|
||||||
|
# `backpacks.read` and hello's `integrations.backpacks.sizes`; and Rust's own world
|
||||||
|
# events (PROTOCOL.md §20.5): `world.event` with its `event`, `world.call`, hello's
|
||||||
|
# `console`, and the tally's `cratesHacked` and `dropsLooted` in place of `apcKills`
|
||||||
|
# and `heliKills`.
|
||||||
|
protocol = 14
|
||||||
|
|
||||||
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1697
overlay/oxide/plugins/RunicGatewayRaids.cs
Normal file
1697
overlay/oxide/plugins/RunicGatewayRaids.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,16 @@ function readChatCommands(source) {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every `[ConsoleCommand("x")]` the plugin declares. */
|
||||||
|
function readConsoleCommands(source) {
|
||||||
|
return [...source.matchAll(/\[ConsoleCommand\("([^"]+)"\)\]/g)].map((m) => m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `rg.*` commands the plugin runs on itself through `ConsoleSystem.Run`. */
|
||||||
|
function readOwnConsoleRuns(source) {
|
||||||
|
return [...source.matchAll(/ConsoleSystem\.Run\([^,]+,\s*"(rg\.[\w.]+)"/g)].map((m) => m[1])
|
||||||
|
}
|
||||||
|
|
||||||
function readMethods(source) {
|
function readMethods(source) {
|
||||||
const found = []
|
const found = []
|
||||||
let m
|
let m
|
||||||
@@ -196,7 +206,20 @@ function check(source, toml) {
|
|||||||
seen.add(cmd.command)
|
seen.add(cmd.command)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. The two declaration sites this repository owns must agree.
|
// 5. Protocol 14 (D363): the console probe runs one of this plugin's own commands
|
||||||
|
// through `ConsoleSystem.Run` to learn whether the framework runs a plugin's
|
||||||
|
// command from code. A probe naming a command the plugin does not declare
|
||||||
|
// answers "no" on every framework, which reads exactly like Carbon's bug.
|
||||||
|
for (const name of readOwnConsoleRuns(source)) {
|
||||||
|
if (!readConsoleCommands(source).includes(name)) {
|
||||||
|
problems.push(
|
||||||
|
`ConsoleSystem.Run runs "${name}", but the plugin declares no [ConsoleCommand("${name}")]: ` +
|
||||||
|
'the console probe would report false on every framework (D363).'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. The two declaration sites this repository owns must agree.
|
||||||
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
|
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
|
||||||
// runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol.
|
// runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol.
|
||||||
const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml)
|
const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml)
|
||||||
@@ -242,6 +265,6 @@ function main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME }
|
module.exports = { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME }
|
||||||
|
|
||||||
if (require.main === module) main()
|
if (require.main === module) main()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const assert = require('node:assert')
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
|
|
||||||
const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin')
|
const { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME } = require('./checkPlugin')
|
||||||
|
|
||||||
/** A minimal plugin that passes, as the baseline every case below deviates from. */
|
/** A minimal plugin that passes, as the baseline every case below deviates from. */
|
||||||
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
|
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
|
||||||
@@ -210,3 +210,55 @@ test('the chat-command parser reads the real plugin, rather than quietly matchin
|
|||||||
assert.ok(names.has('link'), '/link was not found by the chat-command parser')
|
assert.ok(names.has('link'), '/link was not found by the chat-command parser')
|
||||||
assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser')
|
assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── The console probe (protocol 14, D363) ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// The plugin runs its own `rg.probe` through `ConsoleSystem.Run` to learn whether
|
||||||
|
// the framework runs a plugin's command from code. A probe that names a command
|
||||||
|
// the plugin does not declare answers false everywhere, and false is exactly what
|
||||||
|
// Carbon's bug looks like, so nobody would ever notice.
|
||||||
|
|
||||||
|
/** A plugin that runs `ran` on itself and declares `declared`. */
|
||||||
|
function withProbe(ran, declared) {
|
||||||
|
return source({
|
||||||
|
expected: [],
|
||||||
|
methods: ` private void StartWorldEvents()
|
||||||
|
{
|
||||||
|
ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "${ran}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("${declared}")]
|
||||||
|
private void CmdProbe(ConsoleSystem.Arg arg)
|
||||||
|
{
|
||||||
|
}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a probe that runs a command the plugin declares passes', () => {
|
||||||
|
assert.deepEqual(check(withProbe('rg.probe', 'rg.probe'), toml()), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a probe that runs a command the plugin does not declare is caught', () => {
|
||||||
|
const problems = check(withProbe('rg.probe', 'rg.prob'), toml())
|
||||||
|
assert.equal(problems.length, 1)
|
||||||
|
assert.match(problems[0], /ConsoleSystem\.Run runs "rg\.probe", but the plugin declares no \[ConsoleCommand\("rg\.probe"\)\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a native command run through the console is not the probe, and is left alone', () => {
|
||||||
|
const methods = ` private void Call()
|
||||||
|
{
|
||||||
|
ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random");
|
||||||
|
}`
|
||||||
|
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the probe parsers read the real plugin, rather than quietly matching nothing', () => {
|
||||||
|
const real = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ok(readOwnConsoleRuns(real).includes('rg.probe'), 'the console probe was not found by the parser')
|
||||||
|
assert.ok(readConsoleCommands(real).includes('rg.probe'), 'rg.probe was not found among the console commands')
|
||||||
|
assert.ok(readConsoleCommands(real).includes('rg.hooks'), 'rg.hooks was not found among the console commands')
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user