-
${translateText("map.map")}
+
+
+ ${translateText("map.map")}
+
+
+
${Object.entries(mapCategories).map(
@@ -103,6 +177,8 @@ export class SinglePlayerModal extends LitElement {
.mapKey=${mapKey}
.selected=${!this.useRandomMap &&
this.selectedMap === mapValue}
+ .showMedals=${this.showAchievements}
+ .wins=${this.mapWins.get(mapValue) ?? new Set()}
.translation=${translateText(
`map.${mapKey?.toLowerCase()}`,
)}
diff --git a/src/client/components/Maps.ts b/src/client/components/Maps.ts
index 55ea40b82..213b010da 100644
--- a/src/client/components/Maps.ts
+++ b/src/client/components/Maps.ts
@@ -1,6 +1,6 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
-import { GameMapType } from "../../core/game/Game";
+import { Difficulty, GameMapType } from "../../core/game/Game";
import { terrainMapFileLoader } from "../TerrainMapFileLoader";
import { translateText } from "../Utils";
@@ -47,6 +47,7 @@ export const MapDescription: Record
= {
TwoLakes: "Two Lakes",
StraitOfHormuz: "Strait of Hormuz",
Surrounded: "Surrounded",
+ Didier: "Didier",
};
@customElement("map-display")
@@ -54,6 +55,8 @@ export class MapDisplay extends LitElement {
@property({ type: String }) mapKey = "";
@property({ type: Boolean }) selected = false;
@property({ type: String }) translation: string = "";
+ @property({ type: Boolean }) showMedals = false;
+ @property({ attribute: false }) wins: Set = new Set();
@state() private mapWebpPath: string | null = null;
@state() private mapName: string | null = null;
@state() private isLoading = true;
@@ -63,7 +66,7 @@ export class MapDisplay extends LitElement {
width: 100%;
min-width: 100px;
max-width: 120px;
- padding: 4px 4px 0 4px;
+ padding: 6px 6px 10px 6px;
display: flex;
flex-direction: column;
align-items: center;
@@ -73,6 +76,7 @@ export class MapDisplay extends LitElement {
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease-in-out;
+ gap: 6px;
}
.option-card:hover {
@@ -90,7 +94,7 @@ export class MapDisplay extends LitElement {
font-size: 14px;
color: #aaa;
text-align: center;
- margin: 0 0 4px 0;
+ margin: 0;
}
.option-image {
@@ -105,6 +109,26 @@ export class MapDisplay extends LitElement {
align-items: center;
justify-content: center;
}
+
+ .medal-row {
+ display: flex;
+ gap: 6px;
+ justify-content: center;
+ width: 100%;
+ }
+
+ .medal-icon {
+ width: 20px;
+ height: 20px;
+ background: rgba(255, 255, 255, 0.12);
+ mask: url("/images/MedalIconWhite.svg") no-repeat center / contain;
+ -webkit-mask: url("/images/MedalIconWhite.svg") no-repeat center / contain;
+ opacity: 0.25;
+ }
+
+ .medal-icon.earned {
+ opacity: 1;
+ }
`;
connectedCallback() {
@@ -142,8 +166,39 @@ export class MapDisplay extends LitElement {
class="option-image"
/>`
: html`Error
`}
+ ${this.showMedals
+ ? html`${this.renderMedals()}
`
+ : null}
${this.translation || this.mapName}
`;
}
+
+ private renderMedals() {
+ const medalOrder: Difficulty[] = [
+ Difficulty.Easy,
+ Difficulty.Medium,
+ Difficulty.Hard,
+ Difficulty.Impossible,
+ ];
+ const colors: Record
= {
+ [Difficulty.Easy]: "var(--medal-easy)",
+ [Difficulty.Medium]: "var(--medal-medium)",
+ [Difficulty.Hard]: "var(--medal-hard)",
+ [Difficulty.Impossible]: "var(--medal-impossible)",
+ };
+ const wins = this.readWins();
+ return medalOrder.map((medal) => {
+ const earned = wins.has(medal);
+ return html``;
+ });
+ }
+
+ private readWins(): Set {
+ return this.wins ?? new Set();
+ }
}
diff --git a/src/client/styles/core/variables.css b/src/client/styles/core/variables.css
index bb1b48fd0..eb57805ae 100644
--- a/src/client/styles/core/variables.css
+++ b/src/client/styles/core/variables.css
@@ -23,4 +23,11 @@
--secondaryColorDark: #374151;
--secondaryColorHoverDark: #4b5563;
--fontColorDark: #f3f4f6;
+
+ /* Achievements */
+ --medal-easy: #cd7f32;
+ --medal-medium: #c0c0c0;
+ --medal-hard: #ffd700;
+ --medal-impossible: #d32f2f;
+ --medal-custom: #2196f3;
}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 775e86c49..6461027ed 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -42,6 +42,11 @@ export const DiscordUserSchema = z.object({
});
export type DiscordUser = z.infer;
+const SingleplayerMapAchievementSchema = z.object({
+ mapName: z.enum(GameMapType),
+ difficulty: z.enum(Difficulty),
+});
+
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
@@ -51,6 +56,14 @@ export const UserMeResponseSchema = z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
+ achievements: z
+ .array(
+ z.object({
+ type: z.literal("singleplayer-map"), // TODO: change the shape to be more flexible when we have more achievements
+ data: z.array(SingleplayerMapAchievementSchema),
+ }),
+ )
+ .optional(),
}),
});
export type UserMeResponse = z.infer;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 4cd25161b..c09d9ff80 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -88,6 +88,7 @@ const numPlayersConfig = {
[GameMapType.TwoLakes]: [60, 50, 40],
[GameMapType.StraitOfHormuz]: [40, 36, 30],
[GameMapType.Surrounded]: [42, 28, 14], // 3, 2, 1 player(s) per island
+ [GameMapType.Didier]: [100, 70, 50],
} as const satisfies Record;
export abstract class DefaultServerConfig implements ServerConfig {
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index ec54e4a51..80caa9c02 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -110,6 +110,7 @@ export enum GameMapType {
TwoLakes = "Two Lakes",
StraitOfHormuz = "Strait of Hormuz",
Surrounded = "Surrounded",
+ Didier = "Didier",
}
export type GameMapName = keyof typeof GameMapType;
@@ -161,6 +162,7 @@ export const mapCategories: Record = {
GameMapType.FourIslands,
GameMapType.Svalmel,
GameMapType.Surrounded,
+ GameMapType.Didier,
],
};
diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts
index 6efdde53d..2f13a68f0 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -60,6 +60,7 @@ const frequency: Partial> = {
TwoLakes: 6,
StraitOfHormuz: 4,
Surrounded: 4,
+ Didier: 2,
};
interface MapWithMode {