Skip to content

Commit 159c76c

Browse files
FEATURE (discord): basic support for custom status
1 parent c10af8f commit 159c76c

File tree

18 files changed

+428
-61
lines changed

18 files changed

+428
-61
lines changed
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CommonModule } from '@angular/common';
22
import { NgModule } from '@angular/core';
3+
import { GameStateModule } from '@firestone/game-state';
34
import { MemoryModule } from '@firestone/memory';
45
import { SharedCommonServiceModule } from '@firestone/shared/common/service';
56
import { DiscordPresenceManagerService } from './services/discord-presence-manager.service';
@@ -8,7 +9,7 @@ import { DiscordRpcService } from './services/discord-rpc.service';
89
import { PresenceManagerService } from './services/presence-manager.service';
910

1011
@NgModule({
11-
imports: [CommonModule, SharedCommonServiceModule, MemoryModule],
12+
imports: [CommonModule, SharedCommonServiceModule, MemoryModule, GameStateModule],
1213
providers: [DiscordRpcPluginService, DiscordRpcService, DiscordPresenceManagerService, PresenceManagerService],
1314
})
1415
export class DiscordModule {}

libs/discord/src/lib/services/discord-presence-manager.service.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { filter } from 'rxjs';
44
import { DiscordRpcService } from './discord-rpc.service';
55
import { PresenceManagerService } from './presence-manager.service';
66

