mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Extract plugins from settings.
There isn't really any need for these two types to interact. The lua plugin manager effectively fully owned its properties, it just delegated all reads and writes to the settings object. Instead, break the plugin settings out into the plugin manager and preserve the manager in the Game. This will make it possible to expose plugin options in the NGW without breaking the game on cancel.
This commit is contained in:
parent
664efa3ace
commit
77f1706cbb
@ -117,7 +117,7 @@ class ObjectiveFinder:
|
||||
|
||||
if isinstance(
|
||||
ground_object, IadsBuildingGroundObject
|
||||
) and not self.game.settings.plugin_option("skynetiads"):
|
||||
) and not self.game.lua_plugin_manager.is_plugin_enabled("skynetiads"):
|
||||
# Prevent strike targets on IADS Buildings when skynet features
|
||||
# are disabled as they do not serve any purpose
|
||||
continue
|
||||
|
||||
10
game/game.py
10
game/game.py
@ -97,10 +97,12 @@ class Game:
|
||||
start_date: datetime,
|
||||
start_time: time | None,
|
||||
settings: Settings,
|
||||
lua_plugin_manager: LuaPluginManager,
|
||||
player_budget: float,
|
||||
enemy_budget: float,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.lua_plugin_manager = lua_plugin_manager
|
||||
self.theater = theater
|
||||
self.turn = 0
|
||||
# NB: This is the *start* date. It is never updated.
|
||||
@ -227,7 +229,13 @@ class Game:
|
||||
# We need to persist this state so that names generated after game load don't
|
||||
# conflict with those generated before exit.
|
||||
naming.namegen = self.name_generator
|
||||
LuaPluginManager.load_settings(self.settings)
|
||||
|
||||
# The installed plugins may have changed between runs. We need to load the
|
||||
# current configuration and patch in the options that were previously set.
|
||||
new_plugin_manager = LuaPluginManager.load()
|
||||
new_plugin_manager.update_with(self.lua_plugin_manager)
|
||||
self.lua_plugin_manager = new_plugin_manager
|
||||
|
||||
ObjectiveDistanceCache.set_theater(self.theater)
|
||||
self.compute_unculled_zones(GameUpdateEvents())
|
||||
if not game_still_initializing:
|
||||
|
||||
@ -79,14 +79,18 @@ class FlightGroupConfigurator:
|
||||
if self.flight.flight_type in [
|
||||
FlightType.TRANSPORT,
|
||||
FlightType.AIR_ASSAULT,
|
||||
] and self.game.settings.plugin_option("ctld"):
|
||||
] and self.game.lua_plugin_manager.is_plugin_enabled("ctld"):
|
||||
transfer = None
|
||||
if self.flight.flight_type == FlightType.TRANSPORT:
|
||||
coalition = self.game.coalition_for(player=self.flight.blue)
|
||||
transfer = coalition.transfers.transfer_for_flight(self.flight)
|
||||
self.mission_data.logistics.append(
|
||||
LogisticsGenerator(
|
||||
self.flight, self.group, self.mission, self.game.settings, transfer
|
||||
self.flight,
|
||||
self.group,
|
||||
self.mission,
|
||||
self.game.lua_plugin_manager,
|
||||
transfer,
|
||||
).generate_logistics()
|
||||
)
|
||||
|
||||
|
||||
@ -141,7 +141,7 @@ class FlotGenerator:
|
||||
# If the option fc3LaserCode is enabled, force all JTAC
|
||||
# laser codes to 1113 to allow lasing for Su-25 Frogfoots and A-10A Warthogs.
|
||||
# Otherwise use 1688 for the first JTAC, 1687 for the second etc.
|
||||
if self.game.settings.plugins["plugins.ctld.fc3LaserCode"]:
|
||||
if self.game.lua_plugin_manager.is_option_enabled("ctld", "fc3LaserCode"):
|
||||
code = 1113
|
||||
else:
|
||||
code = self.laser_code_registry.get_next_laser_code()
|
||||
|
||||
@ -8,7 +8,7 @@ from game.ato import Flight
|
||||
from game.ato.flightplans.airassault import AirAssaultFlightPlan
|
||||
from game.ato.flightwaypointtype import FlightWaypointType
|
||||
from game.missiongenerator.missiondata import CargoInfo, LogisticsInfo
|
||||
from game.settings.settings import Settings
|
||||
from game.plugins import LuaPluginManager
|
||||
from game.transfers import TransferOrder
|
||||
|
||||
ZONE_RADIUS = 300
|
||||
@ -21,14 +21,14 @@ class LogisticsGenerator:
|
||||
flight: Flight,
|
||||
group: FlyingGroup[Any],
|
||||
mission: Mission,
|
||||
settings: Settings,
|
||||
lua_plugin_manager: LuaPluginManager,
|
||||
transfer: Optional[TransferOrder] = None,
|
||||
) -> None:
|
||||
self.flight = flight
|
||||
self.group = group
|
||||
self.transfer = transfer
|
||||
self.mission = mission
|
||||
self.settings = settings
|
||||
self.lua_plugin_manager = lua_plugin_manager
|
||||
|
||||
def generate_logistics(self) -> LogisticsInfo:
|
||||
# Add Logisitcs info for the flight
|
||||
@ -89,8 +89,8 @@ class LogisticsGenerator:
|
||||
for cargo_unit_type, amount in self.transfer.units.items()
|
||||
]
|
||||
|
||||
if pickup_point is not None and self.settings.plugin_option(
|
||||
"ctld.logisticunit"
|
||||
if pickup_point is not None and self.lua_plugin_manager.is_option_enabled(
|
||||
"ctld", "logisticunit"
|
||||
):
|
||||
# Spawn logisticsunit at pickup zones
|
||||
country = self.mission.country(self.flight.country)
|
||||
|
||||
@ -13,7 +13,6 @@ from dcs.triggers import TriggerStart
|
||||
|
||||
from game.ato import FlightType
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.plugins import LuaPluginManager
|
||||
from game.theater import TheaterGroundObject
|
||||
from game.theater.iadsnetwork.iadsrole import IadsRole
|
||||
from game.utils import escape_string_for_lua
|
||||
@ -246,7 +245,7 @@ class LuaGenerator:
|
||||
self.mission.triggerrules.triggers.append(trigger)
|
||||
|
||||
def inject_plugins(self) -> None:
|
||||
for plugin in LuaPluginManager.plugins():
|
||||
for plugin in self.game.lua_plugin_manager.iter_plugins():
|
||||
if plugin.enabled:
|
||||
plugin.inject_scripts(self)
|
||||
plugin.inject_configuration(self)
|
||||
|
||||
@ -103,7 +103,7 @@ class GroundObjectGenerator:
|
||||
# Special handling for scenery objects
|
||||
self.add_trigger_zone_for_scenery(unit)
|
||||
if (
|
||||
self.game.settings.plugin_option("skynetiads")
|
||||
self.game.lua_plugin_manager.is_plugin_enabled("skynetiads")
|
||||
and self.game.theater.iads_network.advanced_iads
|
||||
and isinstance(group, IadsGroundGroup)
|
||||
and group.iads_role.participate
|
||||
|
||||
@ -7,8 +7,6 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
from game.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.missiongenerator.luagenerator import LuaGenerator
|
||||
|
||||
@ -50,26 +48,10 @@ class PluginSettings:
|
||||
|
||||
def __init__(self, identifier: str, enabled_by_default: bool) -> None:
|
||||
self.identifier = identifier
|
||||
self.enabled_by_default = enabled_by_default
|
||||
self.settings = Settings()
|
||||
self.initialize_settings()
|
||||
|
||||
def set_settings(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self.initialize_settings()
|
||||
|
||||
def initialize_settings(self) -> None:
|
||||
# Plugin options are saved in the game's Settings, but it's possible for
|
||||
# plugins to change across loads. If new plugins are added or new
|
||||
# options added to those plugins, initialize the new settings.
|
||||
self.settings.initialize_plugin_option(self.identifier, self.enabled_by_default)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.settings.plugin_option(self.identifier)
|
||||
self.enabled = enabled_by_default
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
self.settings.set_plugin_option(self.identifier, enabled)
|
||||
self.enabled = enabled
|
||||
|
||||
|
||||
class LuaPluginOption(PluginSettings):
|
||||
@ -173,6 +155,12 @@ class LuaPlugin(PluginSettings):
|
||||
def options(self) -> List[LuaPluginOption]:
|
||||
return self.definition.options
|
||||
|
||||
def is_option_enabled(self, identifier: str) -> bool:
|
||||
for option in self.options:
|
||||
if option.identifier == identifier:
|
||||
return option.enabled
|
||||
raise KeyError(f"Plugin {self.identifier} has no option {self.identifier}")
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, name: str, path: Path) -> Optional[LuaPlugin]:
|
||||
try:
|
||||
@ -183,12 +171,6 @@ class LuaPlugin(PluginSettings):
|
||||
|
||||
return cls(definition)
|
||||
|
||||
def set_settings(self, settings: Settings) -> None:
|
||||
"""Attaches the plugin to a settings object."""
|
||||
super().set_settings(settings)
|
||||
for option in self.definition.options:
|
||||
option.set_settings(self.settings)
|
||||
|
||||
def inject_scripts(self, lua_generator: LuaGenerator) -> None:
|
||||
"""Injects the plugin's scripts into the mission."""
|
||||
for work_order in self.definition.work_orders:
|
||||
@ -231,3 +213,11 @@ class LuaPlugin(PluginSettings):
|
||||
|
||||
for work_order in self.definition.config_work_orders:
|
||||
work_order.work(lua_generator)
|
||||
|
||||
def update_with(self, other: LuaPlugin) -> None:
|
||||
self.enabled = other.enabled
|
||||
for option in self.options:
|
||||
try:
|
||||
option.enabled = other.is_option_enabled(option.identifier)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from game.settings import Settings
|
||||
from .luaplugin import LuaPlugin
|
||||
|
||||
|
||||
class LuaPluginManager:
|
||||
"""Manages available and loaded lua plugins."""
|
||||
|
||||
_plugins_loaded = False
|
||||
_plugins: Dict[str, LuaPlugin] = {}
|
||||
def __init__(self, plugins: dict[str, LuaPlugin]) -> None:
|
||||
self._plugins: dict[str, LuaPlugin] = plugins
|
||||
|
||||
@classmethod
|
||||
def _load_plugins(cls) -> None:
|
||||
@staticmethod
|
||||
def load() -> LuaPluginManager:
|
||||
plugins_path = Path("resources/plugins")
|
||||
|
||||
path = plugins_path / "plugins.json"
|
||||
@ -23,6 +24,7 @@ class LuaPluginManager:
|
||||
|
||||
logging.info(f"Reading plugins list from {path}")
|
||||
|
||||
plugins = {}
|
||||
data = json.loads(path.read_text())
|
||||
for name in data:
|
||||
plugin_path = plugins_path / name / "plugin.json"
|
||||
@ -34,27 +36,41 @@ class LuaPluginManager:
|
||||
logging.info(f"Loading plugin {name} from {plugin_path}")
|
||||
plugin = LuaPlugin.from_json(name, plugin_path)
|
||||
if plugin is not None:
|
||||
cls._plugins[name] = plugin
|
||||
cls._plugins_loaded = True
|
||||
plugins[name] = plugin
|
||||
return LuaPluginManager(plugins)
|
||||
|
||||
@classmethod
|
||||
def _get_plugins(cls) -> Dict[str, LuaPlugin]:
|
||||
if not cls._plugins_loaded:
|
||||
cls._load_plugins()
|
||||
return cls._plugins
|
||||
def update_with(self, other: LuaPluginManager) -> None:
|
||||
"""Updates all setting values with those in the given plugin manager.
|
||||
|
||||
@classmethod
|
||||
def plugins(cls) -> List[LuaPlugin]:
|
||||
return list(cls._get_plugins().values())
|
||||
When a game is loaded, LuaPluginManager.load() is called to load the latest set
|
||||
of plugins and settings. This is called with the plugin manager that was saved
|
||||
to the Game object to preserve any options that were set, and then the Game is
|
||||
updated with this manager.
|
||||
|
||||
@classmethod
|
||||
def load_settings(cls, settings: Settings) -> None:
|
||||
"""Attaches all loaded plugins to the given settings object.
|
||||
|
||||
The LuaPluginManager singleton can only be attached to a single Settings object
|
||||
at a time, and plugins will update the Settings object directly, so attaching
|
||||
the plugin manager to a detached Settings object (say, during the new game
|
||||
wizard, but then canceling the new game) will break the settings UI.
|
||||
This needs to happen because the set of available plugins (or their options) can
|
||||
change between runs.
|
||||
"""
|
||||
for plugin in cls.plugins():
|
||||
plugin.set_settings(settings)
|
||||
for plugin in self.iter_plugins():
|
||||
try:
|
||||
old_plugin = other.by_id(plugin.identifier)
|
||||
except KeyError:
|
||||
continue
|
||||
plugin.update_with(old_plugin)
|
||||
|
||||
def iter_plugins(self) -> Iterator[LuaPlugin]:
|
||||
yield from self._plugins.values()
|
||||
|
||||
def by_id(self, identifier: str) -> LuaPlugin:
|
||||
return self._plugins[identifier]
|
||||
|
||||
def is_plugin_enabled(self, plugin_id: str) -> bool:
|
||||
try:
|
||||
return self.by_id(plugin_id).enabled
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def is_option_enabled(self, plugin_id: str, option_id: str) -> bool:
|
||||
try:
|
||||
return self.by_id(plugin_id).is_option_enabled(option_id)
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import Field, dataclass, field, fields
|
||||
from dataclasses import Field, dataclass, fields
|
||||
from datetime import timedelta
|
||||
from enum import Enum, unique
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, get_type_hints
|
||||
from typing import Any, Optional, get_type_hints
|
||||
|
||||
import yaml
|
||||
from dcs.forcedoptions import ForcedOptions
|
||||
@ -490,9 +490,6 @@ class Settings:
|
||||
enable_frontline_cheats: bool = False
|
||||
enable_base_capture_cheat: bool = False
|
||||
|
||||
# LUA Plugins system
|
||||
plugins: Dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
only_player_takeoff: bool = True # Legacy parameter do not use
|
||||
|
||||
def save_player_settings(self) -> None:
|
||||
@ -548,22 +545,6 @@ class Settings:
|
||||
"""Returns the path to the player's global settings file."""
|
||||
return liberation_user_dir() / "settings.yaml"
|
||||
|
||||
@staticmethod
|
||||
def plugin_settings_key(identifier: str) -> str:
|
||||
return f"plugins.{identifier}"
|
||||
|
||||
def initialize_plugin_option(self, identifier: str, default_value: bool) -> None:
|
||||
try:
|
||||
self.plugin_option(identifier)
|
||||
except KeyError:
|
||||
self.set_plugin_option(identifier, default_value)
|
||||
|
||||
def plugin_option(self, identifier: str) -> bool:
|
||||
return self.plugins[self.plugin_settings_key(identifier)]
|
||||
|
||||
def set_plugin_option(self, identifier: str, enabled: bool) -> None:
|
||||
self.plugins[self.plugin_settings_key(identifier)] = enabled
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# __setstate__ is called with the dict of the object being unpickled. We
|
||||
# can provide save compatibility for new settings options (which
|
||||
|
||||
@ -31,6 +31,7 @@ from ..armedforces.armedforces import ArmedForces
|
||||
from ..armedforces.forcegroup import ForceGroup
|
||||
from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig
|
||||
from ..data.groups import GroupTask
|
||||
from ..plugins import LuaPluginManager
|
||||
from ..profiling import logged_duration
|
||||
from ..settings import Settings
|
||||
|
||||
@ -97,6 +98,8 @@ class GameGenerator:
|
||||
start_date=self.generator_settings.start_date,
|
||||
start_time=self.generator_settings.start_time,
|
||||
settings=self.settings,
|
||||
# TODO: Hoist into NGW so we can expose those options.
|
||||
lua_plugin_manager=LuaPluginManager.load(),
|
||||
player_budget=self.generator_settings.player_budget,
|
||||
enemy_budget=self.generator_settings.enemy_budget,
|
||||
)
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"""Combo box for selecting a flight's task type."""
|
||||
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.settings.settings import Settings
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.plugins import LuaPluginManager
|
||||
from game.theater import ConflictTheater, MissionTarget
|
||||
|
||||
|
||||
@ -11,14 +11,18 @@ class QFlightTypeComboBox(QComboBox):
|
||||
"""Combo box for selecting a flight task type."""
|
||||
|
||||
def __init__(
|
||||
self, theater: ConflictTheater, target: MissionTarget, settings: Settings
|
||||
self,
|
||||
theater: ConflictTheater,
|
||||
target: MissionTarget,
|
||||
lua_plugin_manager: LuaPluginManager,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.theater = theater
|
||||
self.target = target
|
||||
for mission_type in self.target.mission_types(for_player=True):
|
||||
if mission_type == FlightType.AIR_ASSAULT and not settings.plugin_option(
|
||||
"ctld"
|
||||
if (
|
||||
mission_type == FlightType.AIR_ASSAULT
|
||||
and not lua_plugin_manager.is_plugin_enabled("ctld")
|
||||
):
|
||||
# Only add Air Assault if ctld plugin is enabled
|
||||
continue
|
||||
|
||||
@ -4,22 +4,22 @@ from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QLineEdit,
|
||||
QHBoxLayout,
|
||||
)
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game import Game
|
||||
from game.ato.flight import Flight
|
||||
from game.ato.flightroster import FlightRoster
|
||||
from game.ato.package import Package
|
||||
from game.ato.starttype import StartType
|
||||
from game.squadrons.squadron import Squadron
|
||||
from game.theater import ControlPoint, OffMapSpawn
|
||||
from game.ato.package import Package
|
||||
from game.ato.flightroster import FlightRoster
|
||||
from game.ato.flight import Flight
|
||||
from qt_ui.uiconstants import EVENT_ICONS
|
||||
from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner
|
||||
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
|
||||
@ -51,7 +51,7 @@ class QFlightCreator(QDialog):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
self.task_selector = QFlightTypeComboBox(
|
||||
self.game.theater, package.target, self.game.settings
|
||||
self.game.theater, package.target, self.game.lua_plugin_manager
|
||||
)
|
||||
self.task_selector.setCurrentIndex(0)
|
||||
self.task_selector.currentIndexChanged.connect(self.on_task_changed)
|
||||
|
||||
@ -61,8 +61,9 @@ class QFlightWaypointTab(QFrame):
|
||||
self.recreate_buttons.clear()
|
||||
for task in self.package.target.mission_types(for_player=True):
|
||||
|
||||
if task == FlightType.AIR_ASSAULT and not self.game.settings.plugin_option(
|
||||
"ctld"
|
||||
if (
|
||||
task == FlightType.AIR_ASSAULT
|
||||
and not self.game.lua_plugin_manager.is_plugin_enabled("ctld")
|
||||
):
|
||||
# Only add Air Assault if ctld plugin is enabled
|
||||
continue
|
||||
|
||||
@ -285,7 +285,7 @@ class QSettingsWindow(QDialog):
|
||||
self.categoryModel.appendRow(cheat)
|
||||
self.right_layout.addWidget(self.cheatPage)
|
||||
|
||||
self.pluginsPage = PluginsPage()
|
||||
self.pluginsPage = PluginsPage(self.game.lua_plugin_manager)
|
||||
plugins = QStandardItem("LUA Plugins")
|
||||
plugins.setIcon(CONST.ICONS["Plugins"])
|
||||
plugins.setEditable(False)
|
||||
@ -293,7 +293,7 @@ class QSettingsWindow(QDialog):
|
||||
self.categoryModel.appendRow(plugins)
|
||||
self.right_layout.addWidget(self.pluginsPage)
|
||||
|
||||
self.pluginsOptionsPage = PluginOptionsPage()
|
||||
self.pluginsOptionsPage = PluginOptionsPage(self.game.lua_plugin_manager)
|
||||
pluginsOptions = QStandardItem("LUA Plugins Options")
|
||||
pluginsOptions.setIcon(CONST.ICONS["PluginsOptions"])
|
||||
pluginsOptions.setEditable(False)
|
||||
|
||||
@ -12,14 +12,14 @@ from game.plugins import LuaPlugin, LuaPluginManager
|
||||
|
||||
|
||||
class PluginsBox(QGroupBox):
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, manager: LuaPluginManager) -> None:
|
||||
super().__init__("Plugins")
|
||||
|
||||
layout = QGridLayout()
|
||||
layout.setAlignment(Qt.AlignTop)
|
||||
self.setLayout(layout)
|
||||
|
||||
for row, plugin in enumerate(LuaPluginManager.plugins()):
|
||||
for row, plugin in enumerate(manager.iter_plugins()):
|
||||
if not plugin.show_in_ui:
|
||||
continue
|
||||
|
||||
@ -32,14 +32,14 @@ class PluginsBox(QGroupBox):
|
||||
|
||||
|
||||
class PluginsPage(QWidget):
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, manager: LuaPluginManager) -> None:
|
||||
super().__init__()
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setAlignment(Qt.AlignTop)
|
||||
self.setLayout(layout)
|
||||
|
||||
layout.addWidget(PluginsBox())
|
||||
layout.addWidget(PluginsBox(manager))
|
||||
|
||||
|
||||
class PluginOptionsBox(QGroupBox):
|
||||
@ -60,13 +60,13 @@ class PluginOptionsBox(QGroupBox):
|
||||
|
||||
|
||||
class PluginOptionsPage(QWidget):
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, manager: LuaPluginManager) -> None:
|
||||
super().__init__()
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setAlignment(Qt.AlignTop)
|
||||
self.setLayout(layout)
|
||||
|
||||
for plugin in LuaPluginManager.plugins():
|
||||
for plugin in manager.iter_plugins():
|
||||
if plugin.options:
|
||||
layout.addWidget(PluginOptionsBox(plugin))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user