This commit is contained in:
Renan LE CARO 2025-04-06 18:21:53 +02:00
parent 42abc6bc01
commit 46228a2128
20 changed files with 226 additions and 129 deletions

View file

@ -12,18 +12,20 @@ Break colourful bricks, catch bouncing coins and select powerful upgrades !
- [GitLab](https://gitlab.com/lecarore/breakout71)
# Changelog
## To do
- avoid showing a +1 and -1 at the same time when a combo increase is reset
- mention unlock conditions in help
- show unlock condition in unlocks menu for perks as tooltip
- fallback for mobile user to see unlock conditions
## Done
- migration to save past content to localStorage.recovery_data right before starting a new version
- mention unlock conditions in help
- show unlock condition in unlocks menu for perks as tooltip
- fallback for mobile user to see unlock conditions
- New perk : "limitless" raises the max of all perks by 1
- Boosted perk : side kick, now you just need to hit bricks from the left side to gain +lvl combo, hitting from the right side does -2xlvl combo
- add unlock conditions for levels in the form "reach high score X with perk A,B,C but without perk B,C,D"
- remove loop mode :
- remove basecombo

View file

@ -29,8 +29,8 @@ android {
applicationId = "me.lecaro.breakout"
minSdk = 21
targetSdk = 34
versionCode = 29065772
versionName = "29065772"
versionCode = 29065815
versionName = "29065815"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true

File diff suppressed because one or more lines are too long

102
dist/index.html vendored

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
// The version of the cache.
const VERSION = "29065772";
const VERSION = "29065815";
// The name of the cache
const CACHE_NAME = `breakout-71-${VERSION}`;

View file

@ -9,7 +9,13 @@ import {
restart,
} from "./game";
import { asyncAlert, requiredAsyncAlert } from "./asyncAlert";
import { describeLevel, highScoreText, sumOfValues } from "./game_utils";
import {
describeLevel,
highScoreText,
reasonLevelIsLocked,
sumOfValues,
} from "./game_utils";
import { getHistory } from "./gameOver";
export function creativeMode(gameState: GameState) {
return {
@ -57,7 +63,10 @@ export async function openCreativeModePerksPicker() {
.map((u) => ({
icon: u.icon,
text: u.name,
help: (creativeModePerks[u.id] || 0) + "/" + (u.max+creativeModePerks.limitless),
help:
(creativeModePerks[u.id] || 0) +
"/" +
(u.max + (creativeModePerks.limitless || 0)),
value: u,
className: creativeModePerks[u.id]
? "sandbox"
@ -65,12 +74,16 @@ export async function openCreativeModePerksPicker() {
tooltip: u.help(creativeModePerks[u.id] || 1),
})),
t("lab.select_level"),
...allLevels.map((l) => ({
icon: icons[l.name],
text: l.name,
value: l,
tooltip: describeLevel(l),
})),
...allLevels.map((l, li) => {
const problem = reasonLevelIsLocked(li, getHistory());
return {
icon: icons[l.name],
text: l.name,
value: l,
disabled: !!problem,
tooltip: problem || describeLevel(l),
};
}),
],
}))
) {
@ -86,7 +99,8 @@ export async function openCreativeModePerksPicker() {
return;
} else if (choice) {
creativeModePerks[choice.id] =
((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1 + creativeModePerks.limitless);
((creativeModePerks[choice.id] || 0) + 1) %
(choice.max + 1 + (creativeModePerks.limitless || 0));
} else {
return;
}

View file

@ -978,8 +978,8 @@
},
{
"name": "icon:side_kick",
"size": 8,
"bricks": "_WW__y_yWWWWy_y_WWWW_y_y_WW_y_y_r_r______r_r____r_r______r_r____",
"size": 4,
"bricks": "yyrryttryttryyrr",
"svg": null,
"color": ""
},
@ -1273,5 +1273,12 @@
"bricks": "bbg_bgg_______bbb_bgg_______bgg_bbg_______bbg_bbb",
"svg": null,
"color": ""
},
{
"name": "icon:limitless",
"size": 12,
"bricks": "_________________________bbb____ttb_bbbbb__tttbbbb_bbbttt_bbbb__bbbt__bbbb_ttbbb__bbttttttbbbbbb_ttt___bbbb_____________________________________",
"svg": null,
"color": ""
}
]

