import { ActionCreator, createActionCreator } from './createActionCreator' // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a // dependency on the package itself - this factory otherwise has no opinion on whether the // consuming app uses redux-persist at all, and the string itself is a long-stable part of // redux-persist's own public contract (unchanged across every major version). See the reducer's // own REHYDRATE case below for why this needs handling at all. const REHYDRATE = 'persist/REHYDRATE' export interface SelectProfilePayload { profileId: string | null seat: TSeat } export interface ProfileSelectionActions { clearProfile: ActionCreator pruneToRoster: ActionCreator select: ActionCreator> } export type ProfileSelectionState = Record export interface ProfileSelectionSlice { actions: ProfileSelectionActions reducer: (state: ProfileSelectionState | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState } // Which profile each seat last picked - always local, never part of the shared App Group roster // (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is // naturally a per-app, per-device thing, not something a sibling @tastic game should see or // influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled // an identical Record reducer before this factory existed - same duplication // createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for // per-seat selection instead. // // `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with // more than one independent seat-selection concern never collides between them - same convention // createProfileExtensionSlice's own namespace argument follows. // // Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't // exist at runtime), so callers pass their own `defaultState` - the same literal object (e.g. // `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState` // constant today. `clearProfile` walks that state's own keys rather than assuming any particular // seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2". // // `mountKey` defaults to 'profileSelection' - the literal key every current consumer (AirHockey, // BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own // combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be // passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by // whatever name the app's own root reducer happens to use, which this factory has no way to see // from inside its own reducer function - only worth overriding if some future consumer ever mounts // this under a different key. Note `namespace` above is a different thing: it's the app's own name // (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key. // // `pruneToRoster(ids)` nulls every seat whose profile id is no longer in the given roster. Call it // after any profilesActions.setAll (initial reconcile, onRemoteChange), since a sibling app can // delete a profile without this app's own deleteProfile ever running. It returns the same state // object when nothing changes, so dispatching it on every foreground resume costs no re-render. // // One profile is never on two seats. `select` picking a profile another seat already has swaps // the two: that seat takes this seat's previous pick (a profile, or null for guest), the same rule // as @tastic/input's assignSeatScheme. A picker's takenProfileId only hides the other seat's // profile while both seats are on screen, so without the swap a 1 Player pick of seat 2's saved // profile left both seats on it, and every later 2 Player round recorded a win and a loss for that // one profile. A host showing per-seat state tied to the selection (a profile's color override) // should reset it for both seats when a pick swaps. REHYDRATE also drops any duplicate saved before // this rule existed, keeping the profile on the first seat. export function createProfileSelectionSlice(namespace: string, defaultState: ProfileSelectionState, mountKey = 'profileSelection'): ProfileSelectionSlice { const select = createActionCreator>(`profileSelection/${namespace}/select`) const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`) const pruneToRoster = createActionCreator(`profileSelection/${namespace}/pruneToRoster`) function reducer(state: ProfileSelectionState = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState { // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's // entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) - // so a device with a blob persisted before a new seat existed would otherwise rehydrate with // that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no // fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly // that gap - the same pattern createSeatColorsSlice's own REHYDRATE case already uses. if (action.type === REHYDRATE) { const persisted = (action.payload as Record>> | undefined)?.[mountKey] const merged = { ...defaultState, ...state, ...persisted } const seen = new Set() for (const seat of Object.keys(merged) as TSeat[]) { const profileId = merged[seat] if (profileId === null) continue if (seen.has(profileId)) merged[seat] = null else seen.add(profileId) } return merged } if (select.match(action)) { const { profileId, seat } = action.payload const previous = state[seat] ?? null const next = { ...state, [seat]: profileId } as ProfileSelectionState if (profileId !== null) { // Object.keys yields strings, so a numeric seat compares as a string. for (const other of Object.keys(state) as TSeat[]) { if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous } } return next } if (clearProfile.match(action)) { const next = { ...state } for (const seat of Object.keys(state) as TSeat[]) { if (next[seat] === action.payload) next[seat] = null } return next } if (pruneToRoster.match(action)) { const roster = new Set(action.payload) const next = { ...state } let changed = false for (const seat of Object.keys(state) as TSeat[]) { const profileId = state[seat] if (profileId !== null && !roster.has(profileId)) { next[seat] = null changed = true } } return changed ? next : state } return state } return { actions: { clearProfile, pruneToRoster, select }, reducer } }