7+
export const IN_GAME_TEXT_PLACEHOLDER = 'In game';
8+
79
@Injectable()
810
export class DiscordPresenceManagerService {
911
constructor(
@@ -13,12 +15,13 @@ export class DiscordPresenceManagerService {
1315
this.init();
1416
}
1517

18+
// TODO: use different app ID based on premium/non premium to customize main text?
1619
private async init() {
1720
this.presenceManager.presence$$.pipe(filter((presence) => !!presence)).subscribe(async (presence) => {
1821
console.debug('[discord-presence] got new presence', presence);
1922
if (presence?.inGame && presence.enabled) {
2023
await this.discordRpc.updatePresence(
21-
'In game',
24+
IN_GAME_TEXT_PLACEHOLDER,
2225
'',
2326
'', // Use rank image?
2427
'', // Small image tooltip

libs/discord/src/lib/services/presence-manager.service.ts

+165-7
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
1+
/* eslint-disable no-mixed-spaces-and-tabs */
2+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
13
import { Injectable } from '@angular/core';
4+
import { GameFormat, GameType } from '@firestone-hs/reference-data';
5+
import { GameStateUpdatesService, Metadata } from '@firestone/game-state';
6+
import { MatchInfo, PlayerInfo, Rank } from '@firestone/memory';
27
import { GameStatusService, PreferencesService } from '@firestone/shared/common/service';
3-
import { BehaviorSubject, combineLatest, map, of, switchMap, tap } from 'rxjs';
8+
import { CardsFacadeService, ILocalizationService } from '@firestone/shared/framework/core';
9+
import { BehaviorSubject, combineLatest, distinctUntilChanged, map, of, shareReplay, switchMap, tap } from 'rxjs';
10+
import { IN_GAME_TEXT_PLACEHOLDER } from './discord-presence-manager.service';
411

512
@Injectable()
613
export class PresenceManagerService {
714
public presence$$ = new BehaviorSubject<Presence | null>(null);
815

9-
constructor(private readonly prefs: PreferencesService, private readonly gameStatus: GameStatusService) {
16+
constructor(
17+
private readonly prefs: PreferencesService,
18+
private readonly gameStatus: GameStatusService,
19+
private readonly gameStateUpdates: GameStateUpdatesService,
20+
private readonly i18n: ILocalizationService,
21+
private readonly allCards: CardsFacadeService,
22+
) {
1023
this.init();
1124
}
1225

1326
private async init() {
1427
await this.prefs.isReady();
1528
await this.gameStatus.isReady();
29+
await this.gameStateUpdates.isReady();
30+
await this.allCards.waitForReady();
1631

1732
this.prefs
1833
.preferences$((prefs) => prefs.discordRichPresence)
@@ -29,17 +44,160 @@ export class PresenceManagerService {
2944
});
3045
}
3146

47+
// TODO: support Arena (W-L), Duels (MMR + W-L), Battlegrounds (MMR), Mercs
3248
private buildPresence() {
33-
return combineLatest([this.gameStatus.inGame$$]).pipe(
34-
map(([inGame]) => ({
35-
enabled: true,
36-
inGame: inGame ?? false,
37-
})),
49+
const metaData$ = this.gameStateUpdates.gameState$$.pipe(
50+
map((gameState) => gameState?.metadata),
51+
distinctUntilChanged(),
52+
tap((metadata) => console.debug('[presence] new metadata', metadata)),
53+
shareReplay(1),
3854
);
55+
const matchInfo$ = this.gameStateUpdates.gameState$$.pipe(
56+
map((gameState) => gameState?.matchInfo),
57+
distinctUntilChanged(),
58+
tap((matchInfo) => console.debug('[presence] new matchInfo', matchInfo)),
59+
shareReplay(1),
60+
);
61+
const playerHero$ = this.gameStateUpdates.gameState$$.pipe(
62+
map((gameState) => gameState?.playerDeck?.hero?.cardId),
63+
distinctUntilChanged(),
64+
tap((hero) => console.debug('[presence] new hero', hero)),
65+
shareReplay(1),
66+
);
67+
return combineLatest([
68+
this.gameStatus.inGame$$,
69+
//TODO: premium status. Wait until we migrate to tebex, so that we can use the refactored services?
70+
this.prefs.preferences$(
71+
(prefs) => prefs.discordRpcEnableCustomInGameText,
72+
(prefs) => prefs.discordRpcEnableCustomInMatchText,
73+
(prefs) => prefs.discordRpcCustomInGameText,
74+
(prefs) => prefs.discordRpcCustomInMatchText,
75+
),
76+
metaData$,
77+
matchInfo$,
78+
playerHero$,
79+
]).pipe(
80+
map(
81+
([
82+
inGame,
83+
[enableCustomInGameText, enableCustomInMatchText, gameText, matchText],
84+
metaData,
85+
matchInfo,
86+
playerHero,
87+
]) => ({
88+
enabled: true,
89+
inGame: inGame ?? false,
90+
text:
91+
inGame && ((enableCustomInGameText && gameText) || (enableCustomInMatchText && matchText))
92+
? this.buildCustomText(
93+
enableCustomInGameText ? gameText : null,
94+
enableCustomInMatchText ? matchText : null,
95+
metaData,
96+
matchInfo,
97+
playerHero,
98+
) ?? IN_GAME_TEXT_PLACEHOLDER
99+
: IN_GAME_TEXT_PLACEHOLDER,
100+
}),
101+
),
102+
);
103+
}
104+
105+
private buildCustomText(
106+
gameText: string | null,
107+
matchText: string | null,
108+
metaData: Metadata | undefined,
109+
matchInfo: MatchInfo | undefined,
110+
playerHero: string | undefined,
111+
// additionalResult: string | undefined,
112+
): string | null | undefined {
113+
if (!metaData || !matchInfo?.localPlayer) {
114+
return gameText ?? this.i18n.translateString('settings.general.discord.in-game-text-default-value');
115+
}
116+
const mode = this.i18n.translateString(`global.game-mode.${metaData.gameType}`)!;
117+
const rank = this.buildRankText(matchInfo, metaData);
118+
// const [wins, losses] = additionalResult?.includes('-') ? additionalResult.split('-') : [null, null];
119+
const hero = this.allCards.getCard(playerHero ?? '')?.name ?? 'Unknown hero';
120+
return (
121+
matchText
122+
?.replace('{rank}', rank ?? '')
123+
.replace('{mode}', mode)
124+
.replace('{hero}', hero) ??
125+
this.i18n.translateString('settings.general.discord.in-game-text-in-match-default-value')
126+
);
127+
// .replace('{wins}', wins ?? '')
128+
// .replace('{losses}', losses ?? '');
129+
}
130+
131+
private buildRankText(matchInfo: MatchInfo, metaData: Metadata): string | null {
132+
switch (metaData.gameType) {
133+
case GameType.GT_PVPDR:
134+
case GameType.GT_PVPDR_PAID:
135+
console.warn('missing duels support for now');
136+
return this.i18n.translateString('settings.general.discord.in-game-text.mmr', {
137+
value: '???',
138+
});
139+
case GameType.GT_RANKED:
140+
return this.buildRankedRank(matchInfo.localPlayer, metaData.formatType);
141+
case GameType.GT_BATTLEGROUNDS:
142+
case GameType.GT_BATTLEGROUNDS_FRIENDLY:
143+
case GameType.GT_BATTLEGROUNDS_AI_VS_AI:
144+
case GameType.GT_BATTLEGROUNDS_PLAYER_VS_AI:
145+
console.warn('missing duels support for now');
146+
return this.i18n.translateString('settings.general.discord.in-game-text.mmr', {
147+
value: '???',
148+
});
149+
default:
150+
return null;
151+
}
152+
}
153+
154+
private buildRankedRank(player: PlayerInfo, format: GameFormat): string | null {
155+
switch (format) {
156+
case GameFormat.FT_STANDARD:
157+
return this.toRankedString(player.standard);
158+
case GameFormat.FT_WILD:
159+
return this.toRankedString(player.wild);
160+
case GameFormat.FT_CLASSIC:
161+
return this.toRankedString(player.classic);
162+
case GameFormat.FT_TWIST:
163+
return this.toRankedString(player.twist);
164+
default:
165+
return null;
166+
}
167+
}
168+
169+
private toRankedString(rank: Rank | undefined): string | null {
170+
if (!rank) {
171+
return 'Unknown rank';
172+
}
173+
if (rank.leagueId === 0) {
174+
return this.i18n.translateString('settings.general.discord.in-game-text.legend', { rank: rank.legendRank });
175+
} else {
176+
return this.i18n.translateString('settings.general.discord.in-game-text.rank', {
177+
league: this.i18n.translateString(`global.ranks.constructed.${this.rankToLeague(rank.leagueId)}`),
178+
rank: rank.rankValue,
179+
});
180+
}
181+
}
182+
183+
private rankToLeague(rank: number): string | null {
184+
if (rank < 10) {
185+
return this.i18n.translateString('global.ranks.constructed.bronze');
186+
} else if (rank < 20) {
187+
return this.i18n.translateString('global.ranks.constructed.silver');
188+
} else if (rank < 30) {
189+
return this.i18n.translateString('global.ranks.constructed.gold');
190+
} else if (rank < 40) {
191+
return this.i18n.translateString('global.ranks.constructed.platinum');
192+
} else if (rank < 50) {
193+
return this.i18n.translateString('global.ranks.constructed.diamond');
194+
}
195+
return this.i18n.translateString('global.ranks.constructed.legend');
39196
}
40197
}
41198

42199
export interface Presence {
43200
readonly enabled: boolean;
44201
readonly inGame?: boolean;
202+
readonly text?: string;
45203
}
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { CommonModule } from '@angular/common';
22
import { NgModule } from '@angular/core';
33
import { SharedFrameworkCoreModule } from '@firestone/shared/framework/core';
4+
import { GameStateUpdatesService } from './services/game-state-updates.service';
45

56
@NgModule({
67
imports: [CommonModule, SharedFrameworkCoreModule],
8+
providers: [GameStateUpdatesService],
79
})
810
export class GameStateModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Injectable } from '@angular/core';
2+
import { SubscriberAwareBehaviorSubject } from '@firestone/shared/framework/common';
3+
import { AbstractFacadeService, WindowManagerService } from '@firestone/shared/framework/core';
4+
import { GameState } from '../models/game-state';
5+
6+
@Injectable()
7+
export class GameStateUpdatesService extends AbstractFacadeService<GameStateUpdatesService> {
8+
public gameState$$: SubscriberAwareBehaviorSubject<GameState | null>;
9+
10+
constructor(protected override readonly windowManager: WindowManagerService) {
11+
super(windowManager, 'gameStateUpdates', () => !!this.gameState$$);
12+
}
13+
14+
protected override assignSubjects() {
15+
this.gameState$$ = this.mainInstance.gameState$$;
16+
}
17+
18+
protected async init() {
19+
this.gameState$$ = new SubscriberAwareBehaviorSubject<GameState | null>(null);
20+
}
21+
22+
public async updateGameState(gameState: GameState) {
23+
this.mainInstance.updateGameStateInternal(gameState);
24+
}
25+
26+
private updateGameStateInternal(gameState: GameState) {
27+
this.gameState$$.next(gameState);
28+
}
29+
}

libs/legacy/feature-shell/src/lib/css/themes/general-theme.scss

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
--window-outline-color: #472406;
55

66
--color-1: #ffb948;
7+
--color-2: #d9c3ab;
78
--color-3: #d9c3ab;
89
--color-4: #a89782;
910
--color-5: #190505;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
.title {
2+
display: flex;
3+
align-items: center;
4+
margin-bottom: 15px;
5+
6+
.premium-lock {
7+
display: none;
8+
height: 25px;
9+
width: 25px;
10+
--icon-color: var(--default-title-color);
11+
margin-right: 5px;
12+
}
13+
}
14+
15+
.subgroup {
16+
.toggle {
17+
margin-bottom: 10px;
18+
}
19+
20+
.tokens {
21+
list-style: disc;
22+
margin-bottom: 10px;
23+
24+
.token {
25+
margin-left: 15px;
26+
}
27+
}
28+
29+
.custom-text-config {
30+
margin-left: 15px;
31+
width: 300px;
32+
}
33+
}
34+
35+
.settings-group.locked {
36+
.title .premium-lock {
37+
display: flex;
38+
}
39+
40+
.subgroup {
41+
opacity: 0.4;
42+
pointer-events: none;
43+
}
44+
}

0 commit comments

Comments
 (0)