Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,18 @@ See [`CONTRIBUTING-POSIT.md`](CONTRIBUTING-POSIT.md) and [`.cursor/skills/posit-

All random outcomes use OS-backed CSPRNG (`secrets.SystemRandom()` in Python, `crypto.getRandomValues()` in the browser). No outcome manipulation.

## GitHub profile badge

Copy into a profile README under **Highlighted Projects** (same style as other for-the-badge project links):

```markdown
[![The Mandalay Bay](https://img.shields.io/badge/The%20Mandalay%20Bay-8A2BE2?style=for-the-badge&labelColor=333&logo=readme&logoColor=white)](https://github.com/Exios66/degen-llms)
```

Preview:

[![The Mandalay Bay](https://img.shields.io/badge/The%20Mandalay%20Bay-8A2BE2?style=for-the-badge&labelColor=333&logo=readme&logoColor=white)](https://github.com/Exios66/degen-llms)

## License

MIT — see [LICENSE](LICENSE).
2 changes: 2 additions & 0 deletions docs/css/casino.css
Original file line number Diff line number Diff line change
Expand Up @@ -3153,6 +3153,8 @@ body {
display: block;
max-width: 100%;
height: auto;
min-width: 48px;
min-height: 36px;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
}

Expand Down
8 changes: 4 additions & 4 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/casino.css">
<link rel="stylesheet" href="css/intoxication-effects.css">
<link rel="stylesheet" href="css/rewards-phone.css">
<link rel="stylesheet" href="css/casino.css?v=__ASSET_SHA__">
<link rel="stylesheet" href="css/intoxication-effects.css?v=__ASSET_SHA__">
<link rel="stylesheet" href="css/rewards-phone.css?v=__ASSET_SHA__">
</head>
<body>
<div id="app" aria-live="polite">
Expand All @@ -38,7 +38,7 @@
</div>
</div>
<div id="rewards-phone" aria-live="polite"></div>
<script type="module" src="js/app.js"></script>
<script type="module" src="js/app.js?v=__ASSET_SHA__"></script>
<script>
window.addEventListener("error", function (e) {
var app = document.getElementById("app");
Expand Down
27 changes: 25 additions & 2 deletions docs/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ function renderSavePicker() {
banner(CASINO_NAME),
el("p", { className: "subtitle", textContent: "Save Library" }),
el("p", { className: "dim", textContent: "Select a save slot to continue, create a new visit, or play as a guest:" }),
el("p", { className: "dim", textContent: "Save slots are shared with Pixel RPG mode — wallet, hotel, and MGM Rewards carry over." }),
]),
]);

Expand Down Expand Up @@ -290,7 +291,28 @@ function renderSavePicker() {

panel.appendChild(el("p", { className: "subtitle", textContent: "Save slots:" }));
panel.appendChild(menuList);
panel.appendChild(el("p", { className: "footer-note", textContent: "Your most recent saves appear at the top of the library." }));

const occupied = allSlots.filter((slot) => slot.occupied);
if (occupied.length) {
panel.appendChild(el("p", { className: "subtitle", textContent: "Pixel RPG (shared slots):" }));
const rpgList = el("ul", { className: "menu-list" });
for (const slot of occupied) {
const mode = slot.hasRpgProgress ? "continue RPG" : "carry profile into RPG";
rpgList.appendChild(el("li", {}, [
el("button", {
className: "menu-btn",
innerHTML: `<span class="num">→</span> Slot ${slot.slotId} — ${slot.playerName} (${mode})`,
onclick: () => {
persist();
window.location.href = `./rpg/?slot=${slot.slotId}&skipIntro=1`;
},
}),
]));
}
panel.appendChild(rpgList);
}

panel.appendChild(el("p", { className: "footer-note", textContent: "Your most recent saves appear at the top of the library. Chips, hotel, and rewards sync between terminal and RPG." }));
return container;

function handleSlotChoice(slot) {
Expand Down Expand Up @@ -473,8 +495,9 @@ function renderHub() {
} else if (choice === FLOOR_ORDER.length + 7) {
pushView("casino-floor");
} else if (choice === FLOOR_ORDER.length + 8) {
persist();
const rpgUrl = session.slotId != null
? `./rpg/?slot=${session.slotId}`
? `./rpg/?slot=${session.slotId}&skipIntro=1`
: "./rpg/?guest=1";
window.location.href = rpgUrl;
} else {
Expand Down
58 changes: 58 additions & 0 deletions docs/js/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,62 @@ export function migrateRpgState(rpg, legacyRpgData = null) {
return merged;
}

/** True when a slot already has meaningful pixel-RPG progress. */
export function hasRpgProgress(rpg) {
if (!rpg) return false;
if (rpg.archetype) return true;
const visits = rpg.mapVisits && Object.keys(rpg.mapVisits).length > 0;
const eggs = rpg.eggs && Object.keys(rpg.eggs).length > 0;
const flags = rpg.flags && Object.keys(rpg.flags).length > 0;
const quests = rpg.quests && Object.values(rpg.quests).some(
(q) => q?.complete || (q?.progress ?? 0) > 0,
);
return visits || eggs || flags || quests;
}

/** True when the session has casino-terminal progress worth carrying into RPG. */
export function hasCasinoProfileProgress(session) {
if (!session) return false;
const stats = session.activityStats ?? {};
if (Object.values(stats).some((s) => (s?.visits ?? 0) > 0 || (s?.handsOrBets ?? 0) > 0)) {
return true;
}
if ((session.casinoTimeMs ?? 0) > 0) return true;
if (session.hotel?.foundReservation || session.hotel?.reachedRoom) return true;
if ((session.rewards?.lifetimeWagered ?? 0) > 0) return true;
if ((session.amenities?.purchasedItems?.length ?? 0) > 0) return true;
return false;
}

/** Slot was played in the web terminal but has not entered the overworld yet. */
export function isCasinoOnlyProfile(session) {
return hasCasinoProfileProgress(session) && !hasRpgProgress(session.rpg);
}

/**
* Prepare a shared save slot for pixel RPG without erasing casino progress.
* @returns {{ session: PlayerSession, importedFromCasino: boolean, needsCharacterSetup: boolean }}
*/
export function bootstrapSessionForRpg(session) {
const needsCharacterSetup = !hasRpgProgress(session.rpg);
const importedFromCasino = needsCharacterSetup && hasCasinoProfileProgress(session);
const rpg = session.ensureRpgState();
// defaultRpgState() fills archetype/appearance; strip them for first-time RPG
// entry so casino-only slots stay "needs setup" until the character creator runs.
if (needsCharacterSetup) {
rpg.archetype = null;
rpg.appearance = null;
}
if (rpg.worldTime == null) rpg.worldTime = 720;
if (!rpg.reputation) rpg.reputation = { whales: 0, staff: 0, tourists: 0 };
if (!Array.isArray(rpg.inventory)) rpg.inventory = [];
for (const key of ["dex", "eggs", "mapVisits"]) {
if (!rpg[key] || typeof rpg[key] !== "object") rpg[key] = {};
}
if (!rpg.options) rpg.options = { ...defaultRpgState().options };
return { session, importedFromCasino, needsCharacterSetup };
}

export const TransactionKind = {
BUY_IN: "buy_in",
CASH_OUT: "cash_out",
Expand Down Expand Up @@ -408,6 +464,7 @@ function updateSummary(lib, session) {
balance: session.wallet.balance,
updatedAt: new Date().toISOString(),
casinoTimeMs: session.casinoTimeMs ?? 0,
hasRpgProgress: hasRpgProgress(session.rpg),
};
}

Expand All @@ -425,6 +482,7 @@ export function listSlots() {
balance: meta.balance ?? 0,
updatedAt: meta.updatedAt ?? null,
casinoTimeMs: meta.casinoTimeMs ?? 0,
hasRpgProgress: meta.hasRpgProgress ?? false,
occupied,
});
}
Expand Down
11 changes: 9 additions & 2 deletions docs/js/ui/racing-renderers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { ACTIVITIES, signedChips } from "../core.js";
import { fmtOddsEq, generateDressage, generateJumper, settleDressageTicket, settleJumperTicket, simulateDressage, simulateJumper } from "../equestrian.js";
import { createRacePreview, createRaceTrackView } from "../horse-race-track.js";
import { createHorseSpriteCanvas, getHorseSprite, getJockeySilks } from "../horse-sprites.js";
import { assignHorseSprites, createHorseSpriteCanvas, getHorseSprite, getJockeySilks } from "../horse-sprites.js";
import { fmtOdds as fmtRaceOdds, generateRace, getHorseNamePool, parseHorseNamesCSV, setCustomHorseNames, settleTicket, simulateRace } from "../horse_racing.js";
import { effectiveTableStakes, formatStakeRange } from "../stakes.js";

Expand Down Expand Up @@ -48,8 +48,15 @@ export function buildRacingRenderers(ctx) {
return card;
}

function withHorseSpriteIds(horses) {
if (!horses.some((h) => !h.spriteId)) return horses;
const spriteIds = assignHorseSprites(horses.length, 0);
return horses.map((h, i) => (h.spriteId ? h : { ...h, spriteId: spriteIds[i] }));
}

function renderHorsePaddock(horses, { selectedNumber = null, onSelect = null } = {}) {
return el("div", { className: "racing-paddock" }, horses.map((h) =>
const roster = withHorseSpriteIds(horses);
return el("div", { className: "racing-paddock" }, roster.map((h) =>
horsePaddockCard(h, {
selected: selectedNumber === h.number,
onClick: onSelect ? () => onSelect(h.number) : null,
Expand Down
8 changes: 8 additions & 0 deletions docs/rpg/css/rpg.css
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,14 @@ body {
color: var(--text);
}

.title-carry-stats {
margin: 0.75rem 0 1.25rem;
color: #c8e8ff;
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
line-height: 1.6;
}

@keyframes titleResumePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(232, 197, 71, 0.25); }
50% { box-shadow: 0 0 16px 2px rgba(232, 197, 71, 0.18); }
Expand Down
6 changes: 3 additions & 3 deletions docs/rpg/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<!-- Terminal styles come first so the RPG's pixel skin overrides them. -->
<link rel="stylesheet" href="../css/casino.css?v=pr89-main-1">
<link rel="stylesheet" href="css/rpg.css?v=pr89-main-1">
<link rel="stylesheet" href="../css/casino.css?v=pr89-carry-1">
<link rel="stylesheet" href="css/rpg.css?v=pr89-carry-1">
<link rel="stylesheet" href="../css/intoxication-effects.css">
<link rel="stylesheet" href="../css/rewards-phone.css">
<script type="importmap">
Expand Down Expand Up @@ -49,7 +49,7 @@ <h1 class="title-intro-logo">The Mandalay Bay</h1>
</div>
</div>
</div>
<script type="module" src="js/main.js?v=pr89-main-1"></script>
<script type="module" src="js/main.js?v=pr89-carry-1"></script>
<script>
// Surface module-load failures that would otherwise leave a blank title.
window.addEventListener("error", (event) => {
Expand Down
86 changes: 76 additions & 10 deletions docs/rpg/js/scenes/TitleScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
formatSaveTime,
fmtChips,
formatSaveSlotPlayTimes,
hasCasinoProfileProgress,
bootstrapSessionForRpg,
} from "../../../js/core.js";
import { getActiveProfileSummary, getActiveSlotId } from "../../../js/profileCache.js";
import { getWorldCycleState } from "../../../js/world-cycle.js";
Expand Down Expand Up @@ -202,8 +204,7 @@ export class TitleScreen {
if (this.launchSlotId != null) {
const session = loadSlot(this.launchSlotId);
if (session) {
initSessionRpg(session);
this._start(session);
this._beginSlotSession(session);
return;
}
}
Expand All @@ -227,7 +228,7 @@ export class TitleScreen {

const sub = document.createElement("p");
sub.className = "subtitle";
sub.textContent = "Pixel RPG — 28 rooms of Mandalay Bay";
sub.textContent = "Same save slots as the web terminal — chips, hotel, and rewards carry over.";
panel.appendChild(sub);

const active = getActiveProfileSummary(listSlots);
Expand Down Expand Up @@ -280,7 +281,11 @@ export class TitleScreen {
const guestBtn = document.createElement("button");
guestBtn.type = "button";
guestBtn.textContent = "Guest visit (no save)";
guestBtn.onclick = () => this._promptArchetype(initSessionRpg(createGuestSession()));
guestBtn.onclick = () => {
const guest = createGuestSession();
initSessionRpg(guest);
this._promptArchetype(guest);
};
actions.appendChild(guestBtn);
panel.appendChild(actions);

Expand All @@ -299,7 +304,11 @@ export class TitleScreen {
const btn = document.createElement("button");
btn.type = "button";
if (isActive) btn.classList.add("active-profile");
btn.textContent = `${slot.label}: ${slot.playerName} — ${fmtChips(slot.balance)} · ${formatSaveSlotPlayTimes(slot.casinoTimeMs)} (${formatSaveTime(slot.updatedAt)})`;
const mode = slot.occupied
? (slot.hasRpgProgress ? "casino + RPG" : "casino profile")
: "";
const modeSuffix = mode ? ` · ${mode}` : "";
btn.textContent = `${slot.label}: ${slot.playerName} — ${fmtChips(slot.balance)} · ${formatSaveSlotPlayTimes(slot.casinoTimeMs)}${modeSuffix} (${formatSaveTime(slot.updatedAt)})`;
btn.onclick = () => this._loadAndStart(slot.slotId);
li.appendChild(btn);
return li;
Expand Down Expand Up @@ -366,7 +375,11 @@ export class TitleScreen {
renderCharacterCreator(this.root, {
session,
title: "Choose Your Guest",
onComplete: (s) => this._start(s),
onComplete: (s) => {
initSessionRpg(s);
new SaveAdapter(s).persist();
this._start(s);
},
onBack: () => this._renderMain(),
});
}
Expand All @@ -381,15 +394,68 @@ export class TitleScreen {
_loadAndStart(slotId) {
const session = loadSlot(slotId);
if (!session) return;
initSessionRpg(session);
const rpg = session.ensureRpgState();
if (!rpg.archetype) {
this._promptArchetype(session);
this._beginSlotSession(session);
}

_beginSlotSession(session) {
const { importedFromCasino, needsCharacterSetup } = bootstrapSessionForRpg(session);
initSessionRpg(session, null, { allowDefaultArchetype: false });
if (needsCharacterSetup) {
this._promptCarryOver(session, importedFromCasino);
return;
}
initSessionRpg(session);
this._start(session);
}

_promptCarryOver(session, importedFromCasino) {
this.root.innerHTML = "";
const panel = document.createElement("div");
panel.className = "title-panel title-panel--enter title-panel--visible";

const h1 = document.createElement("h1");
h1.textContent = importedFromCasino ? "Carry Over Casino Profile" : "Enter the Resort";
panel.appendChild(h1);

const intro = document.createElement("p");
intro.className = "subtitle";
intro.textContent = importedFromCasino
? "Your web terminal progress loads into this save slot — wallet, hotel, and MGM Rewards stay synced."
: "Pick a guest look before walking the floor.";
panel.appendChild(intro);

const stats = document.createElement("p");
stats.className = "title-carry-stats";
const lines = [`${session.playerName} · ${fmtChips(session.wallet.balance)}`];
if ((session.casinoTimeMs ?? 0) > 0) {
lines.push(formatSaveSlotPlayTimes(session.casinoTimeMs));
}
if (session.rewards?.tier) {
lines.push(`MGM Rewards ${session.rewards.tier}`);
}
if (session.hotel?.foundReservation) {
lines.push("Hotel reservation on file");
} else if (hasCasinoProfileProgress(session)) {
lines.push("Casino activity saved");
}
stats.textContent = lines.join(" · ");
panel.appendChild(stats);

const nextBtn = document.createElement("button");
nextBtn.type = "button";
nextBtn.textContent = "Customize guest →";
nextBtn.onclick = () => this._promptArchetype(session);
panel.appendChild(nextBtn);

const backBtn = document.createElement("button");
backBtn.type = "button";
backBtn.textContent = "Back";
backBtn.onclick = () => this._renderMain();
panel.appendChild(backBtn);

this.root.appendChild(panel);
}

_start(session) {
this.hide();
this.onStart(session);
Expand Down
Loading