From e00d0511126049ab06e301117aaa6e5c30be2f35 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 14:51:24 +0800 Subject: [PATCH] feat(local): compose login account and device sources --- .../framework/adapters/local/local-storage.ts | 11 ++ .../framework/adapters/local/login-sources.ts | 120 ++++++++++++ .../adapters/local/visitor-account.ts | 8 +- .../adapters/local/login-sources.test.ts | 173 ++++++++++++++++++ 4 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/adapters/local/login-sources.ts create mode 100644 cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/local-storage.ts b/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/local-storage.ts index 99165e5..28c18a9 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/local-storage.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/local-storage.ts @@ -7,6 +7,17 @@ export interface LocalStoragePort { export const LOCAL_MACHINE_KEY = 'youle.local-test.machine.v1'; +export function sampleLocalRandom( + random: () => number, + failure: (reason: string, cause?: unknown) => Error, +): number { + let value: number; + try { value = random(); } catch (cause) { throw failure('source failed', cause); } + if (!Number.isFinite(value)) throw failure('expected finite number'); + if (value < 0 || value >= 1) throw failure('expected range [0, 1)'); + return value; +} + export function localAccountKey(context: LocalStartupContext): string { assertLocalStartupContext(context); const { identity } = context.config; diff --git a/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/login-sources.ts b/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/login-sources.ts new file mode 100644 index 0000000..dd502e8 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/login-sources.ts @@ -0,0 +1,120 @@ +import type { LocalStartupContext } from '../../config/local-startup.ts'; +import type { LoginAccountIdentity, LoginDeviceSnapshot } from '../../protocol/contracts/index.ts'; +import { LOCAL_MACHINE_KEY, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts'; +import { createLocalVisitorProvider } from './visitor-account.ts'; + +export interface LocalHostSnapshot { + readonly location: unknown; + readonly returnCitySN?: unknown; +} + +export interface LocalLoginSources { + prepareLogin(): Promise; + getLoginDeviceSnapshot(): LoginDeviceSnapshot; + resetAccount(): Promise; +} + +interface Options { + readonly context: LocalStartupContext; + readonly storage: LocalStoragePort; + readonly random: () => number; + readonly now: () => number; + readonly sampleHost: () => LocalHostSnapshot; + readonly isSessionActive: () => boolean; +} + +function machineFailure(path: string, reason: string, cause?: unknown): Error { + const error = new Error(`Invalid local machine cache ${LOCAL_MACHINE_KEY} at ${path}: ${reason}`) as Error & { cause?: unknown }; + if (cause !== undefined) error.cause = cause; + return error; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function getMachineId(options: Options): string { + let raw: string | null; + try { raw = options.storage.getItem(LOCAL_MACHINE_KEY); } + catch (cause) { throw machineFailure('$', 'storage read failed', cause); } + if (raw !== null) { + let record: unknown; + try { record = JSON.parse(raw); } + catch (cause) { throw machineFailure('$', 'invalid JSON', cause); } + if (!isRecord(record)) throw machineFailure('$', 'expected object'); + if (record.schemaVersion !== 1) throw machineFailure('schemaVersion', 'expected 1'); + for (const field of Object.keys(record)) { + if (field !== 'schemaVersion' && field !== 'machineid') throw machineFailure(field, 'unknown field'); + } + if (typeof record.machineid !== 'string') throw machineFailure('machineid', 'expected string'); + const match = /^(0|[1-9][0-9]*)-([1-9][0-9]{3})$/.exec(record.machineid); + if (!match || !Number.isSafeInteger(Number(match[1])) || Number(match[2]) > 9998) { + throw machineFailure('machineid', 'expected nonnegative safe timestamp and suffix 1000..9998'); + } + return record.machineid; + } + let timestamp: number; + try { timestamp = options.now(); } + catch (cause) { throw machineFailure('now', 'source failed', cause); } + if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw machineFailure('now', 'expected nonnegative safe integer'); + const random = sampleLocalRandom(options.random, (reason, cause) => machineFailure('random', reason, cause)); + const machineid = `${timestamp}-${1000 + Math.floor(random * 8999)}`; + try { options.storage.setItem(LOCAL_MACHINE_KEY, JSON.stringify({ schemaVersion: 1, machineid })); } + catch (cause) { throw machineFailure('$', 'storage write failed', cause); } + return machineid; +} + +export function createLocalLoginSources(options: Options): LocalLoginSources { + const provider = createLocalVisitorProvider(options); + let device: LoginDeviceSnapshot | undefined; + let preparing: Promise | undefined; + let resetting: Promise | undefined; + + function prepareLogin(): Promise { + if (resetting) return Promise.reject(new Error('Cannot prepare login while reset is pending')); + if (preparing) return preparing; + device = undefined; + preparing = (async () => { + const cachedAccount = await provider.getAccount(); + const machineid = getMachineId(options); + const snapshot = options.sampleHost(); + if (!isRecord(snapshot) || !Object.prototype.hasOwnProperty.call(snapshot, 'location') + || (snapshot.location !== null && !isRecord(snapshot.location))) { + throw new Error('Invalid local host location: expected explicit null or record'); + } + const city = snapshot.returnCitySN; + if (city !== undefined) { + if (!isRecord(city)) throw new Error('Invalid local host returnCitySN: expected record'); + for (const field of ['ip', 'province', 'city'] as const) { + if (typeof city[field] !== 'string') throw new Error(`Invalid local host returnCitySN.${field}: expected string`); + } + } + const account: LoginAccountIdentity = Object.freeze({ ...cachedAccount, + ...(city === undefined ? {} : { province: city.province as string, city: city.city as string }), + }); + const nextDevice: LoginDeviceSnapshot = Object.freeze({ location: snapshot.location, + machineid, machineroom: '', deviceLogin: Object.freeze({ enabled: false as const }), + loginPlayerId: Object.freeze({ enabled: false as const }), + ...(city === undefined ? {} : { ip: city.ip as string }), + }); + device = nextDevice; + return account; + })().finally(() => { preparing = undefined; }); + return preparing; + } + + function resetAccount(): Promise { + device = undefined; + if (preparing) return Promise.reject(new Error('Cannot reset account while prepare is pending')); + if (resetting) return Promise.reject(new Error('Cannot reset account while reset is pending')); + resetting = provider.resetAccount().finally(() => { resetting = undefined; }); + return resetting; + } + + function getLoginDeviceSnapshot(): LoginDeviceSnapshot { + if (device === undefined) throw new Error('Local login device requires a successful prepareLogin'); + return device; + } + + return Object.freeze({ prepareLogin, resetAccount, getLoginDeviceSnapshot }); +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/visitor-account.ts b/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/visitor-account.ts index f1dbd09..8e33121 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/visitor-account.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/adapters/local/visitor-account.ts @@ -1,6 +1,6 @@ import type { LoginAccountIdentity } from '../../protocol/contracts/index.ts'; import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts'; -import { localAccountKey, type LocalStoragePort } from './local-storage.ts'; +import { localAccountKey, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts'; const AVATARS = Object.freeze([ 'https://img2.baidu.com/it/u=1823726962,2692568839&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', @@ -67,11 +67,7 @@ function parseCached(raw: string, key: string): LoginAccountIdentity { } function sample(random: () => number, key: string): number { - let value: number; - try { value = random(); } catch (cause) { throw wrap(key, 'random', 'source failed', cause); } - if (!Number.isFinite(value)) throw wrap(key, 'random', 'expected finite number'); - if (value < 0 || value >= 1) throw wrap(key, 'random', 'expected range [0, 1)'); - return value; + return sampleLocalRandom(random, (reason, cause) => wrap(key, 'random', reason, cause)); } function generate(random: () => number, key: string): LoginAccountIdentity { diff --git a/cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts b/cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts new file mode 100644 index 0000000..9d116ca --- /dev/null +++ b/cocoscreator_projects/framework-tests/adapters/local/login-sources.test.ts @@ -0,0 +1,173 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts'; +import { PROFILES } from '../../../YouleNexus/assets/framework/config/profiles.ts'; +import type { LocalStartupContext } from '../../../YouleNexus/assets/framework/config/local-startup.ts'; +import { LOCAL_MACHINE_KEY, localAccountKey, type LocalStoragePort } from '../../../YouleNexus/assets/framework/adapters/local/local-storage.ts'; +import { createLocalLoginSources, type LocalHostSnapshot } from '../../../YouleNexus/assets/framework/adapters/local/login-sources.ts'; + +const context: LocalStartupContext = { hostKind: 'h5', profile: 'local', config: { + mode: 'debug', isDebugger: true, source: 'direct', gameserver: null, rawConfig: null, + servers: PROFILES.local!.servers!, identity: BUILD_IDENTITY, +} }; +class MapStorage implements LocalStoragePort { + readonly values = new Map(); + getItem(key: string): string | null { return this.values.get(key) ?? null; } + setItem(key: string, value: string): void { this.values.set(key, value); } +} +function fixture(overrides: Partial[0]> = {}) { + return createLocalLoginSources({ context, storage: new MapStorage(), random: () => 0.5, + now: () => 123456, sampleHost: () => ({ location: null }), isSessionActive: () => false, ...overrides }); +} + +test('one host sample supplies frozen account and device without rewriting account cache', async () => { + const storage = new MapStorage(); + let samples = 0; + const city = { ip: '10.0.0.2', province: 'P', city: 'C' }; + const location = { unknownProtocolField: [1] }; + const sources = fixture({ storage, sampleHost: () => { samples++; return { location, returnCitySN: city }; } }); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); + const account = await sources.prepareLogin(); + const device = sources.getLoginDeviceSnapshot(); + assert.equal(samples, 1); + assert.equal(account.province, 'P'); + assert.equal(account.city, 'C'); + assert.equal(device.machineid, '123456-5499'); + assert.strictEqual(device.location, location); + assert.equal(device.ip, '10.0.0.2'); + assert.equal(device.machineroom, ''); + assert.deepEqual(device.deviceLogin, { enabled: false }); + assert.deepEqual(device.loginPlayerId, { enabled: false }); + for (const value of [account, device, device.deviceLogin, device.loginPlayerId]) assert.ok(Object.isFrozen(value)); + city.ip = 'changed'; city.province = 'changed'; city.city = 'changed'; + assert.equal(device.ip, '10.0.0.2'); + assert.equal(account.province, 'P'); + assert.equal(account.city, 'C'); + assert.equal(JSON.parse(storage.values.get(localAccountKey(context))!).account.province, 'jiangxi'); +}); + +test('undefined city omits ip and retains generated province and city', async () => { + const sources = fixture(); + const account = await sources.prepareLogin(); + assert.equal(account.province, 'jiangxi'); + assert.equal(account.city, 'nanchang'); + assert.equal(Object.prototype.hasOwnProperty.call(sources.getLoginDeviceSnapshot(), 'ip'), false); +}); + +test('malformed host input fails and leaves no device', async () => { + for (const host of [{}, { location: undefined }, { location: [] }, { location: 0 }, + { location: null, returnCitySN: null }, { location: null, returnCitySN: [] }, + ...['ip', 'province', 'city'].map(field => ({ location: null, returnCitySN: { ip: '', province: '', city: '', [field]: 3 } }))]) { + const sources = fixture({ sampleHost: () => host as LocalHostSnapshot }); + await assert.rejects(sources.prepareLogin(), /location|returnCitySN/); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); + } +}); + +test('machine cache is persisted and reused across source instances without now or random', async () => { + const storage = new MapStorage(); + await fixture({ storage }).prepareLogin(); + assert.deepEqual(JSON.parse(storage.values.get(LOCAL_MACHINE_KEY)!), { schemaVersion: 1, machineid: '123456-5499' }); + const sources = fixture({ storage, now: () => { throw Error('unexpected now'); }, random: () => { throw Error('unexpected random'); } }); + await sources.prepareLogin(); + assert.equal(sources.getLoginDeviceSnapshot().machineid, '123456-5499'); +}); + +test('damaged machine records fail without replacing the stored value', async () => { + for (const raw of ['bad json', 'null', '[]', '{}', ...[ + { schemaVersion: 2, machineid: '1-1000' }, { schemaVersion: 1, machineid: '' }, + { schemaVersion: 1, machineid: 3 }, { schemaVersion: 1, machineid: 'bad' }, + { schemaVersion: 1, machineid: '-1-1000' }, { schemaVersion: 1, machineid: '1-999' }, + { schemaVersion: 1, machineid: '1-9999' }, { schemaVersion: 1, machineid: '9007199254740992-1000' }, + { schemaVersion: 1, machineid: '1-1000', extra: true }, + ].map(value => JSON.stringify(value))]) { + const storage = new MapStorage(); storage.values.set(LOCAL_MACHINE_KEY, raw); + const sources = fixture({ storage }); + await assert.rejects(sources.prepareLogin(), /youle\.local-test\.machine\.v1/); + assert.equal(storage.values.get(LOCAL_MACHINE_KEY), raw); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); + } +}); + +test('machine generation validates timestamp and random boundaries', async () => { + const storage = new MapStorage(); await fixture({ storage }).prepareLogin(); + storage.values.delete(LOCAL_MACHINE_KEY); + for (const now of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await assert.rejects(fixture({ storage, now: () => now }).prepareLogin(), /now|timestamp/); + assert.equal(storage.values.has(LOCAL_MACHINE_KEY), false); + } + for (const random of [-0.1, 1, NaN, Infinity]) { + await assert.rejects(fixture({ storage, random: () => random }).prepareLogin(), /random/); + assert.equal(storage.values.has(LOCAL_MACHINE_KEY), false); + } + for (const [random, expected] of [[0, '0-1000'], [0.999999, '0-9998']] as const) { + storage.values.delete(LOCAL_MACHINE_KEY); + const sources = fixture({ storage, random: () => random, now: () => 0 }); + await sources.prepareLogin(); + assert.equal(sources.getLoginDeviceSnapshot().machineid, expected); + } +}); + +test('machine storage and generation failures preserve the original cause', async () => { + for (const operation of ['read', 'write', 'now', 'random'] as const) { + const stored = new MapStorage(); await fixture({ storage: stored }).prepareLogin(); stored.values.delete(LOCAL_MACHINE_KEY); + const cause = new Error(operation); + const sources = fixture({ storage: { + getItem(key) { if (key === LOCAL_MACHINE_KEY && operation === 'read') throw cause; return stored.getItem(key); }, + setItem(key, value) { if (key === LOCAL_MACHINE_KEY && operation === 'write') throw cause; stored.setItem(key, value); }, + }, now: () => { if (operation === 'now') throw cause; return 0; }, + random: () => { if (operation === 'random') throw cause; return 0; } }); + await assert.rejects(sources.prepareLogin(), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /youle\.local-test\.machine\.v1/); + assert.strictEqual((error as Error & { cause?: unknown }).cause, cause); return true; + }); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); + } +}); + +test('concurrent prepares merge and reset conflicts in both directions', async () => { + let samples = 0; + const sources = fixture({ sampleHost: () => { samples++; return { location: null }; } }); + const first = sources.prepareLogin(); const second = sources.prepareLogin(); + await assert.rejects(sources.resetAccount(), /pending|prepare/i); + const [a, b] = await Promise.all([first, second]); + assert.strictEqual(a, b); assert.equal(samples, 1); + const reset = sources.resetAccount(); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); + const conflictingPrepare = sources.prepareLogin(); + const conflictingReset = sources.resetAccount(); + await assert.rejects(conflictingPrepare, /reset/i); + await assert.rejects(conflictingReset, /pending|reset/i); + await reset; +}); + +test('reset invalidates device and changes account while preserving the machine key', async () => { + const storage = new MapStorage(); let random = 0.5; + const sources = fixture({ storage, random: () => random }); + const old = await sources.prepareLogin(); const machine = storage.values.get(LOCAL_MACHINE_KEY); + random = 0; + const account = await sources.resetAccount(); + assert.notEqual(account.openid, old.openid); + assert.equal(storage.values.get(LOCAL_MACHINE_KEY), machine); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); + assert.equal((await sources.prepareLogin()).openid, account.openid); +}); + +test('prepare starts by invalidating prior device and failure cannot expose it', async () => { + let fail = false; + const sources = fixture({ sampleHost: () => { if (fail) throw Error('host failed'); return { location: null }; } }); + await sources.prepareLogin(); fail = true; + const pending = sources.prepareLogin(); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); + await assert.rejects(pending, /host failed/); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); +}); + +test('active-session reset rejects and invalidates the device without overwriting account', async () => { + const storage = new MapStorage(); const sources = fixture({ storage, isSessionActive: () => true }); + await sources.prepareLogin(); const old = storage.values.get(localAccountKey(context)); + await assert.rejects(sources.resetAccount(), /active session/i); + assert.equal(storage.values.get(localAccountKey(context)), old); + assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i); +});