DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,419 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||||
dnfmonster "longheng.io/server/internal/modules/dnf/monster"
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||||
)
|
||||
|
||||
func TestCurrentDungeonAltarScriptedClearMapPhaseARetriesExactlyOnceOnDuplicateDeath(t *testing.T) {
|
||||
service, session, runtime, repositories := prepareCurrentDungeonAltarSettlementStageTest(t)
|
||||
queue := newFakeCurrentDungeonDeathTimerQueue()
|
||||
service.gameplayTimers = queue
|
||||
service.questCatalog = currentDungeonAltarChallengeQuestTestCatalog(t, 120, 11007)
|
||||
runtime.altar.progress = currentDungeonAltarProgressDungeonComplete
|
||||
runtime.clearMapCompletionPhaseAPersisted = false
|
||||
runtime.clearMapCompletionKey = ""
|
||||
runtime.clearMapCompletionAt = time.Time{}
|
||||
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
||||
CharacterID: "99",
|
||||
States: map[int64]dnfrepo.QuestState{
|
||||
120: {Status: "active", ProgressValue: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("scripted altar clear-map save failed")
|
||||
questStore := &failOnceCurrentDungeonAltarQuestStore{
|
||||
QuestRepository: repositories.Quest,
|
||||
failErr: wantErr,
|
||||
failures: 1,
|
||||
}
|
||||
repositories.Quest = questStore
|
||||
service.repositoryProvider = func() (dnfrepo.Group, bool) { return repositories, true }
|
||||
|
||||
session.dungeon.mu.Lock()
|
||||
if err := service.completeCurrentDungeonAltarLocked(session, runtime, "test_initial_final_op255"); err != nil {
|
||||
session.dungeon.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
if runtime.clearMapCompletionPhaseAPersisted || runtime.altar.settlement.phase != currentDungeonAltarSettlementPrepared {
|
||||
t.Fatalf("failed phase A advanced runtime: persisted=%t settlement_phase=%d",
|
||||
runtime.clearMapCompletionPhaseAPersisted, runtime.altar.settlement.phase)
|
||||
}
|
||||
if scheduled, _, active := queue.counts(); scheduled != 1 || active != 1 {
|
||||
t.Fatalf("phase-A retry queue scheduled=%d active=%d", scheduled, active)
|
||||
}
|
||||
completionKey := runtime.clearMapCompletionKey
|
||||
completionAt := runtime.clearMapCompletionAt
|
||||
if completionKey == "" || completionAt.IsZero() {
|
||||
t.Fatalf("failed phase A did not freeze identity key=%q at=%v", completionKey, completionAt)
|
||||
}
|
||||
|
||||
const duplicateSequence = uint16(60000)
|
||||
runtime.altar.retiredSequences[duplicateSequence] = currentDungeonAltarSequenceOwner{
|
||||
RoomGeneration: runtime.altar.Room.Generation,
|
||||
RoundGeneration: runtime.altar.generation,
|
||||
RoomKey: runtime.altar.Room.Key,
|
||||
MonsterCode: 56004,
|
||||
QuestDropSeed: 1,
|
||||
}
|
||||
if err := service.handleCurrentDungeonAltarDeaths(session, []byte{
|
||||
1,
|
||||
byte(duplicateSequence & 0xff),
|
||||
byte(duplicateSequence >> 8),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !runtime.clearMapCompletionPhaseAPersisted || runtime.clearMapCompletionKey != completionKey ||
|
||||
!runtime.clearMapCompletionAt.Equal(completionAt) {
|
||||
t.Fatalf("phase-A retry identity changed: persisted=%t key=%q at=%v",
|
||||
runtime.clearMapCompletionPhaseAPersisted, runtime.clearMapCompletionKey, runtime.clearMapCompletionAt)
|
||||
}
|
||||
if !completedCurrentDungeonAltarSettlementExitReady(runtime, session.selectedCharacterID) {
|
||||
t.Fatalf("duplicate op255 did not resume settlement: %+v", runtime.altar.settlement)
|
||||
}
|
||||
questRecord, found, err := repositories.Quest.Load(context.Background(), "99")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load challenge quest found=%t err=%v", found, err)
|
||||
}
|
||||
state := questRecord.States[120]
|
||||
if state.ProgressValue != 0 || len(runtime.clearMapCompletionQuestIDs) != 1 || runtime.clearMapCompletionQuestIDs[0] != 120 {
|
||||
t.Fatalf("challenge_120 state=%+v completions=%v", state, runtime.clearMapCompletionQuestIDs)
|
||||
}
|
||||
if questStore.saves.Load() != 2 {
|
||||
t.Fatalf("phase-A saves=%d want failed+successful", questStore.saves.Load())
|
||||
}
|
||||
if _, cancelled, active := queue.counts(); cancelled != 1 || active != 0 {
|
||||
t.Fatalf("duplicate retry did not retire timer: cancelled=%d active=%d", cancelled, active)
|
||||
}
|
||||
activeSnapshots := 0
|
||||
stream := session.conn.(*bufferConn).write.Bytes()
|
||||
for len(stream) != 0 {
|
||||
packet, rest := splitGameServerUpperPacket(t, stream)
|
||||
if packet.Header.MsgID == currentActiveQuestSnapshotMsgID {
|
||||
activeSnapshots++
|
||||
}
|
||||
stream = rest
|
||||
}
|
||||
if activeSnapshots != 1 {
|
||||
t.Fatalf("altar clear-map active snapshots=%d want=1", activeSnapshots)
|
||||
}
|
||||
|
||||
bytesBeforeStale := session.conn.(*bufferConn).write.Len()
|
||||
queue.fire(queue.task(t, 0), true)
|
||||
if session.conn.(*bufferConn).write.Len() != bytesBeforeStale || questStore.saves.Load() != 2 {
|
||||
t.Fatalf("stale retry replayed projection or persistence: bytes=%d->%d saves=%d",
|
||||
bytesBeforeStale, session.conn.(*bufferConn).write.Len(), questStore.saves.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonAltarClearMapNotificationFailureRetriesBeforeSettlement(t *testing.T) {
|
||||
service, session, runtime, repositories := prepareCurrentDungeonAltarSettlementStageTest(t)
|
||||
queue := newFakeCurrentDungeonDeathTimerQueue()
|
||||
service.gameplayTimers = queue
|
||||
service.questCatalog = currentDungeonAltarChallengeQuestTestCatalog(t, 120, 11007)
|
||||
runtime.altar.progress = currentDungeonAltarProgressDungeonComplete
|
||||
runtime.clearMapCompletionPhaseAPersisted = false
|
||||
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
||||
CharacterID: "99",
|
||||
States: map[int64]dnfrepo.QuestState{
|
||||
120: {Status: "active", ProgressValue: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantWriteErr := errors.New("altar op574 write failed")
|
||||
session.conn = &failNthDungeonWriteConn{failAt: 1, err: wantWriteErr}
|
||||
|
||||
session.dungeon.mu.Lock()
|
||||
if err := service.completeCurrentDungeonAltarLocked(session, runtime, "test_op574_failure"); err != nil {
|
||||
session.dungeon.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
if !runtime.clearMapCompletionPhaseAPersisted || runtime.clearMapCompletionActiveSnapshotSent ||
|
||||
runtime.clearMapCompletionNotificationClosed || runtime.altar.settlement.phase != currentDungeonAltarSettlementPrepared {
|
||||
t.Fatalf("failed notification advanced stage: phase_a=%t snapshot=%t closed=%t settlement=%d",
|
||||
runtime.clearMapCompletionPhaseAPersisted, runtime.clearMapCompletionActiveSnapshotSent,
|
||||
runtime.clearMapCompletionNotificationClosed, runtime.altar.settlement.phase)
|
||||
}
|
||||
if scheduled, _, active := queue.counts(); scheduled != 1 || active != 1 {
|
||||
t.Fatalf("notification retry queue scheduled=%d active=%d", scheduled, active)
|
||||
}
|
||||
|
||||
retryConn := &bufferConn{}
|
||||
session.conn = retryConn
|
||||
task := queue.task(t, 0)
|
||||
queue.fire(task, false)
|
||||
if !runtime.clearMapCompletionNotificationClosed || !completedCurrentDungeonAltarSettlementExitReady(runtime, session.selectedCharacterID) {
|
||||
t.Fatalf("notification retry did not finish: closed=%t settlement=%+v",
|
||||
runtime.clearMapCompletionNotificationClosed, runtime.altar.settlement)
|
||||
}
|
||||
activeSnapshots := 0
|
||||
stream := retryConn.write.Bytes()
|
||||
for len(stream) != 0 {
|
||||
packet, rest := splitGameServerUpperPacket(t, stream)
|
||||
if packet.Header.MsgID == currentActiveQuestSnapshotMsgID {
|
||||
activeSnapshots++
|
||||
}
|
||||
stream = rest
|
||||
}
|
||||
if activeSnapshots != 1 {
|
||||
t.Fatalf("notification retry active snapshots=%d want=1", activeSnapshots)
|
||||
}
|
||||
bytesBeforeStale := retryConn.write.Len()
|
||||
queue.fire(task, true)
|
||||
if retryConn.write.Len() != bytesBeforeStale {
|
||||
t.Fatalf("stale notification retry resent packets: bytes=%d->%d", bytesBeforeStale, retryConn.write.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonScriptedAltarPhaseAMatchesBothChallengeQuests(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
questID int64
|
||||
dungeonID int64
|
||||
prepare func(*testing.T) (*Service, *gameSession, *runtimeDungeonState)
|
||||
}{
|
||||
{
|
||||
name: "challenge_119_endless",
|
||||
questID: 119, dungeonID: 11006,
|
||||
prepare: prepareCurrentDungeonEndlessAltarCompletionTest,
|
||||
},
|
||||
{
|
||||
name: "challenge_120_ultimate",
|
||||
questID: 120, dungeonID: 11007,
|
||||
prepare: func(t *testing.T) (*Service, *gameSession, *runtimeDungeonState) {
|
||||
service, session, runtime, _, _ := prepareCurrentDungeonAltarRuntimeTest(t)
|
||||
return service, session, runtime
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service, session, runtime := test.prepare(t)
|
||||
repositories, ok := service.repositoryGroup()
|
||||
if !ok {
|
||||
t.Fatal("repository group unavailable")
|
||||
}
|
||||
service.questCatalog = currentDungeonAltarChallengeQuestTestCatalog(t, test.questID, test.dungeonID)
|
||||
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
||||
CharacterID: "99",
|
||||
States: map[int64]dnfrepo.QuestState{
|
||||
test.questID: {Status: "active", ProgressValue: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counter := &failOnceCurrentDungeonAltarQuestStore{QuestRepository: repositories.Quest}
|
||||
repositories.Quest = counter
|
||||
service.repositoryProvider = func() (dnfrepo.Group, bool) { return repositories, true }
|
||||
if err := runtime.Session.MarkCurrentRoomScriptedCleared(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.Session.Complete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime.altar.completedRounds = runtime.altar.MaxRounds
|
||||
runtime.altar.progress = currentDungeonAltarProgressDungeonComplete
|
||||
|
||||
session.dungeon.mu.Lock()
|
||||
snapshot := runtime.Session.Snapshot()
|
||||
err := service.persistCurrentDungeonScriptedAltarClearMapCompletionPhaseA(
|
||||
context.Background(), session, runtime, snapshot,
|
||||
)
|
||||
if err == nil {
|
||||
err = service.persistCurrentDungeonScriptedAltarClearMapCompletionPhaseA(
|
||||
context.Background(), session, runtime, snapshot,
|
||||
)
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, found, err := repositories.Quest.Load(context.Background(), "99")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load quest found=%t err=%v", found, err)
|
||||
}
|
||||
if state := record.States[test.questID]; state.ProgressValue != 0 || counter.saves.Load() != 1 ||
|
||||
len(runtime.clearMapCompletionQuestIDs) != 1 || runtime.clearMapCompletionQuestIDs[0] != test.questID {
|
||||
t.Fatalf("quest=%d state=%+v saves=%d completions=%v",
|
||||
test.questID, state, counter.saves.Load(), runtime.clearMapCompletionQuestIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDungeonAltarSettlementFailureRetriesAutonomouslyAndStaleTimerNoOps(t *testing.T) {
|
||||
service, session, runtime, _ := prepareCurrentDungeonAltarSettlementStageTest(t)
|
||||
queue := newFakeCurrentDungeonDeathTimerQueue()
|
||||
service.gameplayTimers = queue
|
||||
runtime.altar.progress = currentDungeonAltarProgressDungeonComplete
|
||||
runtime.clearMapCompletionPhaseAPersisted = true
|
||||
roundsBefore := runtime.altar.completedRounds
|
||||
wantWriteErr := errors.New("altar op208 write failed")
|
||||
session.conn = &failNthDungeonWriteConn{failAt: 1, err: wantWriteErr}
|
||||
|
||||
session.dungeon.mu.Lock()
|
||||
if err := service.completeCurrentDungeonAltarLocked(session, runtime, "test_op208_failure"); err != nil {
|
||||
session.dungeon.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.dungeon.mu.Unlock()
|
||||
if runtime.altar.settlement.phase != currentDungeonAltarSettlementPrepared {
|
||||
t.Fatalf("failed op208 advanced settlement phase=%d", runtime.altar.settlement.phase)
|
||||
}
|
||||
if scheduled, _, active := queue.counts(); scheduled != 1 || active != 1 {
|
||||
t.Fatalf("settlement retry queue scheduled=%d active=%d", scheduled, active)
|
||||
}
|
||||
task := queue.task(t, 0)
|
||||
retryConn := &bufferConn{}
|
||||
session.conn = retryConn
|
||||
queue.fire(task, false)
|
||||
if !completedCurrentDungeonAltarSettlementExitReady(runtime, session.selectedCharacterID) {
|
||||
t.Fatalf("timer retry did not finish settlement: %+v", runtime.altar.settlement)
|
||||
}
|
||||
if runtime.altar.completedRounds != roundsBefore {
|
||||
t.Fatalf("timer retry replayed rounds=%d want=%d", runtime.altar.completedRounds, roundsBefore)
|
||||
}
|
||||
ranking, reward := 0, 0
|
||||
stream := retryConn.write.Bytes()
|
||||
for len(stream) != 0 {
|
||||
packet, rest := splitGameServerUpperPacket(t, stream)
|
||||
switch packet.Header.MsgID {
|
||||
case currentDungeonAltarRankingMsgID:
|
||||
ranking++
|
||||
case currentDungeonAltarRewardMsgID:
|
||||
reward++
|
||||
}
|
||||
stream = rest
|
||||
}
|
||||
if ranking != 1 || reward != 1 {
|
||||
t.Fatalf("retry altar UI ranking=%d reward=%d", ranking, reward)
|
||||
}
|
||||
bytesBeforeStale := retryConn.write.Len()
|
||||
queue.fire(task, true)
|
||||
if retryConn.write.Len() != bytesBeforeStale || runtime.altar.completedRounds != roundsBefore {
|
||||
t.Fatalf("stale completion timer mutated state: bytes=%d->%d rounds=%d",
|
||||
bytesBeforeStale, retryConn.write.Len(), runtime.altar.completedRounds)
|
||||
}
|
||||
}
|
||||
|
||||
func currentDungeonAltarChallengeQuestTestCatalog(t *testing.T, questID int64, dungeonID int64) *dnfquest.Catalog {
|
||||
t.Helper()
|
||||
source := bridgePVFSource{
|
||||
dnfquest.DefaultList: fmt.Sprintf("%d `challenge.qst`\n", questID),
|
||||
"n_quest/challenge.qst": "[grade]\n`[challenge]`\n" +
|
||||
"[type]\n`[condition under clear]`\n" +
|
||||
"[sub type]\n6\n" +
|
||||
fmt.Sprintf("[int data]\n%d -1 1\n", dungeonID),
|
||||
}
|
||||
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := dnfquest.Load(context.Background(), index)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func prepareCurrentDungeonEndlessAltarCompletionTest(
|
||||
t *testing.T,
|
||||
) (*Service, *gameSession, *runtimeDungeonState) {
|
||||
t.Helper()
|
||||
source := currentDungeonEndlessAltarCompletionPVFTestSource()
|
||||
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "account-1",
|
||||
Level: 70,
|
||||
Stats: map[string]int64{"fatigue": 100},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
worldMapTable: table,
|
||||
worldMapResolver: resolver,
|
||||
dungeonMonsterTable: monsters,
|
||||
dungeonChoice: func(int) (int, error) { return 0, nil },
|
||||
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
}
|
||||
session := &gameSession{
|
||||
conn: &bufferConn{},
|
||||
connID: "endless-altar-completion-test",
|
||||
selectedCharacterID: 99,
|
||||
characterGeneration: 1,
|
||||
}
|
||||
runtime, _, err := service.prepareDungeonRuntime(
|
||||
context.Background(),
|
||||
session,
|
||||
dungeoncmd.SelectDungeonRequest{DungeonID: 11006, Difficulty: 1},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime.lifecycleToken = 1
|
||||
session.dungeon.runtime = runtime
|
||||
return service, session, runtime
|
||||
}
|
||||
|
||||
func currentDungeonEndlessAltarCompletionPVFTestSource() bridgePVFSource {
|
||||
phaseRows := ""
|
||||
for round := 0; round < 5; round++ {
|
||||
phaseRows += fmt.Sprintf("%d 0 0 1 0 0\n", round)
|
||||
}
|
||||
return bridgePVFSource{
|
||||
worldmap.DefaultMapList: "17020 `dungeon/altar/endless.map`\n",
|
||||
"map/dungeon/altar/endless.map": "[map name]\n`endless`\n" +
|
||||
"[dungeon]\n11006\n" +
|
||||
"[type]\n`[start]`\n" +
|
||||
"[blood monster]\n61010 0 440 222 0 0 0 1 0 1\n[/blood monster]\n" +
|
||||
"[blood phase time]\n" + phaseRows + "[/blood phase time]\n",
|
||||
worldmap.DefaultDungeonList: "11006 `altar/endless.dgn`\n",
|
||||
"dungeon/altar/endless.dgn": "[name]\n`Endless Altar`\n" +
|
||||
"[minimum required level]\n1\n[basis level]\n70\n[no fatigue]\n" +
|
||||
"[blood dungeon]\n1\n[blood max round]\n15\n" +
|
||||
"[maze info]\n[size]\n1 1\n[greed]\n`A`\n" +
|
||||
"[map specification]\n`map` 0 0 17020\n[start map]\n0 0\n[boss map]\n0 0\n",
|
||||
worldmap.DefaultWorldMapList: "1 `altar.wdm`\n",
|
||||
"worldmap/altar.wdm": "[name]\n`Altar`\n[dungeon]\n11006 -1\n[/dungeon]\n",
|
||||
dnfmonster.DefaultList: "1 `dummy.gob`\n",
|
||||
"monster/dummy.gob": "[name]\n`dummy`\n[level]\n1\n[hp]\n1\n[exp]\n0\n",
|
||||
}
|
||||
}
|
||||
|
||||
type failOnceCurrentDungeonAltarQuestStore struct {
|
||||
dnfrepo.QuestRepository
|
||||
failErr error
|
||||
failures int64
|
||||
saves atomic.Int64
|
||||
}
|
||||
|
||||
func (store *failOnceCurrentDungeonAltarQuestStore) Save(ctx context.Context, record dnfrepo.QuestRecord) error {
|
||||
store.saves.Add(1)
|
||||
if store.failures > 0 {
|
||||
store.failures--
|
||||
return store.failErr
|
||||
}
|
||||
return store.QuestRepository.Save(ctx, record)
|
||||
}
|
||||
|
||||
var _ dnfrepo.QuestRepository = (*failOnceCurrentDungeonAltarQuestStore)(nil)
|
||||
Reference in new issue
Block a user