fix(platform): apply room action battle flags

This commit is contained in:
2026-09-05 03:44:32 +08:00
parent 9840ed70b2
commit 72227efd05
2 changed files with 194 additions and 1 deletions
@@ -369,6 +369,16 @@ function parseSeatAction(value: ParsedSeatPayload): {
return { seat, raw }; return { seat, raw };
} }
function stageAfterOptionalDeskwar(
raw: Readonly<Record<string, unknown>>,
currentStage: number,
): number {
if (!hasOwn(raw, 'deskwar')) return currentStage;
return requireProtocolFlag(raw.deskwar, '$.event.raw.deskwar') === 1
? 1
: currentStage;
}
function requireOccupiedSeat(room: InsideRoomState, seat: number): number { function requireOccupiedSeat(room: InsideRoomState, seat: number): number {
if (seat < 0 || seat >= room.seatPlayerIds.length) { if (seat < 0 || seat >= room.seatPlayerIds.length) {
throw new RangeError('$.event.seat: expected room seat range'); throw new RangeError('$.event.seat: expected room seat range');
@@ -572,6 +582,7 @@ export class PlatformStore {
if (room.seatPlayerIds.includes(joined.player.playerid)) { if (room.seatPlayerIds.includes(joined.player.playerid)) {
throw new RangeError('$.event.raw.playerid: duplicate player seat'); throw new RangeError('$.event.raw.playerid: duplicate player seat');
} }
const stage = stageAfterOptionalDeskwar(event.raw, room.stage);
const player = Object.freeze(joined.player); const player = Object.freeze(joined.player);
const entities = Object.freeze({ const entities = Object.freeze({
@@ -598,6 +609,7 @@ export class PlatformStore {
seatPlayerIds: Object.freeze(seatPlayerIds), seatPlayerIds: Object.freeze(seatPlayerIds),
readySeats: Object.freeze(readySeats), readySeats: Object.freeze(readySeats),
offlineSeats: Object.freeze(offlineSeats), offlineSeats: Object.freeze(offlineSeats),
stage,
}), }),
})); }));
} }
@@ -614,6 +626,7 @@ export class PlatformStore {
if (player === undefined) { if (player === undefined) {
throw new RangeError('players.entities: occupied seat has no canonical player'); throw new RangeError('players.entities: occupied seat has no canonical player');
} }
const stage = stageAfterOptionalDeskwar(event.raw, room.stage);
const entities = Object.freeze({ const entities = Object.freeze({
...previous.players.entities, ...previous.players.entities,
[playerid]: Object.freeze({ ...player, isprepare: 1 }), [playerid]: Object.freeze({ ...player, isprepare: 1 }),
@@ -625,7 +638,7 @@ export class PlatformStore {
selfPlayerId: previous.players.selfPlayerId, selfPlayerId: previous.players.selfPlayerId,
entities, entities,
}), }),
room: Object.freeze({ ...room, readySeats }), room: Object.freeze({ ...room, readySeats, stage }),
})); }));
} }
@@ -120,6 +120,106 @@ test('playerJoin atomically adds one canonical entity and its seat id', () => {
assert.equal(Object.isFrozen(state.players.entities[77]), true); assert.equal(Object.isFrozen(state.players.entities[77]), true);
}); });
test('playerJoin applies deskwar battle start in the same root commit', () => {
const store = new PlatformStore();
const loginData = fixture('player-login-room.json').data;
loginData.isbattle = 0;
store.applyLoginSuccess(parseLoginResponse(loginData));
const before = store.getState();
assert.equal(before.room.kind, 'inside');
if (before.room.kind !== 'inside') return;
assert.equal(before.room.stage, 0);
const transitions: PlatformState[] = [];
store.subscribe((state) => { transitions.push(state); });
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2,
playerid: 77,
nickname: '对家',
avatar: 'http://b',
sex: 1,
ip: '127.0.0.2',
onstate: 0,
bean: 90,
isprepare: 0,
deskwar: 1,
}));
const after = store.getState();
assert.equal(transitions.length, 1);
assert.equal(transitions[0], after);
assert.equal(after.room.kind, 'inside');
if (after.room.kind !== 'inside') return;
assert.equal(after.room.stage, 1);
assert.equal(after.room.seatPlayerIds[2], 77);
assert.equal(after.players.entities[77]?.playerid, 77);
});
test('playerJoin preserves the current stage when deskwar is absent or zero', () => {
for (const initialStage of [0, 1] as const) {
for (const deskwar of [undefined, 0] as const) {
const store = new PlatformStore();
const loginData = fixture('player-login-room.json').data;
loginData.isbattle = initialStage;
store.applyLoginSuccess(parseLoginResponse(loginData));
const joinData: Record<string, unknown> = {
seat: 2,
playerid: 77,
nickname: '对家',
avatar: 'http://b',
sex: 1,
ip: '127.0.0.2',
onstate: 0,
bean: 90,
isprepare: 0,
};
if (deskwar !== undefined) joinData.deskwar = deskwar;
store.playerJoin(parseOtherJoinRoomPayload(joinData));
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') continue;
assert.equal(
state.room.stage,
initialStage,
`initial stage ${initialStage}, deskwar ${String(deskwar)}`,
);
}
}
});
test('playerJoin rejects an invalid own deskwar atomically without changing input', () => {
for (const deskwar of [2, -1, true, '1', null, undefined]) {
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
const joinData: Record<string, unknown> = {
seat: 2,
playerid: 77,
nickname: '对家',
avatar: 'http://b',
sex: 1,
ip: '127.0.0.2',
onstate: 0,
bean: 90,
isprepare: 0,
deskwar,
};
const originalInput = { ...joinData };
const parsed = parseOtherJoinRoomPayload(joinData);
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.playerJoin(parsed), /deskwar/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
assert.deepEqual(joinData, originalInput);
assert.equal(Object.prototype.hasOwnProperty.call(joinData, 'deskwar'), true);
assert.equal(Object.isFrozen(joinData), false);
}
});
test('playerReady replaces the affected player and room subtrees without prior mutation', () => { test('playerReady replaces the affected player and room subtrees without prior mutation', () => {
const store = new PlatformStore(); const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
@@ -147,6 +247,86 @@ test('playerReady replaces the affected player and room subtrees without prior m
assert.equal(Object.isFrozen(after.room.readySeats), true); assert.equal(Object.isFrozen(after.room.readySeats), true);
}); });
test('playerReady applies deskwar battle start in the same root commit', () => {
const store = new PlatformStore();
const loginData = fixture('player-login-room.json').data;
loginData.isbattle = 0;
store.applyLoginSuccess(parseLoginResponse(loginData));
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
const before = store.getState();
assert.equal(before.room.kind, 'inside');
if (before.room.kind !== 'inside') return;
assert.equal(before.room.stage, 0);
const transitions: PlatformState[] = [];
store.subscribe((state) => { transitions.push(state); });
store.playerReady(parsePlayerPreparePayload({ seat: 2, deskwar: 1 }));
const after = store.getState();
assert.equal(transitions.length, 1);
assert.equal(transitions[0], after);
assert.equal(after.room.kind, 'inside');
if (after.room.kind !== 'inside') return;
assert.equal(after.room.stage, 1);
assert.deepEqual(after.room.readySeats, [1, 2]);
assert.equal(after.players.entities[77]?.isprepare, 1);
});
test('playerReady preserves the current stage when deskwar is absent or zero', () => {
for (const initialStage of [0, 1] as const) {
for (const deskwar of [undefined, 0] as const) {
const store = new PlatformStore();
const loginData = fixture('player-login-room.json').data;
loginData.isbattle = initialStage;
store.applyLoginSuccess(parseLoginResponse(loginData));
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
const prepareData: Record<string, unknown> = { seat: 2 };
if (deskwar !== undefined) prepareData.deskwar = deskwar;
store.playerReady(parsePlayerPreparePayload(prepareData));
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') continue;
assert.equal(
state.room.stage,
initialStage,
`initial stage ${initialStage}, deskwar ${String(deskwar)}`,
);
}
}
});
test('playerReady rejects an invalid own deskwar atomically without changing input', () => {
for (const deskwar of [2, -1, true, '1', null, undefined]) {
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
const prepareData: Record<string, unknown> = { seat: 2, deskwar };
const originalInput = { ...prepareData };
const parsed = parsePlayerPreparePayload(prepareData);
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.playerReady(parsed), /deskwar/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
assert.deepEqual(prepareData, originalInput);
assert.equal(Object.prototype.hasOwnProperty.call(prepareData, 'deskwar'), true);
assert.equal(Object.isFrozen(prepareData), false);
}
});
test('playerOffline records protocol and room status with one atomic commit', () => { test('playerOffline records protocol and room status with one atomic commit', () => {
const store = new PlatformStore(); const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));