diff --git a/laf-cloud/functions/userProp.ts b/laf-cloud/functions/userProp.ts index d6b8155..6c29e05 100644 --- a/laf-cloud/functions/userProp.ts +++ b/laf-cloud/functions/userProp.ts @@ -46,13 +46,22 @@ export default async function (ctx: FunctionContext) { data.infiniteRocketTime = user.data?.infiniteRocketTime || 0; let propType = Number(ctx.body.propType); let propData = ctx.body.propData; + const boosterField = propType === 2004 ? "hourglassAmount" : propType === 2005 ? "rocketAmount" : null; if (!propType && propType != 0) { return { code: 0, data: null, msg: "未获取到propType" }; } - if (!propData) { + if (!propData && !(boosterField && propData === 0)) { return { code: 0, data: null, msg: "未获取到propData" } } - if (propType === 2001) { + if (boosterField) { + const amount = Number(propData); + if ((typeof propData !== "number" && typeof propData !== "string") + || (typeof propData === "string" && !propData.trim()) + || !Number.isSafeInteger(amount) || amount < 0) { + return { code: 0, data: null, msg: "道具数量必须是非负整数" }; + } + data[boosterField] = amount; + } else if (propType === 2001) { data.freezeAmount = Number(propData); } else if (propType === 2002) { data.hammerAmount = Number(propData); @@ -75,7 +84,9 @@ export default async function (ctx: FunctionContext) { if (propData.infinite_hourglass !== undefined) data.infiniteHourglassTime = Number(propData.infinite_hourglass); if (propData.infinite_rocket !== undefined) data.infiniteRocketTime = Number(propData.infinite_rocket); } - let ret = await db.collection(dbname).where({ _id: uid }).update(data); + let ret = await db.collection(dbname).where({ _id: uid }).update(boosterField + ? { [boosterField]: data[boosterField], timestamp: data.timestamp } + : data); return { code: 1, data: { freeze: data.freezeAmount, diff --git a/laf-cloud/tests/user-prop-boosters.test.mjs b/laf-cloud/tests/user-prop-boosters.test.mjs new file mode 100644 index 0000000..10e24cd --- /dev/null +++ b/laf-cloud/tests/user-prop-boosters.test.mjs @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; + +let user; +let writes; +let beforeUpdate; +globalThis.__boosterPropCloudMock = { + database: () => ({ + collection: () => ({ + where: () => ({ + getOne: async () => ({ data: { ...user } }), + async update(update) { + beforeUpdate?.(); + writes.push({ ...update }); + Object.assign(user, update); + }, + }), + }), + }), +}; +registerHooks({ + load(url, context, next) { + return next(url, url.endsWith(".ts") ? { ...context, format: "module-typescript" } : context); + }, + resolve(specifier, context, next) { + if (specifier === "@lafjs/cloud") { + return { url: "data:text/javascript,export default globalThis.__boosterPropCloudMock", shortCircuit: true }; + } + if (specifier === "@/Utils") { + return { url: "data:text/javascript,export default {checkToken:(actual,expected)=>actual===expected}", shortCircuit: true }; + } + return next(specifier, context); + }, +}); +const { default: userProp } = await import("../functions/userProp.ts"); + +function reset() { + user = { + token: "valid", freezeAmount: 1, hammerAmount: 2, magicAmount: 3, + hourglassAmount: 4, rocketAmount: 5, infiniteHourglassTime: 100, infiniteRocketTime: 200, + }; + writes = []; + beforeUpdate = null; +} + +function save(propType, propData, extra = {}) { + return userProp({ body: { uid: "u1", token: "valid", action: "save", propType, propData, ...extra } }); +} + +for (const [propType, field, responseField] of [[2004, "hourglassAmount", "hourglass"], [2005, "rocketAmount", "rocket"]]) { + test(`${propType} saves only its own count and timestamp`, async () => { + reset(); + const original = { ...user }; + const result = await save(propType, JSON.stringify(7)); + assert.equal(result.code, 1); + assert.equal(result.data[responseField], 7); + assert.deepEqual(Object.keys(writes[0]).sort(), [field, "timestamp"].sort()); + assert.deepEqual(user, { ...original, [field]: 7, timestamp: writes[0].timestamp }); + }); + + test(`${propType} can consume the last item using string or numeric zero`, async () => { + for (const value of ["0", 0]) { + reset(); + assert.equal((await save(propType, value)).code, 1); + assert.equal(user[field], 0); + } + }); + + test(`${propType} does not undo concurrent changes to another prop or infinite entitlement`, async () => { + reset(); + const otherField = propType === 2004 ? "rocketAmount" : "hourglassAmount"; + beforeUpdate = () => Object.assign(user, { hammerAmount: 8, [otherField]: 9, infiniteRocketTime: 999 }); + await save(propType, "2"); + assert.equal(user[field], 2); + assert.equal(user.hammerAmount, 8); + assert.equal(user[otherField], 9); + assert.equal(user.infiniteRocketTime, 999); + }); + + test(`${propType} rejects invalid inventory amounts without writing`, async () => { + for (const value of [undefined, null, "", " ", -1, "-1", 1.5, "NaN", Infinity, true, {}, []]) { + reset(); + assert.equal((await save(propType, value)).code, 0); + assert.deepEqual(writes, []); + } + }); +} + +test("invalid token cannot change either new prop", async () => { + for (const propType of [2004, 2005]) { + reset(); + assert.equal((await save(propType, "3", { token: "invalid" })).code, 0); + assert.deepEqual(writes, []); + } +}); + +test("legacy single-prop and all-prop requests retain their existing behavior", async () => { + reset(); + assert.equal((await save(2001, "0")).code, 1); + assert.equal(user.freezeAmount, 0); + assert.equal((await save(2001, 0)).code, 0); + assert.equal((await save(0, JSON.stringify({ hourglass: 6, rocket: 0, infinite_hourglass: 123 }))).code, 1); + assert.equal(user.hourglassAmount, 6); + assert.equal(user.rocketAmount, 0); + assert.equal(user.infiniteHourglassTime, 123); + assert.equal(user.hammerAmount, 2); +});