@tastic/profile 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
+ import { Profile } from '../types'
1
2
  import { ActionCreator, createActionCreator } from './createActionCreator'
3
+ import { profilesActions } from './profilesSlice'
2
4
 
3
5
  // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
4
6
  // dependency on the package itself - this factory otherwise has no opinion on whether the
@@ -12,21 +14,32 @@ export interface SetLastGuestColorPayload<TSeat> {
12
14
  seat: TSeat
13
15
  }
14
16
 
15
- export interface SetProfileOverridePayload<TSeat> {
17
+ // A color picked for a profile in this game: it holds while the profile keeps the color it had
18
+ // when the pick was made (see profileSeatColor).
19
+ export interface ProfileColorPick {
20
+ color: string
21
+ profileColor: string
22
+ }
23
+
24
+ export interface SetProfileColorPayload {
25
+ // The profile the pick is for, with its own color as it is now.
26
+ profile: Pick<Profile, 'id' | 'color'>
27
+ // The pick. Null, or the profile's own color, drops it, so the seat follows the profile's color
28
+ // again.
16
29
  color: string | null
17
- seat: TSeat
18
30
  }
19
31
 
20
32
  export interface SeatColorsActions<TSeat> {
21
33
  setLastCpuColor: ActionCreator<string>
22
34
  setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>
23
- setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>
35
+ setProfileColor: ActionCreator<SetProfileColorPayload>
24
36
  }
25
37
 
26
38
  export type SeatColorsState<TSeat extends string | number> = {
27
39
  lastCpuColor: string
28
40
  lastGuestColor: Record<TSeat, string>
29
- profileOverride: Record<TSeat, string | null>
41
+ // Each profile's pick in this game, by profile id. A profile not listed shows its own color.
42
+ profileColors: Record<string, ProfileColorPick>
30
43
  }
31
44
 
32
45
  export interface SeatColorsSlice<TSeat extends string | number> {
@@ -34,24 +47,33 @@ export interface SeatColorsSlice<TSeat extends string | number> {
34
47
  reducer: (state: SeatColorsState<TSeat> | undefined, action: { type: string; payload?: unknown }) => SeatColorsState<TSeat>
35
48
  }
36
49
 
50
+ function sameColor(a: string, b: string) {
51
+ return a.toLowerCase() === b.toLowerCase()
52
+ }
53
+
54
+ // The color a seat with `profile` selected shows: the profile's pick in this game, while the profile
55
+ // still has the color the pick was made against, else the profile's own color. So a new color for
56
+ // the profile itself (on any game's Profiles screen, this one or a sibling's through the shared
57
+ // roster) replaces an older pick, while a rename or a new tag keeps it.
58
+ export function profileSeatColor(profile: Pick<Profile, 'id' | 'color'>, profileColors: Record<string, ProfileColorPick>): string {
59
+ const pick = profileColors[profile.id]
60
+ return pick && sameColor(pick.profileColor, profile.color) ? pick.color : profile.color
61
+ }
62
+
37
63
  // Which color a seat should show right now, remembered independently for three sources that must
38
- // never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and a
39
- // manual recolor/clash-swap override landing on a seat that currently has a profile selected (null
40
- // meaning "no override, track that profile's own saved color live"). Four @tastic games (AirHockey,
41
- // BoxHockey, LightCycles, Pong) each hand-rolled an identical dedicated seatColorsSlice.ts - same
42
- // three fields, same createSlice-based reducer, differing only in their own seat type's name
43
- // (Player) and default-color constants - before this factory existed; this is that shape,
44
- // generalized over the seat type exactly like createProfileSelectionSlice generalized per-seat
45
- // profile selection.
64
+ // never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and each
65
+ // profile's own pick in this game (`profileColors`, by profile id, read through profileSeatColor).
66
+ // A profile's pick follows the profile, not the seat: it is kept when the seat switches to another
67
+ // profile and back, when the profile moves to the other seat, and across relaunches, the same as
68
+ // Solitaire's per-profile table color (2026-10-16; until then it was a per-seat `profileOverride`
69
+ // that any profile change on the seat cleared). Picking the profile's own color drops the pick, and
70
+ // so do a new color for the profile itself and deleting it (this package's profilesActions, which
71
+ // this reducer also listens to). Five @tastic games (AirHockey, BoxHockey,
72
+ // LightCycles, Pong, Snake) use this factory; the first four each hand-rolled an identical
73
+ // dedicated seatColorsSlice.ts before it existed.
46
74
  //
47
- // Snake has the same three fields too, but folded directly into its own catch-all gameSlice.ts
48
- // alongside a dozen unrelated persisted settings (wrapEdges, cpuDifficulty, speedTier, ...) rather
49
- // than in a standalone slice of their own. There's no separate `seatColors` sub-state there for this
50
- // factory's reducer to own without restructuring gameSlice's top-level shape to nest these three
51
- // fields under their own key first - a real state-shape migration (and a redux-persist rehydration
52
- // concern, see gameSlice's own REHYDRATE extraReducer), not a drop-in swap. This factory
53
- // intentionally targets the standalone-slice shape the other four apps already have; Snake staying
54
- // hand-rolled inside gameSlice is a deliberate scope call for this pass, not an oversight.
75
+ // A store saved before profileColors existed has a `profileOverride` key instead; REHYDRATE drops
76
+ // it, so every profile starts on its own color once.
55
77
  //
56
78
  // `namespace` prefixes every action type (e.g. 'seatColors/loadout/setLastGuestColor') so an app
57
79
  // with more than one independent seat-colors concern never collides between them - same convention
@@ -71,26 +93,54 @@ export interface SeatColorsSlice<TSeat extends string | number> {
71
93
  export function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey = 'seatColors'): SeatColorsSlice<TSeat> {
72
94
  const setLastCpuColor = createActionCreator<string>(`seatColors/${namespace}/setLastCpuColor`)
73
95
  const setLastGuestColor = createActionCreator<SetLastGuestColorPayload<TSeat>>(`seatColors/${namespace}/setLastGuestColor`)
74
- const setProfileOverride = createActionCreator<SetProfileOverridePayload<TSeat>>(`seatColors/${namespace}/setProfileOverride`)
96
+ const setProfileColor = createActionCreator<SetProfileColorPayload>(`seatColors/${namespace}/setProfileColor`)
97
+
98
+ function withoutPick(state: SeatColorsState<TSeat>, id: string): SeatColorsState<TSeat> {
99
+ if (!state.profileColors[id]) return state
100
+ const profileColors = { ...state.profileColors }
101
+ delete profileColors[id]
102
+ return { ...state, profileColors }
103
+ }
75
104
 
76
105
  function reducer(state: SeatColorsState<TSeat> = defaultState, action: { type: string; payload?: unknown }): SeatColorsState<TSeat> {
77
106
  // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
78
107
  // entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
79
108
  // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) -
80
- // so a device with a blob persisted before `profileOverride` existed would otherwise rehydrate
81
- // with `state.profileOverride === undefined`, and every consumer indexes into it (`profileOverride[seat]`)
82
- // with no fallback, throwing. Spreading `defaultState` first, then the incoming persisted
83
- // value, backfills exactly that gap - the same pattern Snake's own hand-rolled `gameSlice.ts`
84
- // already uses for its equivalent (non-factory) fields via its own REHYDRATE extraReducer.
109
+ // so a device with a blob persisted before a field existed would otherwise rehydrate without
110
+ // it, and every consumer indexes into these fields with no fallback, throwing. Spreading
111
+ // `defaultState` first, then the incoming persisted value, backfills exactly that gap. A
112
+ // reducer that returns a new object here keeps autoMergeLevel1 from putting the saved blob back
113
+ // over it, which is what lets the old `profileOverride` key be dropped.
85
114
  if (action.type === REHYDRATE) {
86
115
  const persisted = (action.payload as Record<string, Partial<SeatColorsState<TSeat>>> | undefined)?.[mountKey]
87
- return { ...defaultState, ...state, ...persisted }
116
+ const next: SeatColorsState<TSeat> & { profileOverride?: unknown } = { ...defaultState, ...state, ...persisted }
117
+ delete next.profileOverride
118
+ return next
88
119
  }
89
120
  if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload }
90
121
  if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } as Record<TSeat, string> }
91
- if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } as Record<TSeat, string | null> }
122
+ if (setProfileColor.match(action)) {
123
+ const { profile, color } = action.payload
124
+ if (color === null || sameColor(color, profile.color)) return withoutPick(state, profile.id)
125
+ return { ...state, profileColors: { ...state.profileColors, [profile.id]: { color, profileColor: profile.color } } }
126
+ }
127
+ if (profilesActions.remove.match(action)) return withoutPick(state, action.payload)
128
+ // A new color for the profile itself replaces its pick: this game's Profiles screen (update), or
129
+ // a sibling's through the shared roster (setAll). A pick for a profile missing from the roster is
130
+ // left alone, like the selection's own pruneToRoster leaves stats alone.
131
+ if (profilesActions.update.match(action)) {
132
+ const { id, patch } = action.payload
133
+ const pick = state.profileColors[id]
134
+ return pick && patch.color !== undefined && !sameColor(patch.color, pick.profileColor) ? withoutPick(state, id) : state
135
+ }
136
+ if (profilesActions.setAll.match(action)) {
137
+ return action.payload.reduce((next, profile) => {
138
+ const pick = next.profileColors[profile.id]
139
+ return pick && !sameColor(profile.color, pick.profileColor) ? withoutPick(next, profile.id) : next
140
+ }, state)
141
+ }
92
142
  return state
93
143
  }
94
144
 
95
- return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer }
145
+ return { actions: { setLastCpuColor, setLastGuestColor, setProfileColor }, reducer }
96
146
  }