mirror of
https://github.com/asphyxia-core/plugins.git
synced 2026-04-25 16:21:44 -05:00
IIDX: Added clear/full combo rate, BEGINNER mode clear lamp support
This commit is contained in:
parent
6902182a8d
commit
8fa3349b88
|
|
@ -33,6 +33,13 @@ Features
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Known Issues
|
||||||
|
|
||||||
|
- Clear Lamps may display invalid lamps due to missing conversion code
|
||||||
|
- LEGGENDARIA play records before HEROIC VERSE may not display on higher version due missing conversion code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Changelogs
|
Changelogs
|
||||||
|
|
||||||
**v0.1.0**
|
**v0.1.0**
|
||||||
|
|
@ -42,8 +49,8 @@ Changelogs
|
||||||
- Added Initial support for HEROIC VERSE
|
- Added Initial support for HEROIC VERSE
|
||||||
- Expanded score array to adapting newer difficulty (SPN ~ DPA [6] -> SPB ~ DPL [10])
|
- Expanded score array to adapting newer difficulty (SPN ~ DPA [6] -> SPB ~ DPL [10])
|
||||||
- This borked previous score datas recorded with v0.1.0
|
- This borked previous score datas recorded with v0.1.0
|
||||||
- All score data now shared with all version.
|
- All score data now shared with all version
|
||||||
- as it doesn't have music_id conversion, it will display incorrect data on certain versions.
|
- as it doesn't have music_id conversion, it will display incorrect data on certain versions
|
||||||
- Added Initial customize support (no webui)
|
- Added Initial customize support (no webui)
|
||||||
|
|
||||||
**v0.1.2**
|
**v0.1.2**
|
||||||
|
|
@ -58,19 +65,19 @@ Changelogs
|
||||||
|
|
||||||
**v0.1.5**
|
**v0.1.5**
|
||||||
- Added Initial support for Resort Anthem
|
- Added Initial support for Resort Anthem
|
||||||
- BEGINNER, LEAGUE, STORY does not work yet.
|
- BEGINNER, LEAGUE, STORY does not work yet
|
||||||
- Fixed where s_hispeed/d_hispeed doesn't save correctly.
|
- Fixed where s_hispeed/d_hispeed doesn't save correctly
|
||||||
|
|
||||||
**v0.1.6**
|
**v0.1.6**
|
||||||
- Added Initial support for tricoro
|
- Added Initial support for tricoro
|
||||||
- Event savings are broken
|
- Event savings are broken
|
||||||
- Added movie_upload url setting on plugin setting (BISTROVER ~)
|
- Added movie_upload url setting on plugin setting (BISTROVER ~)
|
||||||
- This uses JSON instead of XML and this requires additional setup. (can't test or implement this as I don't own NVIDIA GPU)
|
- This uses JSON instead of XML and this requires additional setup (can't test or implement this as I don't own NVIDIA GPU)
|
||||||
|
|
||||||
**v0.1.7**
|
**v0.1.7**
|
||||||
- Added Initial support for SPADA
|
- Added Initial support for SPADA
|
||||||
- Event savings are broken
|
- Event savings are broken
|
||||||
- Fixed where rtype didn't save correctly. (BISTROVER ~)
|
- Fixed where rtype didn't save correctly (BISTROVER ~)
|
||||||
|
|
||||||
**v0.1.8**
|
**v0.1.8**
|
||||||
- Added RIVAL pacemaker support
|
- Added RIVAL pacemaker support
|
||||||
|
|
@ -91,9 +98,11 @@ Changelogs
|
||||||
|
|
||||||
**v0.1.11**
|
**v0.1.11**
|
||||||
- Added Shop Ranking support
|
- Added Shop Ranking support
|
||||||
- Changed pc.common/gameSystem.systemInfo response not to use pugFile.
|
- Changed pc.common/gameSystem.systemInfo response not to use pugFile
|
||||||
- IIDX_CPUS on models/arena.ts came from asphyxia_route_public
|
- IIDX_CPUS on models/arena.ts came from asphyxia_route_public
|
||||||
|
|
||||||
**v0.1.12**
|
**v0.1.12**
|
||||||
- Exposed some of pc.common attributes to plugin settings (WIP)
|
- Exposed some of pc.common attributes to plugin settings (WIP)
|
||||||
- Added Experimental WebUI (WIP)
|
- Added Experimental WebUI (WIP)
|
||||||
|
- Added music.crate/music.breg response
|
||||||
|
- Fixed Venue Top didn't save correctly (BISTROVER ~)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const musicgetrank: EPR = async (info, data, send) => {
|
||||||
[parseInt($(data).attr().iidxid4), await IDtoRef(parseInt($(data).attr().iidxid4))],
|
[parseInt($(data).attr().iidxid4), await IDtoRef(parseInt($(data).attr().iidxid4))],
|
||||||
];
|
];
|
||||||
|
|
||||||
let m = [], top = [];
|
let m = [], top = [], b = [];
|
||||||
let score_data: number[];
|
let score_data: number[];
|
||||||
let indices, temp_mid = 0;
|
let indices, temp_mid = 0;
|
||||||
if (version < 20) {
|
if (version < 20) {
|
||||||
|
|
@ -37,6 +37,7 @@ export const musicgetrank: EPR = async (info, data, send) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
m.push(K.ARRAY("s16", score_data));
|
m.push(K.ARRAY("s16", score_data));
|
||||||
|
if (res.cArray[0] != 0) b.push(K.ARRAY("u16", [temp_mid, res.cArray[0]]));
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let i = 0; i < rival_refids.length; i++) {
|
for (let i = 0; i < rival_refids.length; i++) {
|
||||||
|
|
@ -60,37 +61,7 @@ export const musicgetrank: EPR = async (info, data, send) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (version > 19 && version < 21) {
|
else if (version >= 20) {
|
||||||
indices = cltype === 0 ? [1, 2, 3] : [6, 7, 8];
|
|
||||||
music_data.forEach((res: score) => {
|
|
||||||
if (cltype == 0) {
|
|
||||||
score_data = [-1, res.mid, ...indices.map(i => res.cArray[i]), ...indices.map(i => res.esArray[i]), ...indices.map(i => res.mArray[i])];
|
|
||||||
} else {
|
|
||||||
score_data = [-1, res.mid, ...indices.map(i => res.cArray[i]), ...indices.map(i => res.esArray[i]), ...indices.map(i => res.mArray[i])];
|
|
||||||
}
|
|
||||||
|
|
||||||
m.push(K.ARRAY("s16", score_data));
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < rival_refids.length; i++) {
|
|
||||||
if (_.isNaN(rival_refids[i][0])) continue;
|
|
||||||
|
|
||||||
const rival_score = await DB.Find<score>(String(rival_refids[i][1]),
|
|
||||||
{ collection: "score", }
|
|
||||||
);
|
|
||||||
|
|
||||||
rival_score.forEach((res: score) => {
|
|
||||||
if (cltype == 0) {
|
|
||||||
score_data = [i, res.mid, ...indices.map(i => res.cArray[i]), ...indices.map(i => res.esArray[i]), ...indices.map(i => res.mArray[i])];
|
|
||||||
} else {
|
|
||||||
score_data = [i, res.mid, ...indices.map(i => res.cArray[i]), ...indices.map(i => res.esArray[i]), ...indices.map(i => res.mArray[i])];
|
|
||||||
}
|
|
||||||
|
|
||||||
m.push(K.ARRAY("s16", score_data));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (version >= 21) {
|
|
||||||
if (version >= 27) indices = cltype === 0 ? [0, 1, 2, 3, 4] : [5, 6, 7, 8, 9];
|
if (version >= 27) indices = cltype === 0 ? [0, 1, 2, 3, 4] : [5, 6, 7, 8, 9];
|
||||||
else indices = cltype === 0 ? [1, 2, 3] : [6, 7, 8];
|
else indices = cltype === 0 ? [1, 2, 3] : [6, 7, 8];
|
||||||
|
|
||||||
|
|
@ -102,6 +73,7 @@ export const musicgetrank: EPR = async (info, data, send) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
m.push(K.ARRAY("s16", score_data));
|
m.push(K.ARRAY("s16", score_data));
|
||||||
|
if (res.cArray[0] != 0) b.push(K.ARRAY("u16", [res.mid, res.cArray[0]]));
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let i = 0; i < rival_refids.length; i++) {
|
for (let i = 0; i < rival_refids.length; i++) {
|
||||||
|
|
@ -158,14 +130,17 @@ export const musicgetrank: EPR = async (info, data, send) => {
|
||||||
return send.object({
|
return send.object({
|
||||||
style: K.ATTR({type: String(cltype)}),
|
style: K.ATTR({type: String(cltype)}),
|
||||||
m,
|
m,
|
||||||
|
b,
|
||||||
top,
|
top,
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
return send.success();
|
return send.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
return send.object({
|
return send.object({
|
||||||
m
|
m,
|
||||||
|
b
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,23 +394,23 @@ export const musicreg: EPR = async (info, data, send) => {
|
||||||
|
|
||||||
if (_.isNil(score_top)) {
|
if (_.isNil(score_top)) {
|
||||||
if (esArray[clid] > exscore) {
|
if (esArray[clid] > exscore) {
|
||||||
names[clid] = profile.name;
|
names[tmp_clid] = profile.name;
|
||||||
scores[clid] = esArray[clid];
|
scores[tmp_clid] = esArray[clid];
|
||||||
clflgs[clid] = cArray[clid];
|
clflgs[tmp_clid] = cArray[clid];
|
||||||
} else {
|
} else {
|
||||||
names[clid] = profile.name;
|
names[tmp_clid] = profile.name;
|
||||||
scores[clid] = exscore;
|
scores[tmp_clid] = exscore;
|
||||||
clflgs[clid] = cflg;
|
clflgs[tmp_clid] = cflg;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
names = score_top.names;
|
names = score_top.names;
|
||||||
scores = score_top.scores;
|
scores = score_top.scores;
|
||||||
clflgs = score_top.clflgs;
|
clflgs = score_top.clflgs;
|
||||||
|
|
||||||
if (exscore > scores[clid]) {
|
if (exscore > scores[tmp_clid]) {
|
||||||
names[clid] = profile.name;
|
names[tmp_clid] = profile.name;
|
||||||
scores[clid] = exscore;
|
scores[tmp_clid] = exscore;
|
||||||
clflgs[clid] = cflg;
|
clflgs[tmp_clid] = cflg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,7 +475,11 @@ export const musicreg: EPR = async (info, data, send) => {
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let crate = 0, frate = 0, cflgs = 0, fcflgs = 0;
|
||||||
scores.forEach((rankscore, index) => {
|
scores.forEach((rankscore, index) => {
|
||||||
|
if (rankscore[1] != 1) cflgs += 1;
|
||||||
|
if (rankscore[1] == 7) fcflgs += 1;
|
||||||
|
|
||||||
if (index == shop_rank) {
|
if (index == shop_rank) {
|
||||||
shop_rank_data.push(
|
shop_rank_data.push(
|
||||||
K.ATTR({
|
K.ATTR({
|
||||||
|
|
@ -553,12 +532,21 @@ export const musicreg: EPR = async (info, data, send) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (version > 23) {
|
||||||
|
crate = Math.round((cflgs / shop_rank_data.length) * 1000);
|
||||||
|
frate = Math.round((fcflgs / shop_rank_data.length) * 1000);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
crate = Math.round((cflgs / shop_rank_data.length) * 100);
|
||||||
|
frate = Math.round((fcflgs / shop_rank_data.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
let result: any = {
|
let result: any = {
|
||||||
"@attr": {
|
"@attr": {
|
||||||
mid: String(mid),
|
mid: String(mid),
|
||||||
clid: String(clid),
|
clid: String(clid),
|
||||||
crate: "0",
|
crate: String(crate),
|
||||||
frate: "0",
|
frate: String(frate),
|
||||||
rankside: String(style),
|
rankside: String(style),
|
||||||
},
|
},
|
||||||
ranklist: {
|
ranklist: {
|
||||||
|
|
@ -571,6 +559,148 @@ export const musicreg: EPR = async (info, data, send) => {
|
||||||
return send.object(result);
|
return send.object(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const musicbreg: EPR = async (info, data, send) => {
|
||||||
|
const version = GetVersion(info);
|
||||||
|
|
||||||
|
// mid pgnum gnum cflg //
|
||||||
|
const refid = await IDtoRef(parseInt($(data).attr().iidxid));
|
||||||
|
const pgnum = parseInt($(data).attr().pgnum);
|
||||||
|
const gnum = parseInt($(data).attr().gnum);
|
||||||
|
const cflg = parseInt($(data).attr().cflg);
|
||||||
|
let mid = parseInt($(data).attr().mid);
|
||||||
|
let clid = 0; // SP BEGINNER //
|
||||||
|
let exscore = (pgnum * 2 + gnum);
|
||||||
|
|
||||||
|
if (version < 20) mid = OldMidToNewMid(mid);
|
||||||
|
|
||||||
|
const music_data: score | null = await DB.FindOne<score>(refid, {
|
||||||
|
collection: "score",
|
||||||
|
mid: mid,
|
||||||
|
});
|
||||||
|
|
||||||
|
let pgArray = Array<number>(10).fill(0); // PGREAT //
|
||||||
|
let gArray = Array<number>(10).fill(0); // GREAT //
|
||||||
|
let mArray = Array<number>(10).fill(0); // MISS //
|
||||||
|
let cArray = Array<number>(10).fill(0); // CLEAR FLAGS //
|
||||||
|
let esArray = Array<number>(10).fill(0); // EXSCORE //
|
||||||
|
let optArray = Array<number>(10).fill(0); // USED OPTION (CastHour) //
|
||||||
|
let opt2Array = Array<number>(10).fill(0); // USED OPTION (CastHour) //
|
||||||
|
|
||||||
|
if (_.isNil(music_data)) {
|
||||||
|
pgArray[clid] = pgnum;
|
||||||
|
gArray[clid] = gnum;
|
||||||
|
mArray[clid] = -1;
|
||||||
|
cArray[clid] = cflg;
|
||||||
|
esArray[clid] = exscore;
|
||||||
|
} else {
|
||||||
|
pgArray = music_data.pgArray;
|
||||||
|
gArray = music_data.gArray;
|
||||||
|
mArray = music_data.mArray;
|
||||||
|
cArray = music_data.cArray;
|
||||||
|
esArray = music_data.esArray;
|
||||||
|
optArray = music_data.optArray;
|
||||||
|
opt2Array = music_data.opt2Array;
|
||||||
|
|
||||||
|
const pExscore = esArray[clid];
|
||||||
|
if (exscore > pExscore) {
|
||||||
|
pgArray[clid] = Math.max(pgArray[clid], pgnum);
|
||||||
|
gArray[clid] = Math.max(gArray[clid], gnum);
|
||||||
|
esArray[clid] = Math.max(esArray[clid], exscore);
|
||||||
|
}
|
||||||
|
|
||||||
|
cArray[clid] = Math.max(cArray[clid], cflg);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DB.Upsert<score>(
|
||||||
|
refid,
|
||||||
|
{
|
||||||
|
collection: "score",
|
||||||
|
mid: mid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
pgArray,
|
||||||
|
gArray,
|
||||||
|
mArray,
|
||||||
|
cArray,
|
||||||
|
esArray,
|
||||||
|
optArray,
|
||||||
|
opt2Array,
|
||||||
|
|
||||||
|
[clid]: null,
|
||||||
|
[clid + 10]: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return send.success();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const musiccrate: EPR = async (info, data, send) => {
|
||||||
|
const version = GetVersion(info);
|
||||||
|
const scores = await DB.Find<score>(null, {
|
||||||
|
collection: "score",
|
||||||
|
});
|
||||||
|
|
||||||
|
let cFlgs: Record<number, number[]> = {},
|
||||||
|
fcFlgs: Record<number, number[]> = {},
|
||||||
|
totalFlgs: Record<number, number[]> = {},
|
||||||
|
cFlgArray: number[], fcFlgArray: number[], totalArray: number[];
|
||||||
|
|
||||||
|
scores.forEach((res) => {
|
||||||
|
totalArray = Array<number>(10).fill(0);
|
||||||
|
cFlgArray = Array<number>(10).fill(0);
|
||||||
|
fcFlgArray = Array<number>(10).fill(0);
|
||||||
|
|
||||||
|
for (let a = 0; a < 10; a++) {
|
||||||
|
if (res.cArray[a] != 0) totalArray[a] += 1;
|
||||||
|
if (res.cArray[a] != 1) cFlgArray[a] += 1;
|
||||||
|
if (res.cArray[a] == 7) fcFlgArray[a] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFlgs[res.mid] = totalArray;
|
||||||
|
cFlgs[res.mid] = cFlgArray;
|
||||||
|
fcFlgs[res.mid] = fcFlgArray;
|
||||||
|
});
|
||||||
|
|
||||||
|
let c = [];
|
||||||
|
let indices = [1, 2, 3, 6, 7, 8];
|
||||||
|
for (const key in totalFlgs) {
|
||||||
|
let cRate = Array<number>(10).fill(-1);
|
||||||
|
let fcRate = Array<number>(10).fill(-1);
|
||||||
|
|
||||||
|
for (let a = 0; a < cRate.length; a++) {
|
||||||
|
if (version > 23) {
|
||||||
|
cRate[a] = Math.round((cFlgs[key][a] / totalFlgs[key][a]) * 1000);
|
||||||
|
fcRate[a] = Math.round((fcFlgs[key][a] / totalFlgs[key][a]) * 1000);
|
||||||
|
} else {
|
||||||
|
cRate[a] = Math.round((cFlgs[key][a] / totalFlgs[key][a]) * 100);
|
||||||
|
fcRate[a] = Math.round((fcFlgs[key][a] / totalFlgs[key][a]) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version > 26) {
|
||||||
|
c.push(
|
||||||
|
K.ARRAY("s32", [...cRate, ...fcRate], { mid: key }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (version < 20) { // TODO:: figure out why this doesn't work in Resort Anthem //
|
||||||
|
c.push(
|
||||||
|
K.ARRAY("u8", [...indices.map(i => cRate[i])], { mid: String(NewMidToOldMid(Number(key))) }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
c.push(
|
||||||
|
K.ARRAY("u8", [...indices.map(i => cRate[i]), ...indices.map(i => fcRate[i])], { mid: key }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return send.object({
|
||||||
|
c
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// this is not valid response //
|
// this is not valid response //
|
||||||
export const musicarenacpu: EPR = async (info, data, send) => {
|
export const musicarenacpu: EPR = async (info, data, send) => {
|
||||||
const version = GetVersion(info);
|
const version = GetVersion(info);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { pccommon, pcreg, pcget, pcgetname, pctakeover, pcvisit, pcsave, pcoldget, pcgetlanegacha, pcdrawlanegacha, pcshopregister } from "./handlers/pc";
|
import { pccommon, pcreg, pcget, pcgetname, pctakeover, pcvisit, pcsave, pcoldget, pcgetlanegacha, pcdrawlanegacha, pcshopregister } from "./handlers/pc";
|
||||||
import { shopgetname, shopsavename, shopgetconvention, shopsetconvention } from "./handlers/shop";
|
import { shopgetname, shopsavename, shopgetconvention, shopsetconvention } from "./handlers/shop";
|
||||||
import { musicreg, musicgetrank, musicappoint, musicarenacpu } from "./handlers/music";
|
import { musicreg, musicgetrank, musicappoint, musicarenacpu, musiccrate, musicbreg } from "./handlers/music";
|
||||||
import { graderaised } from "./handlers/grade";
|
import { graderaised } from "./handlers/grade";
|
||||||
import { gssysteminfo } from "./handlers/gamesystem";
|
import { gssysteminfo } from "./handlers/gamesystem";
|
||||||
import { updateRivalSettings, updateCustomSettings } from "./handlers/webui";
|
import { updateRivalSettings, updateCustomSettings } from "./handlers/webui";
|
||||||
|
|
@ -180,9 +180,11 @@ export function register() {
|
||||||
MultiRoute("shop.getconvention", shopgetconvention);
|
MultiRoute("shop.getconvention", shopgetconvention);
|
||||||
MultiRoute("shop.setconvention", shopsetconvention);
|
MultiRoute("shop.setconvention", shopsetconvention);
|
||||||
|
|
||||||
|
MultiRoute("music.crate", musiccrate);
|
||||||
MultiRoute("music.getrank", musicgetrank);
|
MultiRoute("music.getrank", musicgetrank);
|
||||||
MultiRoute("music.appoint", musicappoint);
|
MultiRoute("music.appoint", musicappoint);
|
||||||
MultiRoute("music.reg", musicreg);
|
MultiRoute("music.reg", musicreg);
|
||||||
|
MultiRoute("music.breg", musicbreg);
|
||||||
MultiRoute("music.arenaCPU", musicarenacpu);
|
MultiRoute("music.arenaCPU", musicarenacpu);
|
||||||
|
|
||||||
MultiRoute("grade.raised", graderaised);
|
MultiRoute("grade.raised", graderaised);
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
rival: DB.Find(refid, { collection: "rival" })
|
rival: DB.Find(refid, { collection: "rival" })
|
||||||
|
|
||||||
-
|
-
|
||||||
const rival_list=[["", "None", "0000-0000"]];
|
let rival_list=[["", "None", "0000-0000"]];
|
||||||
profiles.forEach((res) => {
|
profiles.forEach((res) => {
|
||||||
rival_list.push([res.refid, res.name, res.idstr])
|
rival_list.push([res.refid, res.name, res.idstr])
|
||||||
});
|
});
|
||||||
|
|
||||||
const my_sp_rival = [], my_dp_rival = [];
|
let my_sp_rival = [], my_dp_rival = [];
|
||||||
rival.forEach((res) => {
|
rival.forEach((res) => {
|
||||||
if (res.play_style == 1) my_sp_rival[res.index] = res.rival_refid;
|
if (res.play_style == 1) my_sp_rival[res.index] = res.rival_refid;
|
||||||
else if (res.play_style == 2) my_dp_rival[res.index] = res.rival_refid;
|
else if (res.play_style == 2) my_dp_rival[res.index] = res.rival_refid;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user