Sleazy Fork is available in English.
A modern, modular, and resilient cheat/UI layer for SPNATI
// ==UserScript==
// @name Phoenix - SPNATI
// @namespace http://tampermonkey.net/
// @version 1.0.1
// @description A modern, modular, and resilient cheat/UI layer for SPNATI
// @author ARandomGuyWithAComputer
// @match https://spnati.net/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/**
* ==========================================
* PHOENIX NAMESPACE
* ==========================================
*/
const Phoenix = window.Phoenix = {
version: '1.0.1',
modules: {},
isGameReady: false,
state: {}, // Centralized state storage
register(module) {
if (!module || !module.name) {
console.error("[Phoenix] Attempted to register an invalid module.");
return;
}
this.modules[module.name] = module;
},
safeExecute(fn, context = 'Unknown') {
try {
return fn();
} catch (error) {
if (this.modules.Logger) {
this.modules.Logger.error(`[${context}] Critical Failure:`, error);
} else {
console.error(`[Phoenix] [${context}] Critical Failure:`, error);
}
return null;
}
},
async bootstrap() {
const coreModules = Object.values(this.modules).filter(m => m.category === 'Core');
for (const mod of coreModules) {
this.safeExecute(() => mod.init?.(), `Core Init: ${mod.name}`);
}
this.modules.Logger?.info("Waiting for SPNATI game objects...");
await this.modules.Utils.waitForGameReady();
this.isGameReady = true;
this.modules.Logger?.info("Game objects detected. Initializing features...");
const featureModules = Object.values(this.modules).filter(m => m.category !== 'Core');
for (const mod of featureModules) {
this.safeExecute(() => mod.init?.(), `Feature Init: ${mod.name}`);
}
this.modules.Logger?.info(`Phoenix v${this.version} fully operational.`);
}
};
/**
* ==========================================
* CORE: LOGGER
* ==========================================
*/
const Logger = {
name: 'Logger',
category: 'Core',
prefix: '[Phoenix]',
info: (...args) => console.log(Logger.prefix, ...args),
warn: (...args) => console.warn(Logger.prefix, ...args),
error: (...args) => console.error(Logger.prefix, ...args),
debug: (...args) => console.debug(Logger.prefix, ...args),
init() {}
};
Phoenix.register(Logger);
/**
* ==========================================
* CORE: STATE
* ==========================================
*/
const State = {
name: 'State',
category: 'Core',
_data: {},
get(key, defaultValue = null) {
return this._data.hasOwnProperty(key) ? this._data[key] : defaultValue;
},
set(key, value) {
this._data[key] = value;
},
init() {
Logger.info("State manager initialized.");
}
};
Phoenix.register(State);
/**
* ==========================================
* CORE: SETTINGS
* ==========================================
*/
const Settings = {
name: 'Settings',
category: 'Core',
STORAGE_KEY: 'phoenix_settings',
defaults: {
persistUnlocks: false,
uiScale: 1.0,
themeColor: '#ff3b3b'
},
current: {},
load() {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
this.current = saved ? JSON.parse(saved) : { ...this.defaults };
} catch (e) {
this.current = { ...this.defaults };
}
},
save() {
try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.current)); } catch (e) {}
},
get(key, defaultValue = null) {
return this.current.hasOwnProperty(key) ? this.current[key] : (defaultValue !== null ? defaultValue : this.defaults[key]);
},
set(key, value) {
this.current[key] = value;
this.save();
},
init() { this.load(); Logger.info("Settings manager initialized."); }
};
Phoenix.register(Settings);
/**
* ==========================================
* CORE: UTILS
* ==========================================
*/
const Utils = {
name: 'Utils',
category: 'Core',
waitForGameReady(timeout = 30000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const interval = setInterval(() => {
if (typeof window.players !== "undefined" && typeof window.Card !== "undefined") {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error("Timeout waiting for SPNATI game objects."));
}
}, 100);
});
},
createElement(tag, props = {}) {
const el = document.createElement(tag);
Object.entries(props).forEach(([key, val]) => {
if (key === 'style' && typeof val === 'object') {
Object.assign(el.style, val);
} else if (key === 'class') {
el.className = val;
} else if (key === 'textContent') {
el.textContent = val;
} else if (key === 'innerHTML') {
el.innerHTML = val;
} else if (key.startsWith('on') && typeof val === 'function') {
el.addEventListener(key.substring(2), val);
} else if (key === 'dataset' && typeof val === 'object') {
Object.assign(el.dataset, val);
} else {
el.setAttribute(key, val);
}
});
return el;
},
init() {}
};
Phoenix.register(Utils);
/**
* ==========================================
* CORE: HOOKS
* ==========================================
*/
const Hooks = {
name: 'Hooks',
category: 'Core',
_registry: new Map(),
intercept(context, fnName, callback) {
if (!context || typeof context[fnName] !== 'function') {
return false;
}
const originalFn = context[fnName];
const hookId = `${context.constructor?.name || 'Global'}_${fnName}`;
if (this._registry.has(hookId)) {
return false;
}
context[fnName] = function(...args) {
return callback.call(this, originalFn, args);
};
this._registry.set(hookId, originalFn);
Logger.debug(`Hooked: ${hookId}`);
return true;
},
init() {
Logger.info("Hook manager initialized.");
}
};
Phoenix.register(Hooks);
/**
* ==========================================
* CORE: GAME ABSTRACTION
* ==========================================
*/
const Game = {
name: 'Game',
category: 'Core',
SUITS: { SPADE: 0, HEART: 1, DIAMOND: 2, CLUB: 3 },
SUIT_NAMES: ['spade', 'heart', 'diamo', 'clubs'],
RANKS: {
TWO: 2, THREE: 3, FOUR: 4, FIVE: 5, SIX: 6,
SEVEN: 7, EIGHT: 8, NINE: 9, TEN: 10,
JACK: 11, QUEEN: 12, KING: 13, ACE: 14
},
HAND_TYPES: {
HIGH_CARD: 0, PAIR: 1, TWO_PAIR: 2, THREE_OF_A_KIND: 3,
STRAIGHT: 4, FLUSH: 5, FULL_HOUSE: 6, FOUR_OF_A_KIND: 7,
STRAIGHT_FLUSH: 8, ROYAL_FLUSH: 9
},
players() { return window.players || []; },
getHuman() { return this.players()[0]; },
getOpponents() { return this.players().slice(1); },
getOpponent(identifier) {
const opponents = this.getOpponents();
if (!opponents) return null;
if (typeof identifier === 'number') {
return opponents[identifier >= 0 ? identifier : opponents.length + identifier] || null;
}
if (typeof identifier === 'string') {
return opponents.find(p => p && p.name && p.name.toLowerCase().includes(identifier.toLowerCase())) || null;
}
return null;
},
// Robust name fetching: Checks memory first, then falls back to DOM IDs
getPlayerName(player, playerIndex) {
if (player && player.name) return player.name;
// Fallback 1: Check specific ID (e.g., game-name-label-3)
const labelById = document.getElementById(`game-name-label-${playerIndex}`);
if (labelById && labelById.innerText.trim()) return labelById.innerText.trim();
// Fallback 2: Query all labels and map by index
const labels = document.querySelectorAll(".bordered.name-label");
if (labels.length > playerIndex) {
return labels[playerIndex].innerText.trim();
}
return `Player ${playerIndex}`;
},
save() { return window.save || null; },
createCard(suit, rank) {
if (!window.Card) return null;
return new window.Card(suit, rank);
},
generateUniqueHand(cardRanks, targetSuit = null) {
const deck = [];
for (let r = 2; r <= 14; r++) {
for (let s = 0; s < 4; s++) {
deck.push({ rank: r, suit: s });
}
}
const hand = [];
const usedCards = new Set();
for (const rank of cardRanks) {
let validCards = deck.filter(c => c.rank === rank && !usedCards.has(`${c.rank}_${c.suit}`));
if (targetSuit !== null) validCards = validCards.filter(c => c.suit === targetSuit);
if (validCards.length === 0) validCards = deck.filter(c => c.rank === rank && !usedCards.has(`${c.rank}_${c.suit}`));
if (validCards.length > 0) {
const chosen = validCards[Math.floor(Math.random() * validCards.length)];
hand.push(this.createCard(chosen.suit, chosen.rank));
usedCards.add(`${chosen.rank}_${chosen.suit}`);
}
}
return hand;
},
injectHand(player, cardRanks, targetSuit = null) {
if (!player || !player.hand || !Array.isArray(cardRanks)) return;
player.hand.cards = this.generateUniqueHand(cardRanks, targetSuit);
if (typeof player.evaluateHand === 'function') player.evaluateHand();
if (typeof window.evaluateHands === 'function') window.evaluateHands();
},
init() { Logger.info("Game abstraction initialized."); }
};
Phoenix.register(Game);
/**
* ==========================================
* CORE: UI SYSTEM
* ==========================================
*/
const UI = {
name: 'UI',
category: 'Core',
stylesInjected: false,
styles: `
:root {
--phoenix-primary: #ff3b3b;
--phoenix-accent: #ff7b7b;
}
.phoenix-window {
position: fixed; background: rgba(25, 25, 25, 0.95); border: 1px solid #444;
border-radius: 8px; box-shadow: 0 8px 16px rgba(0,0,0,0.5); color: #eee;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; z-index: 99999;
width: 240px; min-width: 200px; min-height: 80px; display: flex; flex-direction: column;
backdrop-filter: blur(5px); resize: both; overflow: hidden;
}
.phoenix-header {
padding: 6px 12px; background: rgba(40, 40, 40, 0.8); cursor: move;
border-bottom: 1px solid #444; border-radius: 8px 8px 0 0; display: flex;
justify-content: space-between; align-items: center; user-select: none;
}
.phoenix-title { font-weight: bold; font-size: 13px; color: var(--phoenix-accent); }
.phoenix-close { cursor: pointer; font-size: 18px; line-height: 1; color: #aaa; transition: color 0.2s; }
.phoenix-close:hover { color: #fff; }
.phoenix-body { padding: 10px; overflow-y: auto; flex: 1; font-size: 12px; }
.phoenix-btn {
width: 100%; padding: 6px 10px; margin: 3px 0; background: #3a3a3a; border: 1px solid #555;
color: #ccc; border-radius: 4px; cursor: pointer; transition: all 0.2s; font-size: 12px;
}
.phoenix-btn:hover { background: #4a4a4a; color: white; }
.phoenix-btn.primary { background: var(--phoenix-primary); border-color: var(--phoenix-primary); color: white; }
.phoenix-btn.primary:hover { filter: brightness(1.2); }
#phoenix-notifications {
position: fixed; bottom: 80px; right: 20px; z-index: 100000; display: flex;
flex-direction: column-reverse; gap: 8px; pointer-events: none;
}
.phoenix-toast {
background: rgba(30, 30, 30, 0.95); color: white; padding: 8px 15px; border-radius: 6px;
border-left: 4px solid var(--phoenix-accent); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: phoenix-slide-in 0.3s ease-out forwards; font-family: sans-serif; font-size: 12px; pointer-events: auto;
}
@keyframes phoenix-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`,
injectStyles() {
if (this.stylesInjected) return;
const style = Utils.createElement('style', { textContent: this.styles });
document.head.appendChild(style);
this.stylesInjected = true;
this.applyTheme(Phoenix.modules.Settings.get('themeColor', '#ff3b3b'));
},
applyTheme(hex) {
document.documentElement.style.setProperty('--phoenix-primary', hex);
document.documentElement.style.setProperty('--phoenix-accent', this.lightenHex(hex, 30));
},
lightenHex(hex, percent) {
let r = parseInt(hex.slice(1, 3), 16);
let g = parseInt(hex.slice(3, 5), 16);
let b = parseInt(hex.slice(5, 7), 16);
r = Math.min(255, Math.floor(r + (255 - r) * (percent / 100)));
g = Math.min(255, Math.floor(g + (255 - g) * (percent / 100)));
b = Math.min(255, Math.floor(b + (255 - b) * (percent / 100)));
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
},
createWindow(id, title, options = {}) {
const existing = document.getElementById(id);
if (existing) existing.remove();
const winStyles = { top: options.top || '100px' };
if (options.right !== undefined) { winStyles.right = options.right; winStyles.left = 'auto'; }
else { winStyles.left = options.left || '100px'; }
const win = Utils.createElement('div', { id: id, class: 'phoenix-window', style: winStyles });
const header = Utils.createElement('div', { class: 'phoenix-header' });
const titleEl = Utils.createElement('span', { class: 'phoenix-title', textContent: title });
const closeBtn = Utils.createElement('span', { class: 'phoenix-close', textContent: '×' });
header.appendChild(titleEl);
header.appendChild(closeBtn);
const body = Utils.createElement('div', { class: 'phoenix-body' });
win.appendChild(header);
win.appendChild(body);
document.body.appendChild(win);
let isDragging = false, offsetX, offsetY;
const startDrag = (e) => { isDragging = true; offsetX = e.clientX - win.getBoundingClientRect().left; offsetY = e.clientY - win.getBoundingClientRect().top; };
const drag = (e) => {
if (!isDragging) return;
e.preventDefault();
let newLeft = e.clientX - offsetX;
let newTop = e.clientY - offsetY;
// BOUNDARY CLAMPING: Prevents dragging off-screen or behind browser UI
const maxLeft = window.innerWidth - win.offsetWidth;
const maxTop = window.innerHeight - win.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
win.style.left = newLeft + 'px';
win.style.top = newTop + 'px';
win.style.right = 'auto';
};
const stopDrag = () => { isDragging = false; };
header.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
closeBtn.addEventListener('click', () => {
win.style.display = 'none';
if (typeof options.onClose === 'function') options.onClose();
});
return { element: win, body: body, show: () => win.style.display = 'flex', hide: () => win.style.display = 'none' };
},
notify(message, type = 'info', duration = 3000) {
const container = document.getElementById('phoenix-notifications');
if (!container) return;
const toast = Utils.createElement('div', { class: `phoenix-toast ${type}`, textContent: message });
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'phoenix-slide-in 0.3s ease-out reverse forwards';
setTimeout(() => toast.remove(), 300);
}, duration);
},
init() {
this.injectStyles();
const notifContainer = Utils.createElement('div', { id: 'phoenix-notifications' });
document.body.appendChild(notifContainer);
Logger.info("UI System initialized.");
}
};
Phoenix.register(UI);
/**
* ==========================================
* UI: MAIN MENU
* ==========================================
*/
const MainMenu = {
name: 'MainMenu',
category: 'UI',
window: null,
fab: null,
buildUI() {
const existingFab = document.querySelector('#phoenix-fab');
if (existingFab) existingFab.remove();
this.fab = Utils.createElement('div', {
id: 'phoenix-fab', textContent: '🔥',
style: {
position: 'fixed', bottom: '20px', right: '20px', width: '50px', height: '50px',
background: 'var(--phoenix-primary)', color: 'white', borderRadius: '50%', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: '24px', cursor: 'pointer',
zIndex: '100001', boxShadow: '0 4px 10px rgba(0,0,0,0.5)', userSelect: 'none'
},
onclick: () => this.toggleWindow()
});
document.body.appendChild(this.fab);
this.window = UI.createWindow('phoenix-main-menu', 'Phoenix Control Panel', { top: '100px', right: '20px' });
const features = [
{ name: 'Hand Builder', action: () => Phoenix.modules.HandBuilder.toggle(), type: 'button' },
{ name: 'Card Inspector', action: () => Phoenix.modules.CardInspector.toggle(), type: 'toggle' },
{ name: 'Loser Picker', action: () => Phoenix.modules.LoserPicker.toggle(), type: 'toggle' },
{ name: 'Wardrobe Manager', action: () => Phoenix.modules.Wardrobe.toggle(), type: 'button' },
{ name: 'Trigger Epilogue', action: () => Phoenix.modules.Epilogue.triggerEpilogue(), type: 'button' },
{ name: 'Temporary Unlocks', action: () => Phoenix.modules.Unlocks.applyTemporaryPatches(), type: 'button' },
{ name: 'Permanent Unlocks', action: () => Phoenix.modules.PermanentUnlocks.inject(), type: 'button' }
];
features.forEach(feat => {
if (feat.type === 'button') {
const btn = Utils.createElement('button', { class: 'phoenix-btn primary', textContent: feat.name, onclick: feat.action });
this.window.body.appendChild(btn);
} else {
const row = Utils.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0' } });
const label = Utils.createElement('span', { textContent: feat.name, style: { fontSize: '13px' } });
// ADDED ID for syncing state across modules
const toggleId = `phoenix-toggle-${feat.name.replace(/\s+/g, '-').toLowerCase()}`;
const toggle = Utils.createElement('input', { type: 'checkbox', id: toggleId, onchange: feat.action });
if (feat.checked) toggle.checked = true;
row.appendChild(label); row.appendChild(toggle);
this.window.body.appendChild(row);
}
});
const themeRow = Utils.createElement('div', { style: { display: 'flex', gap: '10px', alignItems: 'center', margin: '15px 0 5px 0', borderTop: '1px solid #444', paddingTop: '10px' } });
const colorPicker = Utils.createElement('input', { type: 'color', value: Phoenix.modules.Settings.get('themeColor', '#ff3b3b'), style: { width: '30px', height: '25px', padding: 0, border: 'none', background: 'none', cursor: 'pointer' } });
colorPicker.addEventListener('input', (e) => {
Phoenix.modules.Settings.set('themeColor', e.target.value);
UI.applyTheme(e.target.value);
});
const resetThemeBtn = Utils.createElement('button', { class: 'phoenix-btn', textContent: 'Reset', style: { width: 'auto', padding: '4px 8px', margin: 0, fontSize: '11px' } });
resetThemeBtn.onclick = () => {
Phoenix.modules.Settings.set('themeColor', '#ff3b3b');
UI.applyTheme('#ff3b3b');
colorPicker.value = '#ff3b3b';
};
themeRow.appendChild(Utils.createElement('span', { textContent: 'Theme:', style: { fontSize: '12px' } }));
themeRow.appendChild(colorPicker);
themeRow.appendChild(resetThemeBtn);
this.window.body.appendChild(themeRow);
this.window.hide();
},
toggleWindow() {
if (!this.window || !this.window.element) return;
if (this.window.element.style.display === 'none' || this.window.element.style.display === '') this.window.show();
else this.window.hide();
},
init() { this.buildUI(); Logger.info("Main Menu initialized."); }
};
Phoenix.register(MainMenu);
/**
* ==========================================
* GAMEPLAY: HAND BUILDER
* ==========================================
*/
const HandBuilder = {
name: 'HandBuilder',
category: 'Gameplay',
window: null,
isActive: false,
presets: {
'Royal Flush': { ranks: [10, 11, 12, 13, 14], type: Game.HAND_TYPES.ROYAL_FLUSH },
'Straight Flush': { ranks: [9, 10, 11, 12, 13], type: Game.HAND_TYPES.STRAIGHT_FLUSH },
'Four of a Kind': { ranks: [14, 14, 14, 14, 13], type: Game.HAND_TYPES.FOUR_OF_A_KIND },
'Full House': { ranks: [14, 14, 14, 13, 13], type: Game.HAND_TYPES.FULL_HOUSE },
'Flush': { ranks: [14, 13, 11, 8, 6], type: Game.HAND_TYPES.FLUSH },
'Straight': { ranks: [10, 11, 12, 13, 14], type: Game.HAND_TYPES.STRAIGHT },
'Three of a Kind': { ranks: [14, 14, 14, 13, 12], type: Game.HAND_TYPES.THREE_OF_A_KIND },
'Two Pair': { ranks: [14, 14, 13, 13, 12], type: Game.HAND_TYPES.TWO_PAIR },
'Pair': { ranks: [14, 14, 13, 12, 11], type: Game.HAND_TYPES.PAIR },
'High Card (Ace)': { ranks: [14, 13, 12, 11, 9], type: Game.HAND_TYPES.HIGH_CARD }
},
buildUI() {
this.window = UI.createWindow('phoenix-win-hand', 'Hand Builder', { top: '150px', left: '150px' });
const suitLabel = Utils.createElement('div', { textContent: 'Force Suit (Optional):', style: { marginBottom: '5px', fontSize: '12px' } });
const suitSelect = Utils.createElement('select', { class: 'phoenix-btn', id: 'phoenix-suit-select' });
suitSelect.innerHTML = `<option value="-1">Random / Mixed</option><option value="${Game.SUITS.SPADE}">Spades (♠)</option><option value="${Game.SUITS.HEART}">Hearts (♥)</option><option value="${Game.SUITS.DIAMOND}">Diamonds (♦)</option><option value="${Game.SUITS.CLUB}">Clubs (♣)</option>`;
this.window.body.appendChild(suitLabel);
this.window.body.appendChild(suitSelect);
const randomizeRow = Utils.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', margin: '10px 0' } });
const randomizeCheck = Utils.createElement('input', { type: 'checkbox', id: 'phoenix-randomize-ranks' });
const randomizeLabel = Utils.createElement('label', { textContent: 'Randomize Ranks', for: 'phoenix-randomize-ranks', style: { fontSize: '12px', cursor: 'pointer' } });
randomizeRow.appendChild(randomizeCheck);
randomizeRow.appendChild(randomizeLabel);
this.window.body.appendChild(randomizeRow);
const presetLabel = Utils.createElement('div', { textContent: 'Presets (Applies to You):', style: { marginTop: '10px', marginBottom: '5px', fontSize: '12px' } });
this.window.body.appendChild(presetLabel);
Object.entries(this.presets).forEach(([name, data]) => {
const btn = Utils.createElement('button', { class: 'phoenix-btn primary', textContent: name, onclick: () => this.applyPreset({ ...data, name }) });
this.window.body.appendChild(btn);
});
this.window.hide();
},
getRandomizedRanks(handType) {
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
switch(handType) {
case Game.HAND_TYPES.STRAIGHT_FLUSH:
case Game.HAND_TYPES.STRAIGHT: {
const start = randInt(2, 10);
return [start, start+1, start+2, start+3, start+4];
}
case Game.HAND_TYPES.FOUR_OF_A_KIND: {
const quad = randInt(2, 14);
let kicker = randInt(2, 14);
while(kicker === quad) kicker = randInt(2, 14);
return [quad, quad, quad, quad, kicker];
}
case Game.HAND_TYPES.FULL_HOUSE: {
const trip = randInt(2, 14);
let pair = randInt(2, 14);
while(pair === trip) pair = randInt(2, 14);
return [trip, trip, trip, pair, pair];
}
case Game.HAND_TYPES.THREE_OF_A_KIND: {
const trip = randInt(2, 14);
let k1 = randInt(2, 14), k2 = randInt(2, 14);
while(k1 === trip) k1 = randInt(2, 14);
while(k2 === trip || k2 === k1) k2 = randInt(2, 14);
return [trip, trip, trip, k1, k2];
}
case Game.HAND_TYPES.TWO_PAIR: {
let p1 = randInt(2, 14), p2 = randInt(2, 14);
while(p2 === p1) p2 = randInt(2, 14);
let k = randInt(2, 14);
while(k === p1 || k === p2) k = randInt(2, 14);
return [p1, p1, p2, p2, k];
}
case Game.HAND_TYPES.PAIR: {
const p = randInt(2, 14);
const kickers = new Set();
while(kickers.size < 3) {
let k = randInt(2, 14);
if(k !== p) kickers.add(k);
}
return [p, p, ...Array.from(kickers)];
}
case Game.HAND_TYPES.FLUSH:
case Game.HAND_TYPES.HIGH_CARD: {
const ranks = new Set();
while(ranks.size < 5) ranks.add(randInt(2, 14));
return Array.from(ranks).sort((a,b) => b-a);
}
default: return [10, 11, 12, 13, 14]; // Royal Flush stays specific
}
},
applyPreset(presetData) {
const human = Game.getHuman();
if (!human) { UI.notify('Cannot find human player!', 'error'); return; }
const suitVal = parseInt(document.getElementById('phoenix-suit-select').value);
let targetSuit = suitVal === -1 ? null : suitVal;
const randomize = document.getElementById('phoenix-randomize-ranks')?.checked || false;
let ranks = randomize ? this.getRandomizedRanks(presetData.type) : [...presetData.ranks];
const flushTypes = [Game.HAND_TYPES.FLUSH, Game.HAND_TYPES.STRAIGHT_FLUSH, Game.HAND_TYPES.ROYAL_FLUSH];
if (targetSuit === null && flushTypes.includes(presetData.type)) targetSuit = Math.floor(Math.random() * 4);
Game.injectHand(human, ranks, targetSuit);
UI.notify(`Injected ${randomize ? 'Random ' : ''}${presetData.name}!`, 'success');
},
toggle() {
if (!this.window) this.buildUI();
if (this.window.element.style.display === 'none' || this.window.element.style.display === '') { this.window.show(); this.isActive = true; }
else { this.window.hide(); this.isActive = false; }
},
init() { this.buildUI(); Logger.info("Hand Builder module ready."); }
};
Phoenix.register(HandBuilder);
/**
* ==========================================
* GAMEPLAY: CARD INSPECTOR
* ==========================================
*/
const CardInspector = {
name: 'CardInspector',
category: 'Gameplay',
window: null,
intervalId: null,
isActive: false,
RANK_NAMES: ['', '', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'],
SUIT_SYMBOLS: ['♠', '♥', '♦', '♣'],
buildUI() {
this.window = UI.createWindow('phoenix-win-inspect', 'Card Inspector', {
top: '150px',
right: '80px',
onClose: () => {
if (this.isActive) this.stop();
// SYNC: Uncheck the Main Menu toggle when closed via 'X'
const cb = document.getElementById('phoenix-toggle-card-inspector');
if (cb) cb.checked = false;
}
});
this.window.hide();
},
start() {
if (this.isActive) return;
this.isActive = true;
if (!this.window) this.buildUI();
this.window.show();
this.intervalId = setInterval(() => {
if (!Phoenix.isGameReady) return;
Phoenix.safeExecute(() => this.update(), 'CardInspector.Update');
}, 500);
UI.notify('Card Inspector Started', 'success');
},
update() {
if (!this.window || !this.window.body) return;
let html = '<div style="font-family: monospace; font-size: 13px; line-height: 1.4;">';
let hasData = false;
for (let i = 1; i <= 4; i++) {
const p = window.players?.[i];
const displayName = Game.getPlayerName(p, i);
if (p && p.hand && p.hand.cards && p.hand.cards.length > 0) {
hasData = true;
const cards = p.hand.cards.map(c => {
const rank = c.rank !== undefined ? c.rank : c;
const suit = c.suit !== undefined ? c.suit : -1;
const r = this.RANK_NAMES[rank] || rank;
const s = suit >= 0 ? this.SUIT_SYMBOLS[suit] : '';
const color = (suit === 1 || suit === 2) ? '#ff4444' : '#ffffff';
return `<span style="color: ${color}">${r}${s}</span>`;
}).join(' ');
html += `<div style="margin-bottom: 6px;"><strong style="color: var(--phoenix-accent);">${displayName}:</strong> ${cards}</div>`;
} else {
html += `<div style="margin-bottom: 6px; color: #888;"><strong>${displayName}:</strong> Hidden</div>`;
}
}
if (!hasData && !window.players) html += 'Waiting for game data...';
html += '</div>';
this.window.body.innerHTML = html;
},
stop() {
this.isActive = false;
if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; }
if (this.window && this.window.element.style.display !== 'none') {
this.window.hide();
}
// SYNC: Uncheck the Main Menu toggle when stopped via the toggle switch
const cb = document.getElementById('phoenix-toggle-card-inspector');
if (cb) cb.checked = false;
UI.notify('Card Inspector Stopped', 'info');
},
toggle() { if (this.isActive) this.stop(); else this.start(); },
init() { this.buildUI(); Logger.info("Card Inspector module initialized."); }
};
Phoenix.register(CardInspector);
/**
* ==========================================
* GAMEPLAY: LOSER PICKER
* ==========================================
*/
const LoserPicker = {
name: 'LoserPicker',
category: 'Gameplay',
isActive: false,
boundElements: new WeakSet(),
bindInterval: null,
badHandRanks: [2, 3, 4, 5, 7],
enable() {
if (this.isActive) return;
this.isActive = true;
this.bindLabels();
this.bindInterval = setInterval(() => { if (this.isActive) this.bindLabels(); }, 2000);
UI.notify('Loser Picker Enabled', 'success');
},
disable() {
this.isActive = false;
if (this.bindInterval) { clearInterval(this.bindInterval); this.bindInterval = null; }
document.querySelectorAll('.phoenix-loser-target').forEach(el => {
el.classList.remove('phoenix-loser-target');
el.style.cursor = '';
el.style.outline = '';
const clone = el.cloneNode(true);
el.parentNode.replaceChild(clone, el);
});
UI.notify('Loser Picker Disabled', 'info');
},
bindLabels() {
const labels = document.querySelectorAll(".bordered.name-label");
labels.forEach((label) => {
if (this.boundElements.has(label)) return;
let targetPlayer = null;
let playerIndex = -1;
const idMatch = label.id.match(/(\d+)/);
if (idMatch) {
playerIndex = parseInt(idMatch[1], 10);
targetPlayer = Game.players()[playerIndex];
}
if (!targetPlayer) {
let name = label.innerText.trim().split('\n')[0].trim();
targetPlayer = Game.getOpponent(name);
if (!targetPlayer) {
const opponents = Game.getOpponents();
targetPlayer = opponents.find(p => p && p.name && (name.includes(p.name) || p.name.includes(name)));
}
}
if (!targetPlayer || !targetPlayer.hand) {
label.style.cursor = 'not-allowed';
label.addEventListener('click', (e) => {
e.stopPropagation();
UI.notify(`Debug: Clicked ${label.innerText.trim()} but no player found!`, 'error');
});
this.boundElements.add(label);
return;
}
this.boundElements.add(label);
label.classList.add('phoenix-loser-target');
label.style.cursor = 'crosshair';
const highlight = () => { if (this.isActive) label.style.outline = '2px solid #ff3b3b'; };
const unhighlight = () => { label.style.outline = ''; };
const handleClick = (e) => {
if (!this.isActive) return;
e.stopPropagation();
const displayName = label.innerText.trim().split('\n')[0].trim() || "Opponent";
Logger.debug(`Loser Picker: Injecting bad hand for ${displayName}`);
const garbageCards = Game.generateUniqueHand(this.badHandRanks);
// NATIVE INTEGRATION: Replace cards and let spniPoker.js do the math
targetPlayer.hand.cards = garbageCards;
if (typeof targetPlayer.hand.determine === 'function') {
targetPlayer.hand.determine(); // Calculates strength (1 = High Card) and value natively
}
if (typeof targetPlayer.hand.sort === 'function') {
targetPlayer.hand.sort(); // Sorts cards for the UI display
}
// Force UI refresh
if (typeof window.displayHand === 'function') {
const pIdx = Game.players().indexOf(targetPlayer);
if (pIdx !== -1) window.displayHand(pIdx, null);
}
UI.notify(`Forced ${displayName} to fold!`, 'success');
};
label.addEventListener('mouseenter', highlight);
label.addEventListener('mouseleave', unhighlight);
label.addEventListener('click', handleClick);
});
},
toggle() { if (this.isActive) this.disable(); else this.enable(); },
init() { Logger.info("Loser Picker module initialized."); }
};
Phoenix.register(LoserPicker);
/**
* ==========================================
* GAMEPLAY: WARDROBE MANAGER
* ==========================================
*/
const Wardrobe = {
name: 'Wardrobe',
category: 'Gameplay',
window: null,
isActive: false,
buildUI() {
this.window = UI.createWindow('phoenix-win-wardrobe', 'Wardrobe Manager', { top: '200px', left: '200px' });
this.updateUI();
this.window.hide();
},
updateUI() {
if (!this.window || !this.window.body) return;
// FIX: Reset height to 'auto' so it naturally expands to fit the opponent list,
// overriding any previous manual user resizing that made it too small.
this.window.element.style.height = 'auto';
this.window.body.innerHTML = '';
const opponents = Game.getOpponents();
if (opponents.length === 0) { this.window.body.textContent = 'No opponents found.'; return; }
opponents.forEach((p, idx) => {
const pIdx = Game.players().indexOf(p);
const name = Game.getPlayerName(p, pIdx);
const row = Utils.createElement('div', { style: { marginBottom: '10px', borderBottom: '1px solid #444', paddingBottom: '5px' } });
row.appendChild(Utils.createElement('div', { textContent: name, style: { fontWeight: 'bold', color: 'var(--phoenix-accent)', marginBottom: '5px' } }));
const btnStrip = Utils.createElement('button', { class: 'phoenix-btn', textContent: 'Strip 1 Layer', onclick: () => this.stripLayer(pIdx) });
const btnNaked = Utils.createElement('button', { class: 'phoenix-btn', textContent: 'Force Naked (0 Layers)', onclick: () => this.forceNaked(pIdx) });
const btnForfeit = Utils.createElement('button', { class: 'phoenix-btn primary', textContent: 'Force Forfeit (Out)', onclick: () => this.forceForfeit(pIdx) });
row.appendChild(btnStrip); row.appendChild(btnNaked); row.appendChild(btnForfeit);
this.window.body.appendChild(row);
});
const globalRow = Utils.createElement('div', { style: { marginTop: '15px' } });
globalRow.appendChild(Utils.createElement('button', { class: 'phoenix-btn primary', textContent: 'All NPCs Naked', onclick: () => this.allNaked() }));
globalRow.appendChild(Utils.createElement('button', { class: 'phoenix-btn primary', textContent: 'All NPCs Forfeit', onclick: () => this.allForfeit() }));
this.window.body.appendChild(globalRow);
},
stripLayer(pIdx) {
if (typeof window.stripAIPlayer === 'function') {
Phoenix.safeExecute(() => window.stripAIPlayer(pIdx), 'Wardrobe.stripAIPlayer');
UI.notify('Stripped 1 layer.', 'success');
this.updateUI();
}
},
forceNaked(pIdx) {
const p = Game.players()[pIdx];
if (!p) return;
let safety = 20;
while (typeof p.countLayers === 'function' && p.countLayers() > 0 && safety-- > 0) {
if (typeof window.stripAIPlayer === 'function') window.stripAIPlayer(pIdx);
else break;
}
UI.notify('Forced Naked.', 'success');
this.updateUI();
},
forceForfeit(pIdx) {
const p = Game.players()[pIdx];
if (!p) return;
let safety = 20;
while (typeof p.countLayers === 'function' && p.countLayers() > 0 && safety-- > 0) {
if (typeof window.stripAIPlayer === 'function') window.stripAIPlayer(pIdx);
else break;
}
if (typeof window.startMasturbation === 'function') {
Phoenix.safeExecute(() => window.startMasturbation(pIdx), 'Wardrobe.startMasturbation');
} else if (typeof window.forfeit === 'function') {
Phoenix.safeExecute(() => window.forfeit(pIdx), 'Wardrobe.forfeit');
} else {
UI.notify('Native forfeit function not found.', 'error');
return;
}
UI.notify('Forced Forfeit (Naked + Out).', 'success');
this.updateUI();
},
allNaked() { Game.getOpponents().forEach((p) => { const pIdx = Game.players().indexOf(p); if (pIdx !== -1) this.forceNaked(pIdx); }); },
allForfeit() { Game.getOpponents().forEach((p) => { const pIdx = Game.players().indexOf(p); if (pIdx !== -1) this.forceForfeit(pIdx); }); },
toggle() {
if (!this.window) this.buildUI(); else this.updateUI();
if (this.window.element.style.display === 'none' || this.window.element.style.display === '') { this.window.show(); this.isActive = true; }
else { this.window.hide(); this.isActive = false; }
},
init() { this.buildUI(); Logger.info("Wardrobe module initialized."); }
};
Phoenix.register(Wardrobe);
/**
* ==========================================
* GAMEPLAY: EPILOGUE
* ==========================================
*/
const Epilogue = {
name: 'Epilogue',
category: 'Gameplay',
triggerEpilogue() {
const fn = window.doEpilogueModal || window.game?.doEpilogueModal || window.triggerEpilogue || window.endGame;
if (typeof fn === 'function') {
Phoenix.safeExecute(() => fn.call(window), 'Epilogue.triggerEpilogue');
UI.notify('Triggered Epilogue/End Game Function', 'success');
} else {
UI.notify('Epilogue function not found. Are you at the end of a match?', 'error');
}
},
init() { Logger.info("Epilogue module ready."); }
};
Phoenix.register(Epilogue);
/**
* ==========================================
* UNLOCKS: TEMPORARY
* ==========================================
*/
const Unlocks = {
name: 'Unlocks',
category: 'Unlocks',
patched: false,
// 1. Temporary Unlocks (Runtime Prototype Patch)
// Tricks the Gallery UI by intercepting "is unlocked?" checks.
applyTemporaryPatches() {
if (this.patched) return;
const SaveConstructor = window.Save || window.game?.Save;
if (!SaveConstructor?.prototype) {
Logger.warn("Could not find Save prototype.");
UI.notify("Save engine not found.", "error");
return;
}
const proto = SaveConstructor.prototype;
const trueFns = ['hasEnding', 'hasEpilogue', 'isEndingUnlocked', 'hasCollectible', 'isCollectibleUnlocked', 'hasClothing', 'hasUnlockedClothing', 'isClothingUnlocked'];
const falseFns = ['isClothingLocked', 'isWardrobeLocked', 'isOutfitLocked'];
const highNumFns = ['getCollectibleCounter', 'getCollectibleProgress', 'getCollectibleCount'];
trueFns.forEach(fn => { if (typeof proto[fn] === 'function') proto[fn] = () => true; });
falseFns.forEach(fn => { if (typeof proto[fn] === 'function') proto[fn] = () => false; });
highNumFns.forEach(fn => { if (typeof proto[fn] === 'function') proto[fn] = () => 9999; });
this.patched = true;
Logger.info("Temporary Runtime Patches applied.");
UI.notify("Temporary Unlocks Active (Session Only)", "success");
},
init() {
Logger.info("Unlocks module ready.");
}
};
Phoenix.register(Unlocks);
/**
* ==========================================
* UNLOCKS: PERMANENT
* ==========================================
* SPNATI stores epilogues as raw XML DOM Nodes in memory.
* This module extracts the textContent (the epilogue title)
* and passes it to the native addEnding() function.
*/
const PermanentUnlocks = {
name: 'PermanentUnlocks',
category: 'Unlocks',
inject() {
const saveObj = Game.save();
if (!saveObj) {
UI.notify('Could not find native Save engine.', 'error');
return;
}
UI.notify('Scanning XML nodes & injecting endings...', 'info');
// 1. Deep Memory Scanner
const charDefs = [];
const seen = new Set();
const scanObject = (obj, depth = 0) => {
if (!obj || typeof obj !== 'object' || depth > 5 || seen.has(obj)) return;
seen.add(obj);
const hasId = obj.id || obj.characterId || (obj.getAttribute && obj.getAttribute('id'));
const hasData = obj.epilogues || obj.endings || obj.collectibles || obj.clothing;
if (hasData && hasId) charDefs.push(obj);
for (let key in obj) {
try {
const val = obj[key];
if (val && typeof val === 'object' && !(val instanceof Window)) {
// Allow scanning into NodeLists/HTMLCollections but skip raw text nodes
if (val instanceof Node && !(val instanceof Document) && !(val instanceof Element)) continue;
scanObject(val, depth + 1);
}
} catch(e) {}
}
};
[window, window.game, window.spnati, window.save, window.players, window.opponents, window.loadedOpponents].forEach(root => {
try { scanObject(root, 0); } catch(e) {}
});
let endingsInjected = 0;
let collectiblesInjected = 0;
const processedChars = new Set();
let endingsBlob = {};
try { endingsBlob = JSON.parse(localStorage.getItem('SPNatI.endings') || '{}'); } catch(e) { endingsBlob = {}; }
if (typeof endingsBlob !== 'object' || Array.isArray(endingsBlob)) endingsBlob = {};
charDefs.forEach(charObj => {
if (!charObj) return;
// Extract Character ID (handle XML nodes and JS objects)
let charId = charObj.id || charObj.characterId;
if (!charId && charObj.getAttribute) {
charId = charObj.getAttribute('id') || charObj.getAttribute('characterId');
}
if (!charId || typeof charId !== 'string' || processedChars.has(charId)) return;
processedChars.add(charId);
// Collectibles
const cols = charObj.collectibles || charObj.collectible || [];
let colList = [];
if (Array.isArray(cols)) colList = cols;
else if (cols instanceof NodeList || cols instanceof HTMLCollection) colList = Array.from(cols);
else if (typeof cols === 'object') colList = Object.values(cols);
colList.forEach(col => {
if (!col) return;
let colId = col.id || col.name;
if (!colId && col.getAttribute) colId = col.getAttribute('id') || col.getAttribute('name');
if (!colId && typeof col === 'string') colId = col;
if (colId && typeof colId === 'string') {
try {
saveObj.setItem('collectibles.' + charId + '.' + colId, 999);
collectiblesInjected++;
} catch(e) {}
}
});
// Epilogues
const epis = charObj.epilogues || charObj.epilogue || charObj.endings || [];
let epiList = [];
if (Array.isArray(epis)) epiList = epis;
else if (epis instanceof NodeList || epis instanceof HTMLCollection) epiList = Array.from(epis);
else if (typeof epis === 'object') epiList = Object.values(epis);
epiList.forEach(epi => {
if (!epi) return;
const epiIdentifiers = [];
// Handle XML DOM Nodes (The actual format SPNATI uses)
if (epi instanceof Element || epi instanceof Node) {
if (epi.textContent) epiIdentifiers.push(epi.textContent.trim());
if (epi.innerText && epi.innerText !== epi.textContent) epiIdentifiers.push(epi.innerText.trim());
if (epi.getAttribute) {
const idAttr = epi.getAttribute('id');
const titleAttr = epi.getAttribute('title');
const nameAttr = epi.getAttribute('name');
if (idAttr) epiIdentifiers.push(idAttr);
if (titleAttr) epiIdentifiers.push(titleAttr);
if (nameAttr) epiIdentifiers.push(nameAttr);
}
}
// Handle standard JS objects
else if (typeof epi === 'object') {
if (epi.id) epiIdentifiers.push(epi.id);
if (epi.title) epiIdentifiers.push(epi.title);
if (epi.name) epiIdentifiers.push(epi.name);
if (epi.key) epiIdentifiers.push(epi.key);
}
// Handle raw strings
else if (typeof epi === 'string') {
epiIdentifiers.push(epi);
}
// Filter out empty strings and duplicates
const uniqueIds = [...new Set(epiIdentifiers.filter(id => id && typeof id === 'string' && id.length > 0))];
uniqueIds.forEach(epiId => {
// Call native addEnding
try {
if (typeof saveObj.addEnding === 'function') {
saveObj.addEnding(charId, epiId);
endingsInjected++;
}
} catch(e) {}
// Fallback direct localStorage injection
if (!endingsBlob[charId]) endingsBlob[charId] = [];
if (!endingsBlob[charId].includes(epiId)) {
endingsBlob[charId].push(epiId);
}
});
});
});
// Write the JSON blob back to localStorage
try { localStorage.setItem('SPNatI.endings', JSON.stringify(endingsBlob)); } catch(e) {}
if (endingsInjected > 0 || collectiblesInjected > 0) {
UI.notify(`Injected ${endingsInjected} endings & ${collectiblesInjected} collectibles!`, 'success', 6000);
} else {
UI.notify(`No data found. Open Gallery first!`, 'info');
}
Logger.info(`Permanent Unlocks: Injected ${endingsInjected} endings and ${collectiblesInjected} collectibles.`);
},
init() { Logger.info("Permanent Unlocks module ready."); }
};
Phoenix.register(PermanentUnlocks);
/**
* ==========================================
* BOOTSTRAP EXECUTION
* ==========================================
*/
Phoenix.bootstrap().catch(err => {
console.error("[Phoenix] Bootstrap failed:", err);
});
})();