View file

@ -1 +1 @@
"29065772"
"29065815"

View file

@ -1,36 +1,24 @@
import { allLevels, appVersion, icons, upgrades } from "./loadGameData";
import {
Ball,
Coin,
GameState,
LightFlash,
OptionId,
ParticleFlash,
PerkId,
RunParams,
TextFlash,
Upgrade,
} from "./types";
import { getAudioContext, playPendingSounds } from "./sounds";
import {allLevels, appVersion, icons, upgrades} from "./loadGameData";
import {Ball, Coin, GameState, LightFlash, OptionId, ParticleFlash, PerkId, RunParams, TextFlash,} from "./types";
import {getAudioContext, playPendingSounds} from "./sounds";
import {
currentLevelInfo,
describeLevel,
getRowColIndex,
highScoreText,
reasonLevelIsLocked,
levelsListHTMl,
max_levels,
pickedUpgradesHTMl,
reasonLevelIsLocked,
} from "./game_utils";
import "./PWA/sw_loader";
import { getCurrentLang, t } from "./i18n/i18n";
import {getCurrentLang, t} from "./i18n/i18n";
import {
cycleMaxCoins,
cycleMaxParticles,
getCurrentMaxCoins,
getCurrentMaxParticles,
getSettingValue,
getTotalScore,
setSettingValue,
} from "./settings";
@ -42,38 +30,20 @@ import {
setLevel,
setMousePos,
} from "./gameStateMutators";
import {
backgroundCanvas,
ctx,
gameCanvas,
haloCanvas,
haloScale,
render,
scoreDisplay,
} from "./render";
import {
pauseRecording,
recordOneFrame,
resumeRecording,
startRecordingGame,
} from "./recording";
import { newGameState } from "./newGameState";
import {
alertsOpen,
asyncAlert,
AsyncAlertAction,
closeModal,
requiredAsyncAlert,
} from "./asyncAlert";
import { isOptionOn, options, toggleOption } from "./options";
import { hashCode } from "./getLevelBackground";
import { hoursSpentPlaying } from "./pure_functions";
import { helpMenuEntry } from "./help";
import { creativeMode } from "./creative";
import { setupTooltips } from "./tooltip";
import { startingPerkMenuButton } from "./startingPerks";
import {backgroundCanvas, gameCanvas, haloCanvas, haloScale, render, scoreDisplay,} from "./render";
import {pauseRecording, recordOneFrame, resumeRecording, startRecordingGame,} from "./recording";
import {newGameState} from "./newGameState";
import {alertsOpen, asyncAlert, AsyncAlertAction, closeModal, requiredAsyncAlert,} from "./asyncAlert";
import {isOptionOn, options, toggleOption} from "./options";
import {hashCode} from "./getLevelBackground";
import {hoursSpentPlaying} from "./pure_functions";
import {helpMenuEntry} from "./help";
import {creativeMode} from "./creative";
import {setupTooltips} from "./tooltip";
import {startingPerkMenuButton} from "./startingPerks";
import "./migrations";
import { getCreativeModeWarning, getHistory } from "./gameOver";
import {getCreativeModeWarning, getHistory} from "./gameOver";
import {generateSaveFileContent} from "./generateSaveFileContent";
export async function play() {
if (await applyFullScreenChoice()) return;
@ -442,8 +412,7 @@ document.addEventListener("visibilitychange", () => {
async function openScorePanel() {
pause(true);
await asyncAlert({
await asyncAlert({
title: t("score_panel.title", {
score: gameState.score,
level: gameState.currentLevel + 1,
@ -597,17 +566,9 @@ async function openSettingsMenu() {
text: t("main_menu.download_save_file"),
help: t("main_menu.download_save_file_help"),
async value() {
const localStorageContent: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i) as string;
const value = localStorage.getItem(key) as string;
const signedPayload =generateSaveFileContent()
// Store the key-value pair in the object
localStorageContent[key] = value;
}
const signedPayload = JSON.stringify(localStorageContent);
const dlLink = document.createElement("a");
dlLink.setAttribute(

View file

@ -88,7 +88,6 @@ export function gameOver(title: string, intro: string) {
help: "",
},
`<div id="level-recording-container"></div>`,
// pickedUpgradesHTMl(gameState),
unlocksInfo,
getHistograms(gameState),
],
@ -130,9 +129,17 @@ export function getHistograms(gameState: GameState) {
}))
.filter((l) => l.r);
gameState.runStatistics.runTime=Math.round(gameState.runStatistics.runTime)
const perks={...gameState.perks}
for(let id in perks){
if(!perks[id]){
delete perks[id]
}
}
runsHistory.push({
...gameState.runStatistics,
perks: gameState.perks,
perks,
appVersion,
});

View file

@ -448,10 +448,10 @@ export function explodeBrick(
);
if (gameState.perks.side_kick) {
if (Math.abs(ball.vx) > Math.abs(ball.vy)) {
if (ball.previousVX > 0) {
increaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y);
} else {
decreaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y);
decreaseCombo(gameState, gameState.perks.side_kick * 2, ball.x, ball.y);
}
}

View file

@ -92,9 +92,9 @@ export function max_levels(gameState: GameState) {
export function pickedUpgradesHTMl(gameState: GameState) {
const upgradesList = getPossibleUpgrades(gameState)
.filter((u) => gameState.perks[u.id])
.filter((u) => gameState.perks[u.id])
.map((u) => {
const newMax = Math.max(0, u.max +gameState.perks.limitless);
const newMax = Math.max(0, u.max + gameState.perks.limitless);
let bars = [];
for (let i = 0; i < Math.max(u.max, newMax, gameState.perks[u.id]); i++) {

View file

@ -0,0 +1,13 @@
export function generateSaveFileContent() {
const localStorageContent: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i) as string;
// Avoid including recovery info in the recovery info
if(['recovery_data'].includes(key)) continue
const value = localStorage.getItem(key) as string;
localStorageContent[key] = value;
}
return JSON.stringify(localStorageContent);
}

View file

@ -3566,7 +3566,7 @@
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
<approved>false</approved>
</translation>
</translations>
</concept_node>
@ -3581,7 +3581,7 @@
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
<approved>false</approved>
</translation>
</translations>
</concept_node>

View file

@ -68,7 +68,7 @@
"main_menu.footer_html": "<p> \n<span>Made in France by <a href=\"https://lecaro.me\">Renan LE CARO</a>.</span> \n<a href=\"https://paypal.me/renanlecaro\" target=\"_blank\">Donate</a>\n<a href=\"https://discord.gg/bbcQw4x5zA\" target=\"_blank\">Discord</a>\n<a href=\"https://f-droid.org/en/packages/me.lecaro.breakout/\" target=\"_blank\">F-Droid</a>\n<a href=\"https://play.google.com/store/apps/details?id=me.lecaro.breakout\" target=\"_blank\">Google Play</a>\n<a href=\"https://renanlecaro.itch.io/breakout71\" target=\"_blank\">itch.io</a> \n<a href=\"https://gitlab.com/lecarore/breakout71\" target=\"_blank\">Gitlab</a>\n<a href=\"https://breakout.lecaro.me/\" target=\"_blank\">Web version</a>\n<a href=\"https://news.ycombinator.com/item?id=43183131\" target=\"_blank\">HackerNews</a>\n<a href=\"https://breakout.lecaro.me/privacy.html\" target=\"_blank\">Privacy Policy</a>\n<span>v.{{appVersion}}</span>\n</p>\n",
"main_menu.fullscreen": "Fullscreen",
"main_menu.fullscreen_help": "Game will try to go full screen before starting",
"main_menu.help_content": "# Goal\n\nThe goal is to catch as many coins as possible during 7 levels. \nCoins appear when you break bricks.\nThey fly around, bounce and roll, and you need to catch them with your puck to increase your score. \nYour score is displayed in the top right corner of the screen.\nYou must delete all bricks to progress to the next level. \nIf you drop the ball, it's game over, unless you had the \"extra life\" upgrade.\n\n# Upgrades \n\nAfter clearing a level, you'll be able to pick upgrades among a small selection presented to you. \n\nThe upgrade you pick will apply until the end of the run. You will get more upgrade if you play well : catch all coins, clear the level quickly, never miss the bricks, never bounce on the sides or ceiling.\n\nIf you play very well, you'll also get \"rerolls\" that allow you to shuffle the list of upgrades that are offered to you. Once an upgrade is offered, it's less likely to reappear afterward. \n\nYou also get a free random upgrade at the beginning of each run. There's also an easy mode for kids, where the game will always start with the \"slower ball\" upgrade. You can see which upgrades you have and more by clicking your score at the top right of the screen. \n\nUpgrades apply to the whole run and can synergize, or really work against each other. Most of the fun of the game is discovering which ones work best together.\n\nSome upgrades help with aiming, like \"Telekinesis\". Some upgrades can be picked multiple times to increase the effect, you'll see for example \"+1 ball level 2\" which adds a third ball.\n\nWhen you first play, only a few upgrades are available, you unlock the rest by simply playing and scoring points. There's a similar \nmechanic for levels unlock. At the end of a run, the things you just unlocked will be shown, and you can check the full content in menu / unlocks.\n\nMany upgrades impact your combo. \n\n# Combo\n\nYour \"combo\" is the number of coins spawned when a brick breaks. It is displayed on your puck, for example x4 means each\nbrick will spawn 4 coins. It will reset if you miss. \n\nMany upgrades will increase the combo when you break a brick, but also add a condition to reset it. So the more upgrades you pick, the faster it will climb, but the more likely it will be that it returns to it's base value. \n\n# Aiming\n\nWhat decides how the ball flies away is only the position of the puck hit. If the ball hits the puck dead center, it will bounce back up vertically, while in you hit more on one side, it will have more angle. \n\nThe puck speed and incoming angle have no impact on the ball direction after bouncing.\n\nYou might find that a smaller puck makes it a bit easier to aim near corners, but also makes it much harder to catch coins.\n\n\"Wind\" and \"puck controls ball\" can help you aim even after the ball bounced to the wrong direction.\n\n\"Slower ball\" gives you a bit more time to aim, particularly useful in later levels where the ball goes faster. The ball also\naccelerates as you spend time in each level. \n\n# System requirements \n\nThe game should perform well even on low-end devices. It's very lean and does not take much storage space (Roughly 0.1MB). The web version is supposed to work on iOS safari, Firefox ESR and chrome, on desktop and mobile.\n\nIf the app stutters, turn on \"fast mode\" in the settings to render a simplified view that should be faster. You can adjust many aspects of the game there, go have a look ! \n\n# Playing offline \n\nBreakout 71 can work offline in many ways:\n\n- play store : https://play.google.com/store/apps/details?id=me.lecaro.breakout\n- fdroid : https://f-droid.org/packages/me.lecaro.breakout/\n- html file on pc : https://renanlecaro.itch.io/breakout71\n\n",
"main_menu.help_content": "# Goal\n\nThe goal is to catch as many coins as possible during 7 levels. \nCoins appear when you break bricks.\nThey fly around, bounce and roll, and you need to catch them with your puck to increase your score. \nYour score is displayed in the top right corner of the screen.\nYou must delete all bricks to progress to the next level. \nIf you drop the ball, it's game over, unless you had the \"extra life\" upgrade.\n\n# Upgrades \n\nAfter clearing a level, you'll be able to pick upgrades among a small selection presented to you. \n\nThe upgrade you pick will apply until the end of the run. You will get more upgrade if you play well : catch all coins, clear the level quickly, never miss the bricks, never bounce on the sides or ceiling.\n\nIf you play very well, you'll also get \"rerolls\" that allow you to shuffle the list of upgrades that are offered to you. Once an upgrade is offered, it's less likely to reappear afterward. \n\nYou also get a free random upgrade at the beginning of each run. There's also an easy mode for kids, where the game will always start with the \"slower ball\" upgrade. You can see which upgrades you have and more by clicking your score at the top right of the screen. \n\nUpgrades apply to the whole run and can synergize, or really work against each other. Most of the fun of the game is discovering which ones work best together.\n\nSome upgrades help with aiming, like \"Telekinesis\". Some upgrades can be picked multiple times to increase the effect, you'll see for example \"+1 ball level 2\" which adds a third ball.\n\nWhen you first play, only a few upgrades are available, you unlock the rest by simply playing and scoring points. There's a similar \nmechanic for levels unlock. At the end of a run, the things you just unlocked will be shown, and you can check the full content in menu / unlocks.\n\nMany upgrades impact your combo. \n\n# Combo\n\nYour \"combo\" is the number of coins spawned when a brick breaks. It is displayed on your puck, for example x4 means each\nbrick will spawn 4 coins. It will reset if you miss. \n\nMany upgrades will increase the combo when you break a brick, but also add a condition to reset it. So the more upgrades you pick, the faster it will climb, but the more likely it will be that it returns to it's base value. \n\n# Aiming\n\nWhat decides how the ball flies away is only the position of the puck hit. If the ball hits the puck dead center, it will bounce back up vertically, while in you hit more on one side, it will have more angle. \n\nThe puck speed and incoming angle have no impact on the ball direction after bouncing.\n\nYou might find that a smaller puck makes it a bit easier to aim near corners, but also makes it much harder to catch coins.\n\n\"Wind\" and \"puck controls ball\" can help you aim even after the ball bounced to the wrong direction.\n\n\"Slower ball\" gives you a bit more time to aim, particularly useful in later levels where the ball goes faster. The ball also\naccelerates as you spend time in each level. \n\n# Unlocks\n\nWhen starting breakout 71 for the first time, you can access a few upgrades and levels. The rest needs to be unlocked. Upgrades are unlocked by simply playing. Every time you catch a coin, your total score is raised by one. Then once you reach the threshold of the upgrade, it's unlocked.\n\nUnlocking levels is a bit different, it requires you to play very well, in many different ways. The first levels just need a high score of X, but later level are unlocked by reaching a high score of X with perk Y and without perk Z. All those requirements are pseudo random and not handpicked, so some are likely much harder than others. They should be the same for everyone though. It might happen that an update of the game perks list changes the required perks. \n\n# System requirements \n\nThe game should perform well even on low-end devices. It's very lean and does not take much storage space (Roughly 0.1MB). The web version is supposed to work on iOS safari, Firefox ESR and chrome, on desktop and mobile.\n\nIf the app stutters, turn on \"fast mode\" in the settings to render a simplified view that should be faster. You can adjust many aspects of the game there, go have a look ! \n\n# Playing offline \n\nBreakout 71 can work offline in many ways:\n\n- play store : https://play.google.com/store/apps/details?id=me.lecaro.breakout\n- fdroid : https://f-droid.org/packages/me.lecaro.breakout/\n- html file on pc : https://renanlecaro.itch.io/breakout71\n\n",
"main_menu.help_help": "Learn more about general aspects of the game",
"main_menu.help_title": "Help and credits",
"main_menu.help_upgrades": "<h2>Upgrades</h2>",
@ -276,9 +276,9 @@
"upgrades.shunt.fullHelp": "If you also have hot start, the hot start is just added to the current combo",
"upgrades.shunt.help": "Keep {{percent}}% of your combo between levels",
"upgrades.shunt.name": "Shunt",
"upgrades.side_kick.fullHelp": "When a brick get hit, the game checks the ball's velocity, and add +1 to the combo if its horizontal velocity is higher than its vertical velocity. The combo will decrease by one otherwise. The location of the impact on the brick is irrelevant. ",
"upgrades.side_kick.help": "+{{lvl}} combo per brick broken horizontally, -{{lvl}} otherwise",
"upgrades.side_kick.name": "Side kick",
"upgrades.side_kick.fullHelp": "When a brick get hit, the game checks the ball's horizontal velocity, and add +1 to the combo it is towards the right. The combo will decrease by 2 otherwise. The location of the impact on the brick is irrelevant. ",
"upgrades.side_kick.help": "+{{lvl}} combo per brick broken from the left, -{{loss}} otherwise",
"upgrades.side_kick.name": "Left handed",
"upgrades.skip_last.fullHelp": "You need to break all bricks to go to the next level. However, it can be hard to get the last ones. \n\nClearing a level early brings extra choices when upgrading. Never missing the bricks is also very beneficial. \n\nSo if you find it difficult to break the last bricks, getting this perk a few time can help.",
"upgrades.skip_last.help": "The last brick will explode.",
"upgrades.skip_last.help_plural": "The last {{lvl}} bricks will explode.",

File diff suppressed because one or more lines are too long

View file

@ -1,17 +1,28 @@
import { RunHistoryItem } from "./types";
import _appVersion from "./data/version.json";
import {generateSaveFileContent} from "./generateSaveFileContent";
// The page will be reloaded if any migrations were run
let migrationsRun=0
function migrate(name: string, cb: () => void) {
if (!localStorage.getItem(name)) {
try {
cb();
console.debug("Ran migration : " + name);
localStorage.setItem(name, "" + Date.now());
migrationsRun++
} catch (e) {
console.warn("Migration " + name + " failed : ", e);
}
}
}
migrate("save_data_before_upgrade_to_"+_appVersion, () => {
localStorage.setItem("recovery_data",JSON.stringify(generateSaveFileContent()));
});
migrate("migrate_high_scores", () => {
const old = localStorage.getItem("breakout-3-hs");
if (old) {
@ -42,6 +53,7 @@ migrate("remove_long_and_creative_mode_data", () => {
) as RunHistoryItem[];
let cleaned = runsHistory.filter((r) => {
if(!r.perks) return
if ("mode" in r) {
if (r.mode !== "short") {
return false;
@ -52,3 +64,28 @@ migrate("remove_long_and_creative_mode_data", () => {
if (cleaned.length !== runsHistory.length)
localStorage.setItem("breakout_71_runs_history", JSON.stringify(cleaned));
});
migrate("compact_runs_data", () => {
let runsHistory = JSON.parse(
localStorage.getItem("breakout_71_runs_history") || "[]",
) as RunHistoryItem[];
runsHistory.forEach((r) => {
r.runTime=Math.round(r.runTime)
for(let key in r.perks){
if(r.perks && !r.perks[key]){
delete r.perks[key]
}
}
});
localStorage.setItem("breakout_71_runs_history", JSON.stringify(runsHistory));
});
// Avoid a boot loop by setting the hash before reloading
// We can't set the query string as it is used for other things
if(migrationsRun && !window.location.hash){
window.location.hash='#reloadAfterMigration'
window.location.reload()
}

View file

@ -29,6 +29,13 @@ export function getRunLevels(params: RunParams) {
.filter((l) => l.name !== params?.levelToAvoid)
.sort(() => Math.random() - 0.5);
console.log("getRunLevels", {
params,
history,
unlocked,
firstLevel,
restInRandomOrder,
});
return firstLevel.concat(
restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey),
);

3
src/types.d.ts vendored
View file

@ -148,7 +148,6 @@ export type RunStats = {
upgrades_picked: number;
max_combo: number;
max_level: number;
best_level_score: number;
worst_level_score: number;
};
@ -165,7 +164,7 @@ export type ReusableArray<T> = {
};
export type RunHistoryItem = RunStats & {
perks?: PerksMap;
perks?: Partial<PerksMap>;
appVersion?: string;
};
export type GameState = {

View file

@ -19,7 +19,6 @@ export const rawUpgrades = [
},
{
requires: "",
threshold: 0,
id: "streak_shots",
giftable: true,
@ -609,7 +608,7 @@ export const rawUpgrades = [
id: "side_kick",
max: 3,
name: t("upgrades.side_kick.name"),
help: (lvl: number) => t("upgrades.side_kick.help", { lvl }),
help: (lvl: number) => t("upgrades.side_kick.help", { lvl, loss: lvl * 2 }),
fullHelp: t("upgrades.side_kick.fullHelp"),
},
{
@ -661,8 +660,7 @@ export const rawUpgrades = [
id: "limitless",
max: 1,
name: t("upgrades.limitless.name"),
help: (lvl: number) =>
t("upgrades.limitless.help", { lvl }),
help: (lvl: number) => t("upgrades.limitless.help", { lvl }),
fullHelp: t("upgrades.limitless.fullHelp"),
},
}
] as const;