Files
OpenFrontIO/src/client/graphics/layers/SettingsModal.ts
T
scamiv 05e2bc9f0a Improve cacheability with content-hashed public assets and a cacheable app shell (#3494)
## Description:

This reworks asset delivery and cacheability across the app and moves
non-bundled public resources onto immutable, content-hashed URLs.

Vite bundle outputs continue to live under `/assets/**` and remain
content-hashed by Vite. Public resources that were previously fetched
from stable paths in `resources/` now go through a custom hashed
namespace under `/_assets/**`, backed by a generated asset manifest that
is available to the server, browser, and worker runtime.

In parallel, the root app shell is now cacheable shared HTML instead of
request-time `no-store` HTML. Dynamic and live routes remain explicitly
uncached.

## Why
- Improve browser and Cloudflare cacheability for static assets.
- Remove query-string and release-version cache busting for
runtime-fetched assets.
- Allow unchanged public assets to keep the same URL across releases.
- Reduce avoidable work on `/` by serving a shared app shell instead of
rendering HTML on every request.
- Make cache behavior explicit instead of relying on mixed framework
defaults and file-extension heuristics.

## What Changed

### 1. Content-hashed public asset pipeline
- Added a build-time public asset manifest and hashing pipeline for
non-Vite resources.
- Production now emits hashed public assets under `/_assets/**`.
- Added runtime manifest loading for Node so server-rendered paths
resolve against built hashed files instead of rebuilding from source at
runtime.
- Emitted the runtime asset manifest as an ESM module for server
consumption.

Result:
- `/assets/**` = Vite-managed hashed bundle outputs
- `/_assets/**` = custom content-hashed public resources

### 2. Runtime asset URL migration
- Added a shared `assetUrl(...)` resolution path.
- Migrated runtime references away from query-string versioning and
stable source paths.
- Updated browser, worker, and server-side rendering paths to resolve
through the asset manifest.
- Moved map manifests, map binaries, thumbnails, sprites, sounds, fonts,
flags, icons, screenshots, and other runtime-fetched resources onto
hashed URLs.

### 3. Map and preview fixes
- Fixed directory and per-file map asset resolution so map manifest and
binary fetches resolve to the correct hashed URLs.
- Updated preview metadata and map thumbnail paths to use the hashed
asset namespace.
- Fixed runtime manifest loading in prod after deployment.

### 4. Explicit cache policies
- Added explicit immutable cache headers for:
  - `/assets/**`
  - `/_assets/**`
  - worker-prefixed equivalents under `/wN/...`
- Added explicit `no-store` headers for live and dynamic APIs.
- Removed the old `/api/env` bootstrap request and baked `gameEnv` into
the HTML bootstrap instead.

### 5. Cacheable root app shell
- Refactored the root HTML path to serve a shared app shell with:
- `Cache-Control: public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`
- `/` and the SPA fallback now serve shared cacheable HTML instead of
request-time `no-store` rendering.
- `/game/:id` remains dynamic and `no-store`, but now reuses the shared
shell before injecting preview tags.

### 6. Matchmaking instance handling
- Because the app shell is now cacheable, `INSTANCE_ID` was removed from
shared HTML.
- Added `/api/instance` as a temporary `no-store` runtime lookup used
only by matchmaking.
- This preserves correctness with the current random-per-boot
`INSTANCE_ID` model while keeping `/` cacheable, but it is not the
intended long-term design.

## Behavior Changes

### Asset URL contract
Production URLs for non-Vite public resources now change from stable
paths such as:
- `/maps/...`
- `/images/...`
- `/manifest.json`

to content-hashed paths under:
- `/_assets/...`

Examples:
- `/_assets/maps/<map>/manifest.<hash>.json`
- `/_assets/images/Favicon.<hash>.svg`

### Bootstrap/config
- `/api/env` is removed.
- `gameEnv` is now bootstrapped from HTML.

### HTML caching
- `/` and the SPA fallback are now cacheable shared HTML.
- `/game/:id` remains dynamic.

## Cache Matrix After This Branch
- `/_assets/**`: `public, max-age=31536000, immutable`
- `/assets/**`: `public, max-age=31536000, immutable`
- live `/api/**`: explicit `no-store`
- `/api/health`: explicit `no-store`
- `/api/instance`: explicit `no-store`
- `/game/:id`: explicit `no-store`
- `/` and SPA fallback: `public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`

## Notes / Tradeoffs
- `/api/instance` is a temporary compromise. It exists because
`INSTANCE_ID` is currently random per boot, which is not safe to embed
into cacheable shared HTML.
- The current matchmaking flow still asks the client to provide
`instance_id` during `matchmaking/join`. That is functional, but it is
the wrong ownership boundary: instance selection should be handled by
the matchmaking service, not by the browser.
- The cleaner end-state would be:
- make `matchmaking/join` stop requiring `instance_id` from the client,
and let the matchmaking service select a healthy instance from worker
check-ins
- This branch makes the origin behavior edge-cache-friendly, but
Cloudflare still needs matching cache rules if HTML itself should be
cached at the edge.

## Validation
Verified during development with:
- `npx tsc --noEmit`
- `node node_modules\\vite\\bin\\vite.js build`
- `node node_modules\\vitest\\vitest.mjs run
tests/server/RenderHtml.test.ts tests/server/NoStoreHeaders.test.ts
tests/server/StaticAssetCache.test.ts
tests/core/configuration/ConfigLoader.test.ts`

Additional targeted tests added:
- `tests/AssetUrls.test.ts`
- `tests/core/game/FetchGameMapLoader.test.ts`
- `tests/core/configuration/ConfigLoader.test.ts`
- `tests/server/NoStoreHeaders.test.ts`
- `tests/server/StaticAssetCache.test.ts`
- `tests/server/RenderHtml.test.ts`

## Known Existing Warnings
The production build still reports pre-existing warnings that are not
addressed by this branch:
- inconsistent JSON import attributes for `resources/countries.json`
- inconsistent JSON import attributes for `resources/QuickChat.json`
- large chunk warnings from Vite

## Rollout Notes
- Cache rules should treat `/_assets/**` and `/assets/**` as immutable.
- Cloudflare will still classify HTML as dynamic after deploy unless
matching edge cache rules are configured for it.

## Follow-ups
- Remove `/api/instance` by changing `matchmaking/join` so the server
selects the target instance, or by making `INSTANCE_ID` deploy-stable if
the current contract must remain.


## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
2026-03-23 11:36:52 -07:00

552 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { PauseGameIntentEvent } from "src/client/Transport";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
import { translateText } from "../../Utils";
import SoundManager from "../../sound/SoundManager";
import { Layer } from "./Layer";
const structureIcon = assetUrl("images/CityIconWhite.svg");
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
const darkModeIcon = assetUrl("images/DarkModeIconWhite.svg");
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
const exitIcon = assetUrl("images/ExitIconWhite.svg");
const explosionIcon = assetUrl("images/ExplosionIconWhite.svg");
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const sirenIcon = assetUrl("images/SirenIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
const treeIcon = assetUrl("images/TreeIconWhite.svg");
const musicIcon = assetUrl("images/music.svg");
export class ShowSettingsModalEvent {
constructor(
public readonly isVisible: boolean = true,
public readonly shouldPause: boolean = false,
public readonly isPaused: boolean = false,
) {}
}
@customElement("settings-modal")
export class SettingsModal extends LitElement implements Layer {
public eventBus: EventBus;
public userSettings: UserSettings;
@state()
private isVisible: boolean = false;
@state()
private alternateView: boolean = false;
@query(".modal-overlay")
private modalOverlay!: HTMLElement;
@property({ type: Boolean })
shouldPause = false;
@property({ type: Boolean })
wasPausedWhenOpened = false;
init() {
SoundManager.setBackgroundMusicVolume(
this.userSettings.backgroundMusicVolume(),
);
SoundManager.setSoundEffectsVolume(this.userSettings.soundEffectsVolume());
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
this.wasPausedWhenOpened = event.isPaused;
this.pauseGame(true);
});
}
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("click", this.handleOutsideClick, true);
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("click", this.handleOutsideClick, true);
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleOutsideClick = (event: MouseEvent) => {
if (
this.isVisible &&
this.modalOverlay &&
event.target === this.modalOverlay
) {
this.closeModal();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
if (this.isVisible && event.key === "Escape") {
this.closeModal();
}
};
public openModal() {
this.isVisible = true;
this.requestUpdate();
}
public closeModal() {
this.isVisible = false;
this.requestUpdate();
this.pauseGame(false);
}
private pauseGame(pause: boolean) {
if (this.shouldPause && !this.wasPausedWhenOpened) {
if (pause) {
crazyGamesSDK.gameplayStop();
} else {
crazyGamesSDK.gameplayStart();
}
this.eventBus.emit(new PauseGameIntentEvent(pause));
}
}
private onTerrainButtonClick() {
this.alternateView = !this.alternateView;
this.eventBus.emit(new AlternateViewEvent(this.alternateView));
this.requestUpdate();
}
private onToggleEmojisButtonClick() {
this.userSettings.toggleEmojis();
this.requestUpdate();
}
private onToggleStructureSpritesButtonClick() {
this.userSettings.toggleStructureSprites();
this.requestUpdate();
}
private onToggleSpecialEffectsButtonClick() {
this.userSettings.toggleFxLayer();
this.requestUpdate();
}
private onToggleAlertFrameButtonClick() {
this.userSettings.toggleAlertFrame();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this.userSettings.toggleDarkMode();
this.eventBus.emit(new RefreshGraphicsEvent());
this.requestUpdate();
}
private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
this.requestUpdate();
}
private onToggleLeftClickOpensMenu() {
this.userSettings.toggleLeftClickOpenMenu();
this.requestUpdate();
}
private onToggleCursorCostLabelButtonClick() {
this.userSettings.toggleCursorCostLabel();
this.requestUpdate();
}
private onToggleAttackingTroopsOverlayButtonClick() {
this.userSettings.toggleAttackingTroopsOverlay();
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
}
private onVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setBackgroundMusicVolume(volume);
SoundManager.setBackgroundMusicVolume(volume);
this.requestUpdate();
}
private onSoundEffectsVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setSoundEffectsVolume(volume);
SoundManager.setSoundEffectsVolume(volume);
this.requestUpdate();
}
render() {
if (!this.isVisible) {
return null;
}
return html`
<div
class="modal-overlay fixed inset-0 bg-black/60 backdrop-blur-xs z-2000 flex items-center justify-center p-4"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class="bg-slate-800 border border-slate-600 rounded-lg max-w-md w-full max-h-[80vh] overflow-y-auto"
>
<div
class="flex items-center justify-between p-4 border-b border-slate-600"
>
<div class="flex items-center gap-2">
<img
src=${settingsIcon}
alt="settings"
width="24"
height="24"
class="align-middle"
/>
<h2 class="text-xl font-semibold text-white">
${translateText("user_setting.tab_basic")}
</h2>
</div>
<button
class="text-slate-400 hover:text-white text-2xl font-bold leading-none"
@click=${this.closeModal}
>
×
</button>
</div>
<div class="p-4 flex flex-col gap-3">
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img src=${musicIcon} alt="musicIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.background_music_volume")}
</div>
<input
type="range"
min="0"
max="100"
.value=${this.userSettings.backgroundMusicVolume() * 100}
@input=${this.onVolumeChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.backgroundMusicVolume() * 100)}%
</div>
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img
src=${musicIcon}
alt="soundEffectsIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.sound_effects_volume")}
</div>
<input
type="range"
min="0"
max="100"
.value=${this.userSettings.soundEffectsVolume() * 100}
@input=${this.onSoundEffectsVolumeChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.soundEffectsVolume() * 100)}%
</div>
</div>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onTerrainButtonClick}"
>
<img src=${treeIcon} alt="treeIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.toggle_terrain")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.toggle_view_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.alternateView
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleEmojisButtonClick}"
>
<img src=${emojiIcon} alt="emojiIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.emojis_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.emojis_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.emojis()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleDarkModeButtonClick}"
>
<img
src=${darkModeIcon}
alt="darkModeIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.dark_mode_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.dark_mode_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.darkMode()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleSpecialEffectsButtonClick}"
>
<img
src=${explosionIcon}
alt="specialEffects"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.special_effects_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.special_effects_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.fxLayer()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAlertFrameButtonClick}"
>
<img src=${sirenIcon} alt="alertFrame" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.alert_frame_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.alert_frame_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.alertFrame()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleStructureSpritesButtonClick}"
>
<img
src=${structureIcon}
alt="structureSprites"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.structure_sprites_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.structure_sprites_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.structureSprites()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
>
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText(
"user_setting.attacking_troops_overlay_label",
)}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.attacking_troops_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.attackingTroopsOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleCursorCostLabelButtonClick}"
>
<img
src=${cursorPriceIcon}
alt="cursorCostLabel"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.cursor_cost_label_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.cursor_cost_label_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.cursorCostLabel()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleRandomNameModeButtonClick}"
>
<img src=${ninjaIcon} alt="ninjaIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.anonymous_names_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.anonymous_names_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.anonymousNames()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleLeftClickOpensMenu}"
>
<img src=${mouseIcon} alt="mouseIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.left_click_menu")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.left_click_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.leftClickOpensMenu()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onTogglePerformanceOverlayButtonClick}"
>
<img
src=${settingsIcon}
alt="performanceIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.performance_overlay_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.performance_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.performanceOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded-sm text-red-400 transition-colors"
@click="${this.onExitButtonClick}"
>
<img src=${exitIcon} alt="exitIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.exit_game_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.exit_game_info")}
</div>
</div>
</button>
</div>
</div>
</div>
</div>
`;
}
}