mirror of
https://github.com/motajs/template.git
synced 2026-10-09 22:10:23 +08:00
test(06-01): stage 3 integration coverage for combat, damage and map damage
- add D-26 attribute-to-damage linkage and getCalculator coverage in damage.test.ts - cover markEnemyDirty refresh and unregistered delete in mapDamage.test.ts - cover off-map handler and standalone computed enemy in combat.test.ts - register suspected buildup compounding bug as #06-01-4 it.skip with a findings entry - complete the 06-COVERAGE-MAP.md code rows for 106/107/102/103/104/138/139/140/141
This commit is contained in:
parent
f536901733
commit
a9b6e7290c
@ -12,6 +12,15 @@
|
||||
| 100 | combat/context.ts | `warns 100 when both add and delete are non-empty` | 06-01 |
|
||||
| 101 | combat/context.ts | `warns 101 when a local refresh removes a converted special`、`warns 101 when a local refresh adds a converted special` | 06-01 |
|
||||
| 110 | combat/context.ts | `warns 110 and skips buildup without a bound hero` | 06-01 |
|
||||
| 106 | combat/damage.ts | `warns 106 when the calculator is missing` | 06-01 |
|
||||
| 107 | combat/damage.ts | `warns 107 when hero status is missing` | 06-01 |
|
||||
| 102 | combat/mapDamage.ts | `warns 102 when the converter is missing during a full refresh` | 06-01 |
|
||||
| 103 | combat/mapDamage.ts | `warns 103 when the reducer is missing` | 06-01 |
|
||||
| 104 | combat/mapDamage.ts | `warns 104 when marking an unregistered enemy dirty` | 06-01 |
|
||||
| 138 | combat/combat.ts | `warns 138 for collaborators bound to a foreign state` | 06-01 |
|
||||
| 139 | combat/combat.ts | `warns 139 when a required collaborator is missing` | 06-01 |
|
||||
| 140 | combat/combat.ts | `sorts scripts by descending priority and rejects duplicates` | 06-01 |
|
||||
| 141 | combat/combat.ts | `warns 141 when the damage context cannot produce damage info` | 06-01 |
|
||||
|
||||
## 06-01 战斗系统(packages-user/data-system/src/combat)
|
||||
|
||||
@ -21,4 +30,5 @@
|
||||
138 / 139 / 140 / 141 → `combat/combat.ts`(`CombatFlow`)。105 在当前实现不可达,排除。
|
||||
|
||||
阶段 1(构件级)不产生 warn/error 码断言。阶段 2(组合/流水线)已完成 97/98/99/100/101/110;
|
||||
106/107/102/103/104/138/139/140/141 对应的具体用例名将在阶段 3 完成后补全。
|
||||
阶段 3(完整/集成)已完成 106/107/102/103/104/138/139/140/141。105 在当前实现不可达,排除。
|
||||
`#06-01-1..4` 为疑似缺陷的 `it.skip` 正确预期用例,详见 `06-TEST-FINDINGS.md`。
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
| `damage.ts` `DamageContext.calculateCritical` / `findNextCritical` | 产出的临界点 `info` 与 `nextValue` 不对应 | 假计算器 `damage = 100 - atk * 10`、`getCriticalLimit = 10`、当前 `atk = 0`;首个临界点 `nextValue = 1`(正确伤害应为 90),但 `info.damage = 0`、`damageDiff = -100` | `findNextCritical` 在 `middleInfo.damage < referenceDamage` 分支只更新 `right`,从不更新 `targetInfo`,`targetInfo` 一直停留在初始 `upperLimit` 处的计算结果 | 临界点查询(数值提示、战斗模拟)展示的伤害与对应属性值不匹配 | 在 `middleInfo.damage < referenceDamage` 分支同步 `targetInfo = middleInfo`,或二分结束后按最终 `right` 重新计算一次 | `damage.test.ts` `reports the damage info matching the yielded critical value`(#06-01-1) | 中 |
|
||||
| `mapDamage.ts` `MapDamage.deleteEnemy` | 删除怪物后,该怪物产生的有来源地图伤害仍然存在 | 注册一个怪物视图并转换出伤害(假视图伤害 7)→ `getSeparatedDamage` 有 1 条 → `deleteEnemy(view)` → `getSeparatedDamage` 仍有 1 条(正确应为 0) | `viewStore` 与 `damageStore` 只在读/删路径出现,全文件没有任何 `.set(...)` 写入,因此 `deleteEnemy` / `removeEnemyAffecting` 中 `viewStore.get(viewItem)` 恒为 `undefined`,清理循环被整体跳过;`sourcedDamage` 点位与 `affectedBy` 都不会被移除 | 怪物死亡/离场后地图伤害不消失,`getSeparatedDamage` / `getReducedDamage` 继续返回幽灵伤害,并会污染后续 `refreshIndex` 的重新聚合 | 在 `refreshEnemy` / `refreshEnemyAndClearCache` 建立 sourced 伤害时同步写入 `viewStore.set(viewItem, { damages, enemy })` 与 `damageStore.set(damage, { sourceView, sourceEnemy, index })`,或改为在 `deleteEnemy` 中直接遍历 `sourcedDamage` 的 `affectedBy` 反向清理 | `mapDamage.test.ts` `removes enemy-sourced damage when the enemy is deleted`(#06-01-2) | 中 |
|
||||
| `combat.ts` `CombatFlow.combatFlow` / `ICombatScript.before` | 实现与接口文档语义相反:接口声明返回 `false` 放弃战斗,实现却在返回**真值**时短路 | 加载一个 `onBeforeCombat` 钩子,脚本 `before` 返回 `false`;`battle()` 仍会执行该钩子(正确实现应直接返回并放弃) | `const skip = await script.before(...); if (skip) return damage;` 以真值短路,与文档「返回 false 会立刻停止并放弃此次战斗」相反;文档或实现其一有误 | 所有战斗脚本的短路约定:按接口文档编写的脚本会得到相反效果,可能漏战或误战 | 先与接口设计者确认语义,再二选一统一:以 `true` 短路为准则修正接口注释,以文档为准则改为 `if (!skip) return damage;` | `combat.test.ts` `abandons the battle when the before script returns false`(#06-01-3) | 中 |
|
||||
| `context.ts` `EnemyContext.buildup` | 重复调用 `buildup()` 会重复累加光环效果,而非从原始怪物重新计算 | 绑定勇士 → 放置基础 `atk = 2` 且带特殊属性的怪物(假转换器产出 `atk + 5` 的光环)→ `buildup()`(计算后 `atk = 7`)→ `addAura`(全局光环 `atk + 10`)→ `buildup()`;正确应为 `2 + 5 + 10 = 17`,实际为 `22`(`+5` 又被叠加一次) | `buildup` 只清空光环拓扑(`sortedAura` / `convertedAura` 等),未像 `refreshEnemy` 那样对每个视图调用 `reset()` 把计算后怪物恢复至原始怪物,导致 `buildupBase` / `buildupQuery` / `buildupFinal` 在既有计算结果上继续叠加 | 任何在已有怪物视图上再次触发的全量构建(重新绑定勇士、注册/注销光环转换器或效果后再构建)都会放大怪物属性 | 在 `buildup` 进入各效果阶段前对每个视图调用 `reset()`,或在重建开始时重建全部计算后怪物 | `damage.test.ts` `recomputes a repeat buildup from the base enemy without compounding`(#06-01-4) | 中 |
|
||||
|
||||
## #06-02 顶层战斗实现(data-state/src/enemy)
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// 测试战斗流程:同源绑定与外来绑定、脚本优先级与去重、缺参告警 139/141、真实计时器下的 await 顺序
|
||||
// 测试战斗流程:同源绑定与外来绑定、脚本优先级与去重、缺参告警 139/141、非地图与独立怪物流程、真实计时器下的 await 顺序
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type ITileLocator } from '@motajs/common';
|
||||
import {
|
||||
@ -358,6 +358,61 @@ describe('CombatFlow scripts and guards', () => {
|
||||
|
||||
expect(info).toBe(fixture.info);
|
||||
});
|
||||
|
||||
// 验证怪物无法解析出地图位置时战斗信息对象标记为非地图状态
|
||||
it('marks the combat handler as off-map when no locator can be resolved', async () => {
|
||||
const fixture = createFixture();
|
||||
const context = {
|
||||
state: fixture.state,
|
||||
getEnemyLocatorByView: () => null,
|
||||
getEnemyLocator: () => null,
|
||||
getViewByComputed: () => null,
|
||||
getEnemyByLocator: () => null,
|
||||
getEnemyByLoc: () => null
|
||||
} as never;
|
||||
fixture.flow.bindContext(context);
|
||||
fixture.flow.bindHero(fixture.hero);
|
||||
fixture.flow.bindDamage(fixture.damage);
|
||||
let onMap: boolean | null = null;
|
||||
fixture.flow.addCombatScript({
|
||||
priority: 1,
|
||||
before: async (_info, handler) => {
|
||||
onMap = handler.onMap;
|
||||
return false;
|
||||
},
|
||||
after: async () => {}
|
||||
});
|
||||
|
||||
const info = await fixture.flow.battle(fixture.view);
|
||||
|
||||
expect(info).toBe(fixture.info);
|
||||
expect(onMap).toBe(false);
|
||||
});
|
||||
|
||||
// 验证未登记的怪物对象会走独立战斗流程并返回伤害信息
|
||||
it('battles an unregistered computed enemy through the standalone path', async () => {
|
||||
const fixture = createFixture();
|
||||
const context = {
|
||||
state: fixture.state,
|
||||
getEnemyLocatorByView: () => null,
|
||||
getEnemyLocator: () => null,
|
||||
getViewByComputed: () => null,
|
||||
getEnemyByLocator: () => null,
|
||||
getEnemyByLoc: () => null
|
||||
} as never;
|
||||
fixture.flow.bindContext(context);
|
||||
fixture.flow.bindHero(fixture.hero);
|
||||
fixture.flow.bindDamage(fixture.damage);
|
||||
const foreign = new modules.Enemy<TestEnemyAttr>('foreign', 2, {
|
||||
hp: 5,
|
||||
atk: 1,
|
||||
def: 0
|
||||
});
|
||||
|
||||
const info = await fixture.flow.battleComputed(foreign.clone());
|
||||
|
||||
expect(info).toBe(fixture.info);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CombatFlow async ordering', () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// 测试战斗伤害上下文与伤害系统:结果展开、handler 身份、告警码 106/107、缓存与 with、临界生成
|
||||
// 测试战斗伤害上下文与伤害系统:结果展开、handler 身份、告警码 106/107、缓存与 with、临界生成、属性到伤害联动(D-26)
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type ITileLocator } from '@motajs/common';
|
||||
import { type IEnemy, type IStateBase } from '@user/data-base';
|
||||
@ -59,9 +59,11 @@ interface TestHeroAttr {
|
||||
interface TestModules {
|
||||
DamageContext: typeof import('./damage').DamageContext;
|
||||
DamageSystem: typeof import('./damage').DamageSystem;
|
||||
EnemyContext: typeof import('./context').EnemyContext;
|
||||
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
|
||||
Enemy: typeof import('@user/data-base').Enemy;
|
||||
MapLocIndexer: typeof import('@user/data-common').MapLocIndexer;
|
||||
FullRange: typeof import('@motajs/common').FullRange;
|
||||
logger: typeof import('@motajs/common').logger;
|
||||
}
|
||||
|
||||
@ -71,15 +73,18 @@ beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const damageModule = await import('./damage');
|
||||
const contextModule = await import('./context');
|
||||
const baseModule = await import('@user/data-base');
|
||||
const commonModule = await import('@user/data-common');
|
||||
const motaModule = await import('@motajs/common');
|
||||
modules = {
|
||||
DamageContext: damageModule.DamageContext,
|
||||
DamageSystem: damageModule.DamageSystem,
|
||||
EnemyContext: contextModule.EnemyContext,
|
||||
HeroAttribute: baseModule.HeroAttribute,
|
||||
Enemy: baseModule.Enemy,
|
||||
MapLocIndexer: commonModule.MapLocIndexer,
|
||||
FullRange: motaModule.FullRange,
|
||||
logger: motaModule.logger
|
||||
};
|
||||
});
|
||||
@ -218,6 +223,83 @@ function createHandler(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个最小特殊属性对象,用于驱动真实怪物上下文的光环转换
|
||||
* @param code 特殊属性代码
|
||||
*/
|
||||
function createSpecial(code: number): never {
|
||||
return { code, clone: () => createSpecial(code) } as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个按怪物攻击力线性换算伤害的计算器,用于验证属性到伤害的联动
|
||||
*/
|
||||
class EnemyAttributeCalculator
|
||||
implements IDamageCalculator<TestEnemyAttr, TestHeroAttr>
|
||||
{
|
||||
/** calculate 调用次数 */
|
||||
calls: number = 0;
|
||||
/** 每点怪物攻击力换算的伤害 */
|
||||
readonly damagePerAtk: number;
|
||||
|
||||
/**
|
||||
* @param damagePerAtk 每点怪物攻击力换算的伤害
|
||||
*/
|
||||
constructor(damagePerAtk: number = 2) {
|
||||
this.damagePerAtk = damagePerAtk;
|
||||
}
|
||||
|
||||
calculate(
|
||||
handler: IReadonlyEnemyHandler<TestEnemyAttr, TestHeroAttr>
|
||||
): IEnemyDamageInfoBase {
|
||||
this.calls++;
|
||||
return {
|
||||
damage: handler.enemy.getAttribute('atk') * this.damagePerAtk,
|
||||
turn: 1
|
||||
};
|
||||
}
|
||||
|
||||
getCriticalLimit(): number {
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个按怪物攻击力换算伤害的测试计算器
|
||||
* @param damagePerAtk 每点怪物攻击力换算的伤害
|
||||
*/
|
||||
function createCalculator(damagePerAtk: number = 2): EnemyAttributeCalculator {
|
||||
return new EnemyAttributeCalculator(damagePerAtk);
|
||||
}
|
||||
|
||||
interface FakeConverterShape {
|
||||
/**
|
||||
* 判断特殊属性是否应被转换
|
||||
* @param special 特殊属性
|
||||
*/
|
||||
shouldConvert(special: { code: number }): boolean;
|
||||
|
||||
/**
|
||||
* 将特殊属性转换为光环视图
|
||||
*/
|
||||
convert(): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个可驱动光环流水线的真实怪物上下文
|
||||
* @param converter 赋予上下文的测试用光环转换器
|
||||
*/
|
||||
function createEnemyContext(
|
||||
converter: FakeConverterShape
|
||||
): InstanceType<TestModules['EnemyContext']> {
|
||||
const context = new modules.EnemyContext<TestEnemyAttr, TestHeroAttr>(
|
||||
{} as IStateBase
|
||||
);
|
||||
context.resize(3, 3);
|
||||
context.registerAuraConverter(converter as never);
|
||||
return context;
|
||||
}
|
||||
|
||||
describe('DamageContext behaviour', () => {
|
||||
// 验证已绑定计算器与勇士时按视图返回计算器结果,并携带正确身份的 handler
|
||||
it('returns calculator output with an identity handler for a view', () => {
|
||||
@ -503,3 +585,130 @@ describe('DamageContext critical generation', () => {
|
||||
expect(first.damageDiff).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DamageSystem attribute linkage', () => {
|
||||
// 验证 getCalculator 在未设置时返回 null,设置后返回同一计算器
|
||||
it('exposes the current calculator through getCalculator', () => {
|
||||
const fixture = createFixture();
|
||||
const system = new modules.DamageSystem(fixture.context);
|
||||
|
||||
expect(system.getCalculator()).toBeNull();
|
||||
|
||||
const calculator = createCalculator(3);
|
||||
system.useCalculator(calculator);
|
||||
|
||||
expect(system.getCalculator()).toBe(calculator);
|
||||
});
|
||||
|
||||
// 验证光环改属性后伤害系统按新属性重算,且全量构建会失效缓存(D-26)
|
||||
it('reflects aura-modified attributes and invalidates the cache on buildup', () => {
|
||||
const context = createEnemyContext({
|
||||
shouldConvert: special => special.code === 20,
|
||||
convert: () => ({
|
||||
priority: 1,
|
||||
range: new modules.FullRange(),
|
||||
couldApplyBase: true,
|
||||
couldApplySpecial: false,
|
||||
getRangeParam: () => undefined,
|
||||
apply: (handler: { enemy: IEnemy<TestEnemyAttr> }) =>
|
||||
handler.enemy.addAttribute('atk', 5),
|
||||
applySpecial: () => null
|
||||
})
|
||||
});
|
||||
const hero = new modules.HeroAttribute<TestHeroAttr>({
|
||||
hp: 100,
|
||||
atk: 0,
|
||||
def: 0
|
||||
});
|
||||
context.bindHero(hero);
|
||||
const enemy = new modules.Enemy<TestEnemyAttr>('e1', 1, {
|
||||
hp: 10,
|
||||
atk: 2,
|
||||
def: 0
|
||||
});
|
||||
enemy.addSpecial(createSpecial(20));
|
||||
context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
const calculator = createCalculator(2);
|
||||
const system = new modules.DamageSystem(context);
|
||||
system.useCalculator(calculator);
|
||||
context.attachDamageSystem(system);
|
||||
|
||||
context.buildup();
|
||||
|
||||
const view = context.getEnemyByLoc(0, 0)!;
|
||||
const first = system.getDamageInfo(view);
|
||||
|
||||
expect(first?.damage).toBe(14);
|
||||
expect(calculator.calls).toBe(1);
|
||||
|
||||
expect(system.getDamageInfo(view)).toBe(first);
|
||||
expect(calculator.calls).toBe(1);
|
||||
|
||||
// setEnemyAt 换入更高攻击的怪物并重新构建,伤害随新属性重算
|
||||
const stronger = new modules.Enemy<TestEnemyAttr>('e2', 1, {
|
||||
hp: 10,
|
||||
atk: 20,
|
||||
def: 0
|
||||
});
|
||||
stronger.addSpecial(createSpecial(20));
|
||||
context.setEnemyAt({ x: 0, y: 0 }, stronger);
|
||||
context.buildup();
|
||||
const newView = context.getEnemyByLoc(0, 0)!;
|
||||
const second = system.getDamageInfo(newView);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second?.damage).toBe(50);
|
||||
expect(calculator.calls).toBe(2);
|
||||
|
||||
// clear 会清空伤害缓存,因此再次取用不再命中旧缓存而返回 null
|
||||
context.clear();
|
||||
expect(system.getDamageInfo(newView)).toBeNull();
|
||||
});
|
||||
|
||||
// 疑似 bug:重复全量构建应从原始怪物重算而非在原计算值上累加,详见 06-TEST-FINDINGS.md #06-01-4,修复后取消 skip
|
||||
it.skip('recomputes a repeat buildup from the base enemy without compounding', () => {
|
||||
const context = createEnemyContext({
|
||||
shouldConvert: special => special.code === 20,
|
||||
convert: () => ({
|
||||
priority: 1,
|
||||
range: new modules.FullRange(),
|
||||
couldApplyBase: true,
|
||||
couldApplySpecial: false,
|
||||
getRangeParam: () => undefined,
|
||||
apply: (handler: { enemy: IEnemy<TestEnemyAttr> }) =>
|
||||
handler.enemy.addAttribute('atk', 5),
|
||||
applySpecial: () => null
|
||||
})
|
||||
});
|
||||
context.bindHero(
|
||||
new modules.HeroAttribute<TestHeroAttr>({ hp: 100, atk: 0, def: 0 })
|
||||
);
|
||||
const enemy = new modules.Enemy<TestEnemyAttr>('e1', 1, {
|
||||
hp: 10,
|
||||
atk: 2,
|
||||
def: 0
|
||||
});
|
||||
enemy.addSpecial(createSpecial(20));
|
||||
context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
context.buildup();
|
||||
context.addAura({
|
||||
priority: 2,
|
||||
range: new modules.FullRange(),
|
||||
couldApplyBase: true,
|
||||
couldApplySpecial: false,
|
||||
getRangeParam: () => undefined,
|
||||
apply: (handler: { enemy: IEnemy<TestEnemyAttr> }) =>
|
||||
handler.enemy.addAttribute('atk', 10),
|
||||
applySpecial: () => null
|
||||
} as never);
|
||||
|
||||
context.buildup();
|
||||
|
||||
expect(
|
||||
context
|
||||
.getEnemyByLoc(0, 0)!
|
||||
.getComputedEnemy()
|
||||
.getAttribute('atk')
|
||||
).toBe(17);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// 测试地图伤害:无来源伤害增删、有来源转换与合并、告警码 102/103/104、分离伤害合并与 deleteEnemy
|
||||
// 测试地图伤害:无来源伤害增删、有来源转换与合并、告警码 102/103/104、分离伤害合并、deleteEnemy 与 markEnemyDirty
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IRange,
|
||||
@ -170,12 +170,15 @@ class FakeConverter
|
||||
{
|
||||
/** 转换输出的伤害视图 */
|
||||
readonly views: IMapDamageView<number>[];
|
||||
/** convert 调用次数 */
|
||||
calls: number = 0;
|
||||
|
||||
constructor(views: IMapDamageView<number>[]) {
|
||||
this.views = views;
|
||||
}
|
||||
|
||||
convert(): IMapDamageView<number>[] {
|
||||
this.calls++;
|
||||
return this.views;
|
||||
}
|
||||
}
|
||||
@ -422,6 +425,37 @@ describe('MapDamage sourced conversion and reduction', () => {
|
||||
expect(result.info.map(v => v.code)).toContain(104);
|
||||
});
|
||||
|
||||
// 验证已注册怪物的 markEnemyDirty 会按其视图重新转换有来源伤害
|
||||
it('refreshes sourced damage when a registered enemy is marked dirty', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
fixture.damage.useConverter(fixture.converter);
|
||||
expect(fixture.converter.calls).toBe(1);
|
||||
|
||||
fixture.damage.markEnemyDirty(fixture.view);
|
||||
|
||||
expect(fixture.converter.calls).toBe(2);
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage(fixture.locator)][0].damage
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
// 验证删除未注册怪物不产生任何有来源伤害变化
|
||||
it('ignores deleting an enemy without sourced damage', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
fixture.damage.useConverter(fixture.converter);
|
||||
const before = [
|
||||
...fixture.damage.getSeparatedDamage(fixture.locator)
|
||||
].length;
|
||||
|
||||
fixture.damage.deleteEnemy(createUnknownView(fixture));
|
||||
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage(fixture.locator)]
|
||||
).toHaveLength(before);
|
||||
});
|
||||
|
||||
// 疑似 bug:deleteEnemy 应移除该怪物带来的有来源地图伤害,详见 06-TEST-FINDINGS.md #06-01-2,修复后取消 skip
|
||||
it.skip('removes enemy-sourced damage when the enemy is deleted', () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user