Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
736246b
feat: add MedalIconWhite SVG asset for UI
bijx Dec 29, 2025
7753380
added medal row
bijx Dec 29, 2025
e5c1004
grayscale row
bijx Dec 29, 2025
042b427
added win logic into localStorage
bijx Dec 29, 2025
f8e3619
renamed localStorage variable to be more reusable in the future
bijx Dec 29, 2025
1b432de
make sure local win only counts for singleplayer games
bijx Dec 29, 2025
6f75d78
added medal renderer, fixed win state, added medals only to singlepla…
bijx Dec 29, 2025
51384bf
add toggle for achievements in Single Player mode
bijx Dec 29, 2025
6acbc1d
realigned toggel acheivemetns button right
bijx Dec 29, 2025
90ba5fc
added icon credits to credits file
bijx Dec 29, 2025
eb70ec1
added check to enforce settings are not tampered with to get award
bijx Dec 29, 2025
6601553
put colors in a more reusable place
bijx Dec 29, 2025
d1f05f9
lint
bijx Dec 29, 2025
26a8cc1
Merge branch 'main' into bijx/singleplayer-achievements
bijx Dec 30, 2025
38f6b4d
Merge branch 'main' into bijx/singleplayer-achievements
bijx Dec 31, 2025
90d28c8
Merge branch 'main' into bijx/singleplayer-achievements
bijx Dec 31, 2025
819fb76
Merge branch 'main' into bijx/singleplayer-achievements
iiamlewis Jan 1, 2026
cbaa59a
Implement API call for recording singleplayer wins in ClientGameRunner
bijx Jan 1, 2026
7c42e45
Merge branch 'bijx/singleplayer-achievements' of https://github.com/o…
bijx Jan 1, 2026
5473a0a
Remove unused Difficulty and GameMapType imports in ClientGameRunner
bijx Jan 1, 2026
f86ce97
added back difficulty or custom check using default settings function
bijx Jan 1, 2026
92f6812
added gameId to api call
bijx Jan 1, 2026
3e0e9ca
implement API call to load singleplayer wins and update map wins stat…
bijx Jan 2, 2026
175669d
updated singleplayer win recording to use user authentication and inc…
bijx Jan 2, 2026
fb9ca70
lint + format
bijx Jan 2, 2026
ad2a2ef
removed saving game stats from clientgamerunner since it now is handl…
bijx Jan 2, 2026
4f2c5b4
remove other unused functions
bijx Jan 2, 2026
6fcfb78
added new achievements shape to getUserMe response
bijx Jan 2, 2026
51a9dac
replaced API call with listening for getUserMe response to render me…
bijx Jan 2, 2026
7e5bd1b
lint + format
bijx Jan 2, 2026
858638a
Merge branch 'main' into bijx/singleplayer-achievements
bijx Jan 2, 2026
99f0374
added enum list for difficulty + custom to map completion schema
bijx Jan 2, 2026
9cc040d
Merge branch 'main' into bijx/singleplayer-achievements
bijx Jan 2, 2026
3426dfd
Merge branch 'main' into bijx/singleplayer-achievements
bijx Jan 2, 2026
e52e482
api schema updated to match backend
bijx Jan 2, 2026
9d038bf
achievement fetching now follows new API shape
bijx Jan 2, 2026
385424a
removed custom from possible medals
bijx Jan 2, 2026
02d6a93
Merge branch 'main' into bijx/singleplayer-achievements
bijx Jan 2, 2026
ec04a88
Merge branch 'main' into bijx/singleplayer-achievements
bijx Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ Copyright © opentopography.org. All Rights Reserved. [Terms of Use](https://ope

Stats icon by [Meko](https://thenounproject.com/mekoda/) – https://thenounproject.com/icon/stats-4942475/
Pay Per Click icon by [Fauzan Adiima](https://thenounproject.com/creator/fauzan94/) – https://thenounproject.com/icon/pay-per-click-2586454/
Medal icon by [Snow](https://thenounproject.com/snowdoll/) – https://thenounproject.com/icon/medal-4567887/
87 changes: 87 additions & 0 deletions resources/images/MedalIconWhite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"title": "Single Player",
"random_spawn": "Random spawn",
"allow_alliances": "Allow alliances",
"toggle_achievements": "Toggle achievements",
"options_title": "Options",
"bots": "Bots: ",
"bots_disabled": "Disabled",
Expand Down
78 changes: 77 additions & 1 deletion src/client/SinglePlayerModal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { UserMeResponse } from "../core/ApiSchemas";
import {
Difficulty,
Duos,
Expand Down Expand Up @@ -49,6 +50,8 @@ export class SinglePlayerModal extends LitElement {
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@state() private showAchievements: boolean = false;
@state() private mapWins: Map<GameMapType, Set<Difficulty>> = new Map();

@state() private disabledUnits: UnitType[] = [];

Expand All @@ -57,9 +60,17 @@ export class SinglePlayerModal extends LitElement {
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
document.addEventListener(
"userMeResponse",
this.handleUserMeResponse as EventListener,
);
}

disconnectedCallback() {
document.removeEventListener(
"userMeResponse",
this.handleUserMeResponse as EventListener,
);
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
Expand All @@ -71,13 +82,76 @@ export class SinglePlayerModal extends LitElement {
}
};

private toggleAchievements = () => {
this.showAchievements = !this.showAchievements;
};

private handleUserMeResponse = (
event: CustomEvent<UserMeResponse | false>,
) => {
this.applyAchievements(event.detail);
};

private applyAchievements(userMe: UserMeResponse | false) {
if (!userMe) {
this.mapWins = new Map();
return;
}

const achievements = Array.isArray(userMe.player.achievements)
? userMe.player.achievements
: [];

const completions =
achievements
.find((achievement) => achievement?.type === "singleplayer-map")
?.data ?? [];

const winsMap = new Map<GameMapType, Set<Difficulty>>();
for (const entry of completions) {
const { mapName, difficulty } = entry ?? {};
const isValidMap =
typeof mapName === "string" &&
Object.values(GameMapType).includes(mapName as GameMapType);
const isValidDifficulty =
typeof difficulty === "string" &&
Object.values(Difficulty).includes(difficulty as Difficulty);
if (!isValidMap || !isValidDifficulty) continue;

const map = mapName as GameMapType;
const set = winsMap.get(map) ?? new Set<Difficulty>();
set.add(difficulty as Difficulty);
winsMap.set(map, set);
}

this.mapWins = winsMap;
}

render() {
return html`
<o-modal title=${translateText("single_modal.title")}>
<div class="options-layout">
<!-- Map Selection -->
<div class="options-section">
<div class="option-title">${translateText("map.map")}</div>
<div
class="option-title"
style="position:relative; display:flex; align-items:center; justify-content:center; width:100%;"
>
<span style="text-align:center; width:100%;">
${translateText("map.map")}
</span>
<button
@click=${this.toggleAchievements}
title=${translateText("single_modal.toggle_achievements")}
style="display:flex; align-items:center; justify-content:center; width:28px; height:28px; border:1px solid rgba(255,255,255,0.2); border-radius:6px; background:rgba(255,255,255,0.06); cursor:pointer; padding:4px; position:absolute; right:0; top:50%; transform:translateY(-50%);"
>
<img
src="/images/MedalIconWhite.svg"
alt="Toggle achievements"
style=${`width:18px; height:18px; opacity:${this.showAchievements ? "1" : "0.5"};`}
/>
</button>
</div>
Comment on lines +136 to +154
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if MedalIconWhite.svg exists
echo "=== Searching for MedalIconWhite.svg ==="
fd -t f "MedalIconWhite.svg"

echo -e "\n=== Checking public/images directory ==="
ls -la public/images/ 2>/dev/null || echo "public/images not found"

echo -e "\n=== Checking assets/images directory ==="
ls -la assets/images/ 2>/dev/null || echo "assets/images not found"

echo -e "\n=== Listing all .svg files in repo ==="
fd -t f "\.svg$" | head -20

Repository: openfrontio/OpenFrontIO

Length of output: 938


🏁 Script executed:

#!/bin/bash
# Examine SinglePlayerModal.ts for styling approach
echo "=== Checking SinglePlayerModal.ts structure ==="
wc -l src/client/SinglePlayerModal.ts

echo -e "\n=== Looking for createRenderRoot ==="
rg "createRenderRoot" src/client/SinglePlayerModal.ts -A 3

echo -e "\n=== Looking for static styles in component ==="
rg "static styles|css\`" src/client/SinglePlayerModal.ts -B 2 -A 5

Repository: openfrontio/OpenFrontIO

Length of output: 294


🏁 Script executed:

#!/bin/bash
# Check for other components to understand styling patterns
echo "=== Looking at Lit component styling patterns ==="
fd -t f "\.ts$" src/client/ | head -10 | xargs -I {} sh -c 'echo "=== {} ===" && grep -l "static styles" {}'

echo -e "\n=== Check for inline style usage in similar files ==="
rg "style=" src/client/SinglePlayerModal.ts | head -5

Repository: openfrontio/OpenFrontIO

Length of output: 1196


Fix the asset path for MedalIconWhite.svg.

The image path /images/MedalIconWhite.svg is incorrect. The file is located at resources/images/MedalIconWhite.svg. Update line 151 to use the correct path, otherwise the medal icon will fail to load.


Consider extracting inline styles for maintainability.

The toggle button works correctly, but the inline styles on lines 137, 145, and 150 make the template harder to read. However, since this component uses light DOM (createRenderRoot() returns this), `static styles = css`` will not work. You can instead:

  • Extract inline styles to a helper method that returns a style string
  • Add a <style> tag in the template
  • Keep inline styles if they are consistent with your codebase pattern

Extracting styles improves readability and makes changes easier to maintain.

🤖 Prompt for AI Agents
In src/client/SinglePlayerModal.ts around lines 135 to 153, the image src uses
the wrong absolute path (/images/MedalIconWhite.svg) which prevents the medal
icon from loading; update the src on line 151 to the correct relative path
resources/images/MedalIconWhite.svg. While making the change, optionally move
the long inline style strings into a small helper that returns a style string or
include a <style> block in the template to improve readability (since static
styles can't be used with light DOM), but the immediate required fix is
replacing the src path to resources/images/MedalIconWhite.svg.

<div class="option-cards flex-col">
<!-- Use the imported mapCategories -->
${Object.entries(mapCategories).map(
Expand All @@ -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()}`,
)}
Expand Down
60 changes: 57 additions & 3 deletions src/client/components/Maps.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -54,6 +54,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<Difficulty> = new Set();
@state() private mapWebpPath: string | null = null;
@state() private mapName: string | null = null;
@state() private isLoading = true;
Expand All @@ -63,7 +65,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;
Expand All @@ -73,6 +75,7 @@ export class MapDisplay extends LitElement {
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease-in-out;
gap: 6px;
}

.option-card:hover {
Expand All @@ -90,7 +93,7 @@ export class MapDisplay extends LitElement {
font-size: 14px;
color: #aaa;
text-align: center;
margin: 0 0 4px 0;
margin: 0;
}

.option-image {
Expand All @@ -105,6 +108,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() {
Expand Down Expand Up @@ -142,8 +165,39 @@ export class MapDisplay extends LitElement {
class="option-image"
/>`
: html`<div class="option-image">Error</div>`}
${this.showMedals
? html`<div class="medal-row">${this.renderMedals()}</div>`
: null}
<div class="option-card-title">${this.translation || this.mapName}</div>
</div>
`;
}

private renderMedals() {
const medalOrder: Difficulty[] = [
Difficulty.Easy,
Difficulty.Medium,
Difficulty.Hard,
Difficulty.Impossible,
];
const colors: Record<Difficulty, string> = {
[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`<div
class="medal-icon ${earned ? "earned" : ""}"
style="background-color:${colors[medal]};"
title=${medal}
></div>`;
});
}

private readWins(): Set<Difficulty> {
return this.wins ?? new Set();
}
}
7 changes: 7 additions & 0 deletions src/client/styles/core/variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions src/core/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export const DiscordUserSchema = z.object({
});
export type DiscordUser = z.infer<typeof DiscordUserSchema>;

const SingleplayerMapAchievementSchema = z.object({
mapName: z.enum(GameMapType),
difficulty: z.enum(Difficulty),
});

export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
Expand All @@ -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<typeof UserMeResponseSchema>;
Expand Down
Loading