diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..25e62fb --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,55 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [master, mobile] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout master + uses: actions/checkout@v4 + with: + ref: master + path: master + + - name: Checkout mobile + uses: actions/checkout@v4 + with: + ref: mobile + path: mobile + + - name: Build site structure + run: | + mkdir -p _site/mobile + # Copy master as root + cp master/index.html master/style.css master/game.js _site/ 2>/dev/null || true + cp -r master/assets _site/assets 2>/dev/null || true + # Copy mobile under /mobile/ + cp mobile/index.html mobile/style.css mobile/game.js _site/mobile/ 2>/dev/null || true + cp -r mobile/assets _site/mobile/assets 2>/dev/null || true + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/MOBILE-PHASE2.md b/MOBILE-PHASE2.md new file mode 100644 index 0000000..88c73bf --- /dev/null +++ b/MOBILE-PHASE2.md @@ -0,0 +1,51 @@ +# Quarter Close — Mobile Phase 2: Improvements & Polish + +*Post-launch refinements to the mobile port. Phase 1 delivered full playability; Phase 2 focuses on polish, information density, and UX improvements.* + +## Baseline Rules (Same as Phase 1) + +- **ONLY work on the `mobile` branch. NEVER touch master.** +- **NEVER checkout, merge from, or modify the master branch.** +- Commit and push frequently — live preview auto-deploys at https://kpa-clawbot.github.io/quarter-close/mobile/ +- Increment `?v=` cache busters on game.js and style.css in index.html with each change +- **Playability > aesthetics** — never break core mechanics for visual improvements +- **All 15 core mechanics must keep working** after every change (test on mobile viewport) +- Single page app: `index.html`, `style.css`, `game.js` — no frameworks +- Mobile detection: `isMobile()` using `matchMedia('(max-width: 600px)')` +- Mobile CSS in `@media (max-width: 600px)` blocks +- Read `MOBILE.md` for full architecture documentation + +## Technical Reference + +- **Ops tab cash header**: `#row-cash` (the desktop stats row, restyled for mobile with CSS) +- **Non-ops tab mini header**: `#mobile-cash-header` (JS-managed, shown on P&L/Board/Settings tabs) +- **Cash header update (ops)**: `updateGridValues()` in game.js updates `#cash-display`, `#per-tick-display`, `#stock-price-cell` +- **Cash header update (non-ops)**: `updateMobileCashHeader()` updates `#mob-cash-amount` and `#mob-cash-perday` +- **Desktop Row 1 info**: Cash, $/day, Total Rev/yr, Stock Price (post-IPO) + +## Known Issues + +### ✅ Fixed — Cash Header Missing Stock Price & Total Rev/yr +- The non-ops `#mobile-cash-header` only showed cash and $/day +- Stock price (post-IPO) and Total Rev/yr were missing +- **Fix**: Added stock price and total rev/yr to both the ops header and non-ops mini header + +### ✅ Fixed — Cash Header White Flash on Tap +- Tapping the `#row-cash` header caused a white flash +- Missing `-webkit-tap-highlight-color: transparent` on the header +- **Fix**: Added tap highlight suppression to `#row-cash` and `#mobile-cash-header` + +### ✅ Fixed — Cash Header Information Density +- Desktop shows: Cash, $/day, Total Rev/yr, Stock Price +- Mobile ops header was missing Total Rev/yr +- **Fix**: Restructured mobile cash header to show all key metrics in a compact layout + +## Phase 2 Improvements + +*Placeholder — add items here as they're identified.* + +- [ ] *(Add future improvement items here)* + +--- + +*Created: 2026-02-14* diff --git a/MOBILE.md b/MOBILE.md new file mode 100644 index 0000000..b290d29 --- /dev/null +++ b/MOBILE.md @@ -0,0 +1,166 @@ +# Quarter Close — Mobile Support + +## Status: ✅ Phase 1 Complete + +The mobile version is available at `/mobile/` and includes all features from master. + +### Phase 1 Summary (Completed 2026-02-14) + +Phase 1 delivered a fully playable mobile port of Quarter Close with: + +- **Card-based mobile layout** replacing the desktop spreadsheet grid — each department rendered as a rounded card with action buttons +- **Bottom navigation bar** with four tabs: Ops, P&L, Board, More — with Board tab auto-hidden pre-IPO +- **All 15 core mechanics working on mobile** — verified and tested (see checklist below) +- **Touch-friendly UI** with minimum 44px tap targets across all interactive elements +- **Haptic feedback** on all major actions (hire, upgrade, automate, collect, unlock, tab switch, etc.) +- **Animations and active states** — button press feedback, cash pulse, card unlock animations, toast slide-ups, mini-task pop-in +- **Nav badges and collect highlights** — P&L tab alert badge for tax debts, green highlight on collect buttons with pending revenue +- **Multiple polish passes** — disabled button styling, hidden scrollbars, modal backdrop blur, overtime card styling, toast vertical stacking, safe area insets for notched phones + +## Architecture + +Mobile support is built as a **CSS + JS layer on top of master's codebase**: + +- **Same `game.js`** — mobile detection via `isMobile()` (`max-width: 600px` media query) +- **Same `index.html`** — extra mobile-only elements added (cash header, views, bottom nav) +- **Same `style.css`** — mobile overrides in `@media (max-width: 600px)` blocks + +This ensures mobile stays in sync with master's features automatically. + +## Mobile Layout + +### Operations Tab (Main View) +- **Green cash header** — sticky at top, shows cash balance, rev/day, stock price (post-IPO) +- **Card-based departments** — each department is a rounded card with: + - Row 1: Name, level badge, employee count, rev/day + - Row 2: Max(N) + Hire button (full width) + - Row 3: Max(N) + Upgrade button + Collect button (side by side) + - Row 4: Automate button OR compact "⚡ AUTO" badge (when automated) +- **Restructure buttons** — gold-styled RE buttons appear next to Upgrade after IPO (full-width on automated depts) +- **Automated department indicator** — teal left border on automated cards (with transition) +- **Overtime row** — dashed border card at bottom, orange styling +- **Locked departments** — greyed out with Unlock button; non-unlockable ones hidden +- **Mini-task bar** — yellow gradient bar with pop-in animation, styled approve/skip buttons + +### P&L Tab +- Profit & Loss card (quarterly + lifetime financials) +- Investor Relations card (stock price, guidance, earnings tracking) +- Tax debt cards with settle buttons +- Mini cash header at top (shows cash on non-ops tabs) +- **Alert badge on tab** when tax debts exist + +### Board Room Tab +- RE balance header +- Upgrade cards with name, description, cost, Buy/Locked status +- Only visible after IPO + +### Settings Tab (More) +- Save Game, Auto-save toggle, Boss Mode, Game Options, Help, New Game +- Game stats display (date, rev/sec, rev/day, rev/year, total earned, clicks, play time, stock, RE) +- **Active effects display** (power outage, revenue penalty/bonus, DB outage, hire freeze, IRS garnishment with countdown timers) + +### Bottom Navigation +- Ops | P&L | Board | More +- Board tab hidden pre-IPO +- Active tab highlighted in green with top indicator line +- Subtle press/active state for touch feedback +- **Alert badge** on P&L tab when tax debts exist (animated pop) + +## Mobile-Specific Behavior + +### Touch & Interaction +- **Haptic feedback** — vibration on all major actions (hire, upgrade, automate, collect, unlock, restructure, deal clicks, mini-tasks, overtime, golden cell, arc selection, tab switch, event choices) +- **Button active states** — scale + color change feedback on all button types (hire=blue, upgrade=green, collect=green, automate=teal, unlock=orange, prestige=gold) +- **Cash header pulse** — green pulse animation on revenue collection +- **Card unlock animation** — scale + opacity + color transition when unlocking departments +- **Toast slide-up animation** — toasts slide up from bottom instead of appearing +- **Mini-task pop-in** — animated appearance for mini-task bar +- **Double-tap prevention** — 400ms debounce on automate buttons +- **Scroll-to-top** — smooth scroll on tab switch +- **Scroll to unlocked card** — auto-scrolls to newly unlocked department + +### Visual Polish +- **Collect button highlight** — green background when pending revenue available +- **Disabled button styling** — reduced opacity for unaffordable actions +- **Hidden scrollbars** — cleaner look on all scrollable views +- **Modal backdrop blur** — subtle blur behind modals +- **Overtime card** — warm orange styling with active state +- **Toast buttons stacked vertically** — full-width for easy tapping on earnings guidance (4 options) +- **Status bar** — wider text area, subtle background, shows game date and timer + +### Button Labels +- **Clear button labels**: "Hire $5K", "Upgrade $30K", "💰 Collect $41K", "Automate $500" +- **Upgrade always visible** — no longer hidden when not automated +- **Collect shows pending amount** — "💰 Collect $X" or just "💰 Collect" when nothing pending +- **Auto-badge compact** — small "⚡ AUTO" text, doesn't waste vertical space + +### Layout & Positioning +- **CSS flex ordering** — status bar (order 90) and nav (order 100) always at bottom, cash header (order -2) at top on non-ops tabs +- **Toast/notification positioning** — fixed above bottom nav + status bar, no overlap with nav +- **Deal popup** — fixed above bottom nav +- **Golden cell** — applies to whole card row +- **Splash screen** — skipped on mobile +- **Touch targets** — minimum 44px for all interactive elements +- **Safe area insets** — supports notched phones + +### Arc Selection +- **Flex layout** — icon on left, text content on right +- **Active press state** — scale + color feedback on tap +- **Full-height** — uses 100dvh for proper mobile viewport +- **Gradient background** — subtle green gradient + +### Data Updates +- **P&L hash** includes tax debt details (current amount + stage) for live updates +- **P&L hash** includes cash for real-time settle button enable/disable +- **Board Room hash** includes cash for buy button enable/disable + +## All 15 Game Mechanics Tested on Mobile + +1. ✅ Start new game — arc selection flows smoothly +2. ✅ Collect revenue — 💰 Collect buttons with clear amounts + haptic + cash pulse +3. ✅ Hire employees — Hire + Max(N) buttons + haptic +4. ✅ Upgrade departments — always visible (not hidden behind Auto) + haptic +5. ✅ Automate departments — Automate button → ⚡ AUTO badge + teal border + haptic +6. ✅ Unlock new departments — Unlock buttons, locked ones properly greyed + unlock animation +7. ✅ Mini-tasks — yellow bar with Approve/Sign buttons + haptic +8. ✅ Events/notifications — toasts with action buttons, slide-up animation, vertical stacking +9. ✅ IPO / Earnings — earnings modal with guidance selection (4 vertical buttons) +10. ✅ IRS taxes — Pay/Ignore buttons + P&L tab badge +11. ✅ Board Room — browse RE upgrades, Buy buttons, requirement locks +12. ✅ Settings — save/load, new game confirm, boss mode, help, game options, active effects +13. ✅ Overtime (Push It) — overtime card with orange styling + haptic + cash pulse +14. ✅ Close the Deal — deal popup with Sign button + haptic per click + success celebration +15. ✅ Management Focus — department name tap for focus boost + +## Polish Pass 3 (Latest) + +### Fixes Applied +1. **Stock price visible in Ops cash header** — `#stock-price-cell` was hidden by `.cell-h { display: none }` rule. Added `display: flex !important` override with higher specificity (`#row-cash #stock-price-cell`). Shows "📈 $X.XX" via CSS `::before` pseudo-element, hiding the "Stock:" label for space efficiency. +2. **Penalty/bonus indicators improved** — Colors lightened for dark green header background (#ff8a80 red, #ffab40 orange, #a5d6a7 green). Font size bumped from 9px to 11px for readability. +3. **Non-ops tabs cash header shows effects** — `updateMobileCashHeader()` now renders penalty/bonus/outage indicators with mobile-appropriate colors, matching the Ops tab behavior. +4. **Prestige button disabled state** — Changed from flat grey (#f5f5f0) to muted gold gradient, maintaining visual identity even when unaffordable. + +### Verified Working +- All 15 core mechanics tested end-to-end +- Penalty/bonus/outage indicators on both Ops and non-ops headers +- Boss mode (Excel disguise) renders correctly on mobile viewport +- Event toasts with action buttons properly positioned above nav +- Deal popup positioned and functional +- Mini-task bar with pop-in animation +- Board Room scrolling through all categories +- Earnings guidance selection (4 vertical buttons) +- Swipe gesture between tabs +- No console errors or warnings + +## Files Modified + +All changes are in the standard three files: +- `index.html` — mobile viewport meta, mobile-only elements, bottom nav +- `style.css` — `@media (max-width: 600px)` blocks (~800 lines) +- `game.js` — `isMobile()`, `mobileSwitchTab()`, mobile P&L/Board Room/Settings builders, `mobileTickUpdate()`, haptic feedback, animations, window exposures + +## Deployment + +GitHub Pages workflow deploys both branches: +- `/` — master (desktop) +- `/mobile/` — mobile branch diff --git a/game.js b/game.js index 5391425..6740ac3 100644 --- a/game.js +++ b/game.js @@ -50,7 +50,7 @@ const ARCS = { { name: 'Garage Sale', flavor: 'One man\'s trash...' }, { name: 'eBay Reselling', flavor: 'Buy low, sell high' }, { name: 'Dropshipping Store', flavor: 'Never touch inventory' }, - { name: 'Amazon FBA', flavor: 'Let Bezos handle logistics' }, + { name: 'Drop Shipping', flavor: 'Let the warehouse handle logistics' }, { name: 'Warehouse & Distro', flavor: 'Cutting out the middleman' }, { name: 'Private Label Brand', flavor: 'Your name on the box' }, { name: 'Retail Chain', flavor: 'Brick and mortar comeback' }, @@ -149,8 +149,13 @@ function miniTaskReward(task) { } // ===== EVENTS DEFINITIONS ===== +// Global event frequency multiplier — scales all event weights together +// 1.0 = default, 2.0 = twice as frequent, 0.5 = half as frequent, 0 = off +let EVENT_FREQ_MULT = 1.0; + const EVENTS = [ { + weight: 3, sender: 'Mom', subject: 'Quick investment opportunity', body: 'Honey, I believe in your little business! Here\'s a little something to help out.', @@ -164,6 +169,7 @@ const EVENTS = [ ] }, { + weight: 4, sender: 'Angry Customer', subject: 'RE: TERRIBLE SERVICE!!!', body: 'I want a FULL REFUND or I\'m leaving a 1-star review everywhere!', @@ -180,19 +186,32 @@ const EVENTS = [ ] }, { + weight: 1, sender: 'College Buddy', - subject: 'Hey can I get a discount??', - body: 'Bro remember me from college?? Hook me up with a discount! For old times\' sake 🤙', + subject: 'Business proposal over beers? 🍺', + body: 'Dude, I\'ve got this idea that could be huge. Let me pitch you — worst case we grab drinks and catch up.', actions: [ - { label: 'Give discount (-1% cash)', effect: (gs) => { - const cost = Math.max(5, Math.floor(gs.cash * 0.01)); + { label: '🍺 Take the meeting (5% cash)', effect: (gs) => { + const cost = Math.max(50, Math.floor(gs.cash * 0.05)); gs.cash -= cost; - return `Gave ${formatMoney(cost)} discount. Your friend is happy!`; + // 40% chance it's actually good + if (Math.random() < 0.4) { + const rev = totalRevPerTick(); + const bonus = rev * (60 + Math.floor(Math.random() * 120)); // 1-3 min of revenue + gs.cash += bonus; + gs.totalEarned += bonus; + gs.quarterRevenue += bonus; + if (gs.isPublic) gs.earningsQuarterRevenue += bonus; + return `Spent ${formatMoney(cost)} on dinner... but the idea was legit! Closed a ${formatMoney(bonus)} side deal.`; + } else { + return `Spent ${formatMoney(cost)} on drinks. The pitch was an MLM. Classic.`; + } }}, - { label: 'Full price (no effect)', effect: () => 'They understand. Business is business.' }, + { label: '📱 "Super busy rn"', effect: () => 'Left on read. He\'ll try again in 6 months.' }, ] }, { + weight: 2, sender: 'IT Department', subject: '⚠️ POWER OUTAGE - Building 3', body: 'Emergency maintenance required. All systems will be offline for approximately 15 seconds. This cannot be prevented.', @@ -205,6 +224,142 @@ const EVENTS = [ actions: [] }, { + weight: 1, + sender: 'IT Security', + subject: '🔒 RANSOMWARE DETECTED - All Systems', + body: 'Ransomware detected on the network. All file shares encrypted. Pay the ransom or wait for IT to rebuild from backups.', + actions: [ + { label: '💰 Pay ransom (15% cash)', effect: (gs) => { + const cost = Math.max(100, Math.floor(gs.cash * 0.15)); + gs.cash -= cost; + return `Paid ${formatMoney(cost)} ransom. Systems restored. IT is "looking into it."`; + }}, + { label: '🛡️ Refuse — rebuild from backups', effect: (gs) => { + const duration = 30000 + Math.floor(Math.random() * 30000); // 30-60s + gs.powerOutage = { until: Date.now() + duration }; + return `Revenue frozen for ${Math.round(duration/1000)}s while IT rebuilds. Should\'ve had better backups.`; + }}, + ] + }, + { + weight: 1, + sender: 'Network Operations', + subject: '🌐 DDoS ATTACK IN PROGRESS', + body: 'Massive distributed denial-of-service attack hitting all public-facing services. Mitigation in progress but performance is degraded.', + actions: [ + { label: 'Nothing we can do', effect: (gs) => { + const duration = 20000 + Math.floor(Math.random() * 10000); // 20-30s + gs.revPenalty = { mult: 0.5, until: Date.now() + duration }; + return `⚠️ DDoS — revenue at 50% for ${Math.round(duration/1000)}s while CloudFlare mitigates.`; + }}, + ] + }, + { + weight: 1, + sender: 'DBA Team', + subject: '💾 CRITICAL: Database corruption detected', + body: 'Primary database showing consistency errors. We can do an emergency restore ($$$) or let auto-recovery run (slower).', + actions: [ + { label: '💰 Emergency restore (3% cash)', effect: (gs) => { + const cost = Math.max(50, Math.floor(gs.cash * 0.03)); + gs.cash -= cost; + return `Paid ${formatMoney(cost)} for emergency DB restore. Crisis averted.`; + }}, + { label: '⏳ Wait for auto-recovery', effect: (gs) => { + const duration = 15000 + Math.floor(Math.random() * 5000); // 15-20s + const unlocked = gs.sources.map((s, i) => ({ s, i })).filter(x => x.s.unlocked && x.s.employees > 0); + if (unlocked.length > 0) { + const pick = unlocked[Math.floor(Math.random() * unlocked.length)]; + gs.dbOutage = { sourceIndex: pick.i, until: Date.now() + duration }; + const arc = ARCS[gs.arc]; + const name = arc.sources[pick.i].name || getSourceDef(pick.i).name; + return `${name} offline for ${Math.round(duration/1000)}s while database recovers.`; + } + console.log('DB outage: no unlocked sources'); + return 'Auto-recovery running... minimal impact.'; + }}, + ] + }, + { + weight: 3, + sender: 'IT Department', + subject: '📧 Email server is DOWN', + body: 'Mail server is unreachable. No one can send or receive email until it\'s fixed. Approval workflows are frozen.', + actions: [ + { label: 'Wait it out', effect: (gs) => { + const duration = 45000 + Math.floor(Math.random() * 15000); // 45-60s + gs.miniTaskBlocked = { until: Date.now() + duration }; + return `📧 Email down — no approvals for ${Math.round(duration/1000)}s. Your streak is in danger.`; + }}, + ] + }, + { + weight: 4, + sender: 'IT Security', + subject: '🔑 MANDATORY PASSWORD RESET', + body: 'Security audit requires all employees to reset passwords immediately. Productivity will be impacted.', + timed: true, + timedDelay: 3000, + timedEffect: (gs) => { + gs.powerOutage = { until: Date.now() + 10000 }; + return '🔑 Company-wide password reset. Revenue paused for 10 seconds.'; + }, + actions: [] + }, + { + weight: 2, + sender: 'Status Page', + subject: '☁️ CLOUD PROVIDER OUTAGE', + body: 'Major incident at your cloud provider. Multiple availability zones affected. ETA for resolution: "We\'re working on it." Helpful.', + actions: [ + { label: 'Welcome to the cloud', effect: (gs) => { + const duration = 15000 + Math.floor(Math.random() * 10000); // 15-25s + gs.revPenalty = { mult: 0.25, until: Date.now() + duration }; + return `☁️ Cloud outage — revenue at 25% for ${Math.round(duration/1000)}s. Nothing you can do.`; + }}, + ] + }, + { + weight: 3, + sender: 'Engineering Lead', + subject: '🐛 P0 BUG: Production is on fire', + body: 'Critical bug in prod. Customers are seeing errors. We can hotfix now (costs money for the war room) or punt to next sprint and eat the churn.', + actions: [ + { label: '🚨 Hotfix now (5% cash)', effect: (gs) => { + const cost = Math.max(100, Math.floor(gs.cash * 0.05)); + gs.cash -= cost; + return `Spent ${formatMoney(cost)} on an emergency war room. Bug squashed. Engineers need therapy.`; + }}, + { label: '📋 Next sprint', effect: (gs) => { + // Random department loses 50% revenue for 60s + const unlocked = gs.sources.map((s, i) => ({ s, i })).filter(x => x.s.unlocked && x.s.employees > 0); + if (unlocked.length > 0) { + const pick = unlocked[Math.floor(Math.random() * unlocked.length)]; + const duration = 60000; + gs.revPenalty = { mult: 0.5, until: Date.now() + duration }; + const arc = ARCS[gs.arc]; + const name = arc.sources[pick.i].name || getSourceDef(pick.i).name; + return `Customers churning — revenue at 50% for 60s. "${name}" taking the biggest hit.`; + } + return 'Customers aren\'t happy but they\'ll survive... probably.'; + }}, + ] + }, + { + weight: 2, + sender: 'IT Asset Management', + subject: '💻 LAPTOP RECALL - Security Vulnerability', + body: 'Critical firmware vulnerability discovered. All company laptops must be collected for patching. Employees will work from their phones (poorly).', + actions: [ + { label: 'Comply with recall', effect: (gs) => { + const duration = 20000; + gs.revPenalty = { mult: 0.7, until: Date.now() + duration }; + return '💻 Laptop recall — revenue at 70% for 20s while everyone works from phones.'; + }}, + ] + }, + { + weight: 2, sender: 'Google Alerts', subject: '📈 Your company is trending on TikTok!', body: 'A customer posted a viral video about your product. 2.3M views and counting! Revenue is spiking.', @@ -216,6 +371,7 @@ const EVENTS = [ ] }, { + weight: 2, sender: 'PR Team', subject: 'Forbes wants to feature us! 🎉', body: 'Forbes is running a "30 Under 30" style piece and wants to include us. This will be huge for brand awareness.', @@ -228,6 +384,7 @@ const EVENTS = [ ] }, { + weight: 2, sender: 'Social Media', subject: '🚀 We hit the front page of Reddit!', body: 'Someone posted about us on r/technology and it exploded. Server traffic is through the roof!', @@ -239,6 +396,7 @@ const EVENTS = [ ] }, { + weight: 2, sender: 'Marketing', subject: '📺 Local news wants to do a segment on us', body: 'Channel 7 heard about us and wants to do a feel-good local business story. Free advertising!', @@ -251,6 +409,7 @@ const EVENTS = [ ] }, { + weight: 3, sender: 'Sales Team', subject: '🎉 Big client just signed!', body: null, // dynamic — set at trigger time @@ -261,7 +420,7 @@ const EVENTS = [ const pick = unlocked[Math.floor(Math.random() * unlocked.length)]; const src = getSourceDef(pick.i); const arc = ARCS[gs.arc]; - const name = arc.names[pick.i] || src.name; + const name = arc.sources[pick.i].name || src.name; const mult = 5 + Math.floor(Math.random() * 6); // 5-10× const bonus = Math.floor(sourceRevPerTick(pick.s) * mult); if (bonus <= 0) return null; @@ -279,6 +438,35 @@ const EVENTS = [ }; }, }, + // R&D Breakthrough — permanent 2× revenue for a random source + { + weight: 2, + debugLabel: 'R&D Breakthrough', + generate: () => { + const arc = ARCS[gameState.arc]; + const unlocked = gameState.sources.map((s, i) => ({ s, i })).filter(x => x.s.unlocked && x.s.employees > 0); + if (unlocked.length === 0) return null; + const pick = unlocked[Math.floor(Math.random() * unlocked.length)]; + const name = arc.sources[pick.i].name; + const currentMult = pick.s.breakthroughMult || 1; + return { + sender: 'R&D Department', + subject: '🔬 Breakthrough Innovation!', + body: `Your ${name} team has made a major breakthrough! They've developed a revolutionary new process that will permanently double their revenue output.\n\nCurrent multiplier: ×${currentMult}\nNew multiplier: ×${currentMult * 2}`, + actions: [ + { label: `Implement (+×2 ${name})`, effect: (gs) => { + gs.sources[pick.i].breakthroughMult = (gs.sources[pick.i].breakthroughMult || 1) * 2; + return `🔬 Breakthrough! ${name} revenue permanently doubled (now ×${gs.sources[pick.i].breakthroughMult})`; + }}, + { label: 'File patent (5% cash)', effect: (gs) => { + const patent = Math.max(10, Math.floor(gs.cash * 0.05)); + gs.cash += patent; + return `Filed patent for ${formatMoney(patent)} instead. Your lawyers are happy.`; + }}, + ] + }; + }, + }, ]; // ===== BOARD ROOM UPGRADES (Phase 2.2) ===== @@ -286,7 +474,7 @@ const BOARD_ROOM_UPGRADES = [ { id: 'finance_dept_1', name: 'Finance Dept Lv1', - desc: 'The Intern — auto-earnings, but randomizes guidance (often wrong).', + desc: 'The Intern — auto-earnings, randomizes guidance (often wrong).', cost: 500, requires: null, maxCount: 1, @@ -295,7 +483,7 @@ const BOARD_ROOM_UPGRADES = [ { id: 'finance_dept_2', name: 'Finance Dept Lv2', - desc: 'Competent CFO — analyzes trends, picks right guidance ~70% of the time.', + desc: 'Competent CFO — analyzes trends, picks right ~70% of the time.', cost: 2500, requires: 'finance_dept_1', maxCount: 1, @@ -364,6 +552,15 @@ const BOARD_ROOM_UPGRADES = [ maxCount: 1, category: 'Investor', }, + { + id: 'cpa', + name: 'CPA on Retainer', + desc: 'Auto-pays taxes when affordable, auto-settles debts. No more IRS toasts.', + cost: 750, + requires: null, + maxCount: 1, + category: 'Tax', + }, { id: 'golden_parachute', name: 'Golden Parachute', @@ -373,6 +570,16 @@ const BOARD_ROOM_UPGRADES = [ maxCount: Infinity, category: 'Protection', }, + { + id: 'growth_initiative', + name: 'Growth Initiative', + desc: '+2% revenue multiplier (stacks). Cost scales 10% each.', + cost: 50, + requires: null, + maxCount: Infinity, + category: 'Revenue', + scalingCost: 1.10, // each purchase costs 10% more + }, ]; function hasBoardRoomUpgrade(id) { @@ -383,11 +590,22 @@ function getBoardRoomUpgradeCount(id) { return gameState.boardRoomPurchases[id] || 0; } +function getUpgradeCost(upgrade) { + if (upgrade.scalingCost) { + const owned = getBoardRoomUpgradeCount(upgrade.id); + return Math.floor(upgrade.cost * Math.pow(upgrade.scalingCost, owned)); + } + return upgrade.cost; +} + function getBoardRoomRevMultiplier() { let mult = 1.0; if (hasBoardRoomUpgrade('rev_mult_1')) mult *= 1.1; if (hasBoardRoomUpgrade('rev_mult_2')) mult *= 1.25; if (hasBoardRoomUpgrade('rev_mult_3')) mult *= 1.5; + // Growth Initiative: +2% per purchase, stacks multiplicatively + const gi = getBoardRoomUpgradeCount('growth_initiative'); + if (gi > 0) mult *= Math.pow(1.02, gi); return mult; } @@ -421,39 +639,125 @@ function pickCFOGuidance(level) { return 'ambitious'; } - // Lv2 and Lv3: project revenue vs each guidance target - const projectedRev = totalRevPerTick() * EARNINGS_QUARTER_DAYS; - const safetyMargin = level >= 3 ? 1.05 : 1.20; - - // Find the most aggressive guidance we can safely beat - let bestPick = 'conservative'; // fallback - for (const key of keys) { - const gl = GUIDANCE_LEVELS[key]; - const target = projectedRev * gl.pct * gameState.analystBaseline; - if (projectedRev > target * safetyMargin) { - bestPick = key; + // Lv2/Lv3: context-aware weighted pick + // Base weights for each guidance level + const weights = { conservative: 0, 'in-line': 0, ambitious: 0, aggressive: 0 }; + const streak = gameState.earningsStreak; + const baseline = gameState.analystBaseline; + + if (level === 2) { + // Competent CFO — reads trends but not perfectly + if (streak <= -2) { + // After multiple misses: play it safe + weights.conservative = 70; + weights['in-line'] = 25; + weights.ambitious = 5; + } else if (streak < 0) { + // After one miss: cautious + weights.conservative = 40; + weights['in-line'] = 45; + weights.ambitious = 15; + } else if (streak === 0) { + // Fresh start or reset: balanced + weights.conservative = 20; + weights['in-line'] = 50; + weights.ambitious = 25; + weights.aggressive = 5; + } else if (streak <= 3) { + // Short beat streak: getting confident + weights.conservative = 10; + weights['in-line'] = 35; + weights.ambitious = 40; + weights.aggressive = 15; + } else { + // Long beat streak: bold but not reckless + weights.conservative = 5; + weights['in-line'] = 20; + weights.ambitious = 45; + weights.aggressive = 30; } - } - if (level >= 3) { - // Elite CFO adjustments - const keyIdx = keys.indexOf(bestPick); + // Analyst pressure adjustment — high baseline means analysts expect more + if (baseline > 1.15) { + weights.conservative += 20; + weights['in-line'] += 10; + weights.ambitious -= 15; + weights.aggressive -= 15; + } + } else { + // Lv3: Elite CFO — smarter weights, factors in more context + if (streak <= -2) { + weights.conservative = 50; + weights['in-line'] = 40; + weights.ambitious = 10; + } else if (streak < 0) { + weights.conservative = 25; + weights['in-line'] = 50; + weights.ambitious = 20; + weights.aggressive = 5; + } else if (streak === 0) { + weights.conservative = 10; + weights['in-line'] = 40; + weights.ambitious = 35; + weights.aggressive = 15; + } else if (streak <= 3) { + weights.conservative = 5; + weights['in-line'] = 20; + weights.ambitious = 45; + weights.aggressive = 30; + } else if (streak <= 6) { + // Strong streak but analysts ratcheting — pull back slightly + weights.conservative = 5; + weights['in-line'] = 25; + weights.ambitious = 40; + weights.aggressive = 30; + } else { + // Very long streak — analysts are aggressive, play safer + weights.conservative = 10; + weights['in-line'] = 35; + weights.ambitious = 35; + weights.aggressive = 20; + } - // Long streak (>5): one notch safer — analysts are ratcheting hard - if (gameState.earningsStreak > 5 && keyIdx > 0) { - bestPick = keys[keyIdx - 1]; + // High analyst baseline: elite CFO recognizes danger + if (baseline > 1.15) { + weights.conservative += 15; + weights['in-line'] += 10; + weights.ambitious -= 10; + weights.aggressive -= 15; + } else if (baseline < 0.85) { + // Low expectations: be bolder + weights.ambitious += 10; + weights.aggressive += 10; + weights.conservative -= 10; + weights['in-line'] -= 10; } - // Active revenue bonus: one notch more aggressive - else if (gameState.revBonus && Date.now() < gameState.revBonus.until && keyIdx < keys.length - 1) { - bestPick = keys[keyIdx + 1]; + + // Active revenue penalty: notch safer + if (gameState.revPenalty && Date.now() < gameState.revPenalty.until) { + weights.conservative += 20; + weights.ambitious -= 10; + weights.aggressive -= 10; } - // Active revenue penalty: one notch safer - else if (gameState.revPenalty && Date.now() < gameState.revPenalty.until && keyIdx > 0) { - bestPick = keys[keyIdx - 1]; + // Active revenue bonus: notch bolder + if (gameState.revBonus && Date.now() < gameState.revBonus.until) { + weights.ambitious += 10; + weights.aggressive += 10; + weights.conservative -= 10; } } - return bestPick; + // Ensure no negative weights + for (const k of keys) weights[k] = Math.max(0, weights[k]); + + // Weighted random pick + const total = keys.reduce((sum, k) => sum + weights[k], 0); + let roll = Math.random() * total; + for (const key of keys) { + roll -= weights[key]; + if (roll <= 0) return key; + } + return 'in-line'; // fallback } function setActiveCFOLevel(level) { @@ -474,6 +778,7 @@ let gameState = { revPenalty: null, revBonus: null, powerOutage: null, + dbOutage: null, hireFrozen: null, taxDebts: [], seriesAShown: false, @@ -484,6 +789,7 @@ let gameState = { totalPlayTime: 0, miniTaskCooldown: 0, miniTaskActive: false, + miniTaskBlocked: null, miniTaskStreak: 0, goldenCellActive: false, goldenCellCooldown: 60, // don't spawn for first 60s @@ -519,6 +825,7 @@ let gameState = { activeCFOLevel: 0, // 0 = manual, 1/2/3 = Finance Dept level in use cfoRecords: {}, // { 1: {beats:0,total:0}, 2: {...}, 3: {...} } revenueHistory: [], // last 3 quarterly revenues for trend analysis + lastQuarterRE: 0, // RE earned last quarter (for ETA display) }; let gridBuilt = false; @@ -554,6 +861,34 @@ function automateCost(source) { return Math.floor(Math.max(50, stats.unlockCost) * stats.autoCostMult); } +// Prestige: restructure a department for 10× revenue, costs RE +function prestigeCost(source) { + const tier = source.id; + const level = source.prestigeLevel || 0; + // Mild tier scaling: lemonade stand is cheap, megacorp is expensive + // 50 × (1 + tier) × 3^level + // Tier 0: 50 → 150 → 450 | Tier 11: 600 → 1800 → 5400 + return Math.floor(50 * (1 + tier) * Math.pow(3, level)); +} + +function restructureSource(index) { + if (!gameState.isPublic) return; // requires IPO + const state = gameState.sources[index]; + if (!state.unlocked || state.employees === 0) return; + const cost = prestigeCost(state); + if (gameState.retainedEarnings < cost) return; + gameState.retainedEarnings -= cost; + state.prestigeLevel = (state.prestigeLevel || 0) + 1; + const src = getSourceDef(index); + document.getElementById('status-text').textContent = `★ ${src.name} restructured! Revenue ×10 (Prestige ${state.prestigeLevel})`; + setTimeout(() => { document.getElementById('status-text').textContent = 'Ready'; }, 4000); + _lastTaxPanelHash = ''; // force IR rebuild (RE changed) + updateDisplay(); + updateGridValues(); + saveGame(); + mobileHaptic('heavy'); +} + function maxAffordable(source) { let cash = gameState.cash; let emps = source.employees; @@ -590,13 +925,18 @@ function sourceRevPerTick(source) { if (!source.unlocked || source.employees === 0) return 0; const stats = SOURCE_STATS[source.id]; const upgradeMult = 1 + source.upgradeLevel * 0.5; - return source.employees * stats.baseRate * upgradeMult / 365.25; + const prestigeMult = Math.pow(10, source.prestigeLevel || 0); + const breakthroughMult = source.breakthroughMult || 1; + const focusMult = isFeatureEnabled('managementFocus') ? 1 + (source.focus || 0) * 0.05 : 1; + return source.employees * stats.baseRate * upgradeMult * prestigeMult * breakthroughMult * focusMult / 365.25; } function totalRevPerTick() { let total = 0; - for (const s of gameState.sources) { - total += sourceRevPerTick(s); + const dbOut = gameState.dbOutage && Date.now() < gameState.dbOutage.until ? gameState.dbOutage.sourceIndex : -1; + for (let i = 0; i < gameState.sources.length; i++) { + if (i === dbOut) continue; // department offline from DB corruption + total += sourceRevPerTick(gameState.sources[i]); } // Board Room revenue multiplier total *= getBoardRoomRevMultiplier(); @@ -621,7 +961,10 @@ function sourceAnnualRev(source) { if (!source.unlocked || source.employees === 0) return 0; const stats = SOURCE_STATS[source.id]; const upgradeMult = 1 + source.upgradeLevel * 0.5; - return source.employees * stats.baseRate * upgradeMult; + const prestigeMult = Math.pow(10, source.prestigeLevel || 0); + const breakthroughMult = source.breakthroughMult || 1; + const focusMult = isFeatureEnabled('managementFocus') ? 1 + (source.focus || 0) * 0.05 : 1; + return source.employees * stats.baseRate * upgradeMult * prestigeMult * breakthroughMult * focusMult; } function totalAnnualRev() { @@ -636,6 +979,12 @@ function totalAnnualRev() { // ===== FORMATTING ===== function formatMoney(n) { + if (n >= 1e33) return '$' + (n / 1e33).toFixed(2) + 'Dc'; + if (n >= 1e30) return '$' + (n / 1e30).toFixed(2) + 'No'; + if (n >= 1e27) return '$' + (n / 1e27).toFixed(2) + 'Oc'; + if (n >= 1e24) return '$' + (n / 1e24).toFixed(2) + 'Sp'; + if (n >= 1e21) return '$' + (n / 1e21).toFixed(2) + 'Sx'; + if (n >= 1e18) return '$' + (n / 1e18).toFixed(2) + 'Qi'; if (n >= 1e15) return '$' + (n / 1e15).toFixed(2) + 'Q'; if (n >= 1e12) return '$' + (n / 1e12).toFixed(2) + 'T'; if (n >= 1e9) return '$' + (n / 1e9).toFixed(2) + 'B'; @@ -650,6 +999,12 @@ function formatNum(n) { function formatRate(annualRev) { // Show rate in the most readable unit + if (annualRev >= 1e33) return '$' + (annualRev / 1e33).toFixed(1) + 'Dc/yr'; + if (annualRev >= 1e30) return '$' + (annualRev / 1e30).toFixed(1) + 'No/yr'; + if (annualRev >= 1e27) return '$' + (annualRev / 1e27).toFixed(1) + 'Oc/yr'; + if (annualRev >= 1e24) return '$' + (annualRev / 1e24).toFixed(1) + 'Sp/yr'; + if (annualRev >= 1e21) return '$' + (annualRev / 1e21).toFixed(1) + 'Sx/yr'; + if (annualRev >= 1e18) return '$' + (annualRev / 1e18).toFixed(1) + 'Qi/yr'; if (annualRev >= 1e15) return '$' + (annualRev / 1e15).toFixed(1) + 'Q/yr'; if (annualRev >= 1e12) return '$' + (annualRev / 1e12).toFixed(1) + 'T/yr'; if (annualRev >= 1e9) return '$' + (annualRev / 1e9).toFixed(1) + 'B/yr'; @@ -659,6 +1014,12 @@ function formatRate(annualRev) { } function formatPerTick(perTick) { + if (perTick >= 1e33) return '$' + (perTick / 1e33).toFixed(1) + 'Dc'; + if (perTick >= 1e30) return '$' + (perTick / 1e30).toFixed(1) + 'No'; + if (perTick >= 1e27) return '$' + (perTick / 1e27).toFixed(1) + 'Oc'; + if (perTick >= 1e24) return '$' + (perTick / 1e24).toFixed(1) + 'Sp'; + if (perTick >= 1e21) return '$' + (perTick / 1e21).toFixed(1) + 'Sx'; + if (perTick >= 1e18) return '$' + (perTick / 1e18).toFixed(1) + 'Qi'; if (perTick >= 1e15) return '$' + (perTick / 1e15).toFixed(1) + 'Q'; if (perTick >= 1e12) return '$' + (perTick / 1e12).toFixed(1) + 'T'; if (perTick >= 1e9) return '$' + (perTick / 1e9).toFixed(1) + 'B'; @@ -707,6 +1068,10 @@ function selectArc(arcKey) { upgradeLevel: 0, automated: false, pendingCollect: 0, + prestigeLevel: 0, + breakthroughMult: 1, + focus: 0, + lastFocusClick: 0, })); gameState.seriesAShown = false; gameState.totalPlayTime = 0; @@ -714,6 +1079,7 @@ function selectArc(arcKey) { gameState.eventCooldown = 30; gameState.miniTaskCooldown = 10; gameState.miniTaskActive = false; + gameState.miniTaskBlocked = null; gameState.miniTaskStreak = 0; gameState.goldenCellActive = false; gameState.goldenCellCooldown = 60; @@ -722,6 +1088,8 @@ function selectArc(arcKey) { gameState.revPenalty = null; gameState.revBonus = null; gameState.powerOutage = null; + gameState.dbOutage = null; + gameState.miniTaskBlocked = null; gameState.hireFrozen = null; gameState.taxDebts = []; gameState.quarterRevenue = 0; @@ -753,6 +1121,7 @@ function selectArc(arcKey) { gameState.activeCFOLevel = 0; gameState.cfoRecords = {}; gameState.revenueHistory = []; + gameState.lastQuarterRE = 0; // Clear stale panels const taxPanel = document.getElementById('tax-panel'); @@ -770,6 +1139,25 @@ function selectArc(arcKey) { document.getElementById('arc-select').classList.add('hidden'); document.getElementById('game-view').classList.remove('hidden'); + mobileHaptic('heavy'); + + // Reset mobile state + if (isMobile()) { + _mobileActiveTab = 'operations'; + const cashHeader = document.getElementById('mobile-cash-header'); + if (cashHeader) cashHeader.classList.remove('visible'); + document.querySelectorAll('.mob-nav-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === 'operations'); + }); + const gridContainer = document.getElementById('grid-container'); + if (gridContainer) gridContainer.style.display = ''; + const pnlView = document.getElementById('mobile-pnl-view'); + const brView = document.getElementById('mobile-boardroom-view'); + const settingsView = document.getElementById('mobile-settings-view'); + if (pnlView) pnlView.classList.add('hidden'); + if (brView) brView.classList.add('hidden'); + if (settingsView) settingsView.classList.add('hidden'); + } buildGrid(); updateDisplay(); @@ -785,9 +1173,11 @@ function showArcSelect() { div.onclick = () => selectArc(key); div.innerHTML = `
${arc.icon}
-
${arc.name}
-
${arc.desc}
-
${arc.sources[0].name} → ${arc.sources[arc.sources.length - 1].name}
+
+
${arc.name}
+
${arc.desc}
+
${arc.sources[0].name} → ${arc.sources[arc.sources.length - 1].name}
+
`; container.appendChild(div); } @@ -798,6 +1188,7 @@ function showArcSelect() { // ===== MINI-TASKS ===== function trySpawnMiniTask() { if (gameState.miniTaskActive) return; + if (gameState.miniTaskBlocked && Date.now() < gameState.miniTaskBlocked.until) return; // email server down if (gameState.miniTaskCooldown > 0) { gameState.miniTaskCooldown--; return; } // Frequency decreases as passive income grows @@ -871,6 +1262,8 @@ function completeMiniTask() { flashCash(); updateDisplay(); + mobileHaptic('success'); + mobileCashPulse(); } function skipMiniTask() { @@ -884,6 +1277,7 @@ function skipMiniTask() { setTimeout(() => { document.getElementById('status-text').textContent = 'Ready'; }, 2000); } gameState.miniTaskStreak = 0; + mobileHaptic('light'); } // ===== TIME SCALE CHANGE NOTIFICATION ===== @@ -904,8 +1298,12 @@ function trySpawnGoldenCell() { const pick = unlocked[Math.floor(Math.random() * unlocked.length)]; const rowIndex = pick.i; - // Pick a random visible cell in that row (columns b through h) - const cols = ['cell-b', 'cell-c', 'cell-d', 'cell-e', 'cell-f', 'cell-g', 'cell-h']; + // Pick a random visible cell in that row + // On mobile, apply golden to the whole card row instead of a single cell + const _mob = isMobile(); + const cols = _mob + ? ['cell-a', 'cell-b', 'cell-c', 'cell-d'] + : ['cell-b', 'cell-c', 'cell-d', 'cell-e', 'cell-f', 'cell-g', 'cell-h']; const colClass = cols[Math.floor(Math.random() * cols.length)]; // Find the grid row for this source @@ -913,7 +1311,7 @@ function trySpawnGoldenCell() { if (rowIndex >= gridRows.length) return; const row = gridRows[rowIndex]; - const cell = row.querySelector('.' + colClass); + const cell = _mob ? row : row.querySelector('.' + colClass); if (!cell) return; const reward = Math.floor(totalRevPerTick() * 20); @@ -952,6 +1350,8 @@ function clickGoldenCell(cell) { setTimeout(() => { document.getElementById('status-text').textContent = 'Ready'; }, 2000); flashCash(); + mobileHaptic('heavy'); + mobileCashPulse(); updateDisplay(); return true; } @@ -965,7 +1365,7 @@ function buildGrid() { for (let i = 0; i < SOURCE_STATS.length; i++) { const src = getSourceDef(i); const state = gameState.sources[i]; - const rowNum = i + 4; + const rowNum = i + 3; const row = document.createElement('div'); row.className = 'grid-row source-row'; @@ -974,6 +1374,8 @@ function buildGrid() { if (!state.unlocked) { row.classList.add('source-locked'); const nextUnlockable = isNextUnlock(i); + // On mobile, hide non-unlockable locked rows + if (!nextUnlockable) row.classList.add('mob-hidden'); row.innerHTML = `
${rowNum}
🔒 ${src.name}
@@ -1004,6 +1406,27 @@ function buildGrid() { container.appendChild(row); } + // Overtime row (below sources, hidden until feature enabled + has revenue) + const overtimeRow = document.createElement('div'); + overtimeRow.className = 'grid-row source-row'; + overtimeRow.id = 'overtime-row'; + overtimeRow.style.display = 'none'; + const otRowNum = SOURCE_STATS.length + 4; + overtimeRow.innerHTML = ` +
${otRowNum}
+
⏰ Overtime
+
+
+
+ +
+
+
+
+
+ `; + container.appendChild(overtimeRow); + // Filler rows buildFillerRows(); @@ -1030,7 +1453,7 @@ function buildFillerRows() { const available = viewportHeight - gridBottom - bottomChrome; const fillerCount = Math.max(3, Math.ceil(available / ROW_HEIGHT) + 1); - const startRow = SOURCE_STATS.length + 4 + taxRowCount; + const startRow = SOURCE_STATS.length + 3 + taxRowCount; for (let i = 0; i < fillerCount; i++) { const row = document.createElement('div'); row.className = 'filler-row'; @@ -1070,16 +1493,48 @@ function updateGridValues() { const hCost = hireCost(state); const uCost = upgradeCost(state); const aCost = automateCost(state); + const _mob = isMobile(); - // Name + // Name (clickable for focus) const nameCell = row.querySelector('[data-field="name"]'); - nameCell.innerHTML = src.name + (state.upgradeLevel > 0 ? ` Lv${state.upgradeLevel}` : ''); + const prestigeTag = (state.prestigeLevel || 0) > 0 ? ` ★${state.prestigeLevel}` : ''; + const breakthroughTag = (state.breakthroughMult || 1) > 1 ? ` 🔬×${state.breakthroughMult}` : ''; + const focusLevel = state.focus || 0; + const focusable = isFeatureEnabled('managementFocus') && state.automated; + const focusIcon = focusable ? `🎯` : ''; + const lvBadgeCls = _mob ? 'mob-level-badge' : ''; + const tags = (state.upgradeLevel > 0 ? `Lv${state.upgradeLevel}` : '') + prestigeTag + breakthroughTag; + const autoTag = (_mob && state.automated) ? '' : ''; + nameCell.innerHTML = `${focusIcon}${src.name}${autoTag}${tags}`; + if (focusable) { + nameCell.style.cursor = 'pointer'; + nameCell.classList.add('focusable'); + nameCell.onclick = () => clickFocus(i); + } else { + nameCell.style.cursor = ''; + nameCell.classList.remove('focusable'); + nameCell.onclick = null; + } // Employees row.querySelector('[data-field="emp"]').textContent = state.employees; - // Rev/day (column C) - row.querySelector('[data-field="rate"]').textContent = formatPerTick(revPerDay); + // Rev/day (column C) — show focus bonus when active + const rateCell = row.querySelector('[data-field="rate"]'); + // DB outage visual + const isDbDown = gameState.dbOutage && Date.now() < gameState.dbOutage.until && gameState.dbOutage.sourceIndex === i; + if (isDbDown) { + row.classList.add('db-outage'); + const secsLeft = Math.ceil((gameState.dbOutage.until - Date.now()) / 1000); + rateCell.innerHTML = `💾 OFFLINE ${secsLeft}s`; + } else { + row.classList.remove('db-outage'); + if (isFeatureEnabled('managementFocus') && focusLevel > 0) { + rateCell.innerHTML = `${formatPerTick(revPerDay)} +${focusLevel * 5}%`; + } else { + rateCell.textContent = formatPerTick(revPerDay); + } + } // Rev/yr (column G) row.querySelector('[data-field="annual"]').textContent = formatRate(rev); @@ -1094,31 +1549,61 @@ function updateGridValues() { const maxHires = maxAffordable(state); const upgradeMult = 1 + state.upgradeLevel * 0.5; const hireGainPerDay = src.baseRate * upgradeMult / 365.25; - a1.innerHTML = (maxHires > 1 ? `` : '') + - ``; + const hireLbl = _mob + ? `Hire ${formatMoney(hCost)}` + : `Hire ${formatMoney(hCost)} (+${formatPerTick(hireGainPerDay)}/d)`; + a1.innerHTML = (maxHires > 1 ? `` : '') + + ``; } // Action 2: Upgrade or Automate const a2 = row.querySelector('[data-field="action2"]'); - if (!state.automated) { + if (_mob) { + // Mobile: Always show Upgrade in cell-e (action2) + const maxUpgrades = maxAffordableUpgrades(state); + const revGainPerDay = state.employees * src.baseRate * 0.5 / 365.25; + const upgLbl = `Upgrade ${formatMoney(uCost)}`; + a2.innerHTML = (maxUpgrades > 1 ? `` : '') + + ``; + } else if (!state.automated) { a2.innerHTML = ``; } else { const maxUpgrades = maxAffordableUpgrades(state); const revGainPerDay = state.employees * src.baseRate * 0.5 / 365.25; - a2.innerHTML = (maxUpgrades > 1 ? `` : '') + - ``; + const upgLbl = `⬆ ${formatMoney(uCost)} (+${formatPerTick(revGainPerDay)}/d)`; + a2.innerHTML = (maxUpgrades > 1 ? `` : '') + + ``; } - // Action 3: Collect (click) or AUTO badge + // Action 3: Collect (click) or AUTO badge + Restructure const a3 = row.querySelector('[data-field="action3"]'); if (!state.automated) { const pending = state.pendingCollect; const clickVal = src.clickValue; const hasPending = pending > 0.005; - a3.innerHTML = ``; + const collectLbl = _mob + ? `💰 Collect${hasPending ? ' ' + formatMoney(pending) : ''}` + : `Collect${hasPending ? ' ' + formatMoney(pending) : ''} (+${formatMoney(clickVal)})`; + a3.innerHTML = ``; + } else if (gameState.isPublic) { + const pCost = prestigeCost(state); + const canPrestige = gameState.retainedEarnings >= pCost; + a3.innerHTML = ` `; } else { a3.innerHTML = '⚡ AUTO'; } + + // Mobile: Auto button in separate row (cell-g repurposed, shown via CSS) + if (_mob) { + const autoCell = row.querySelector('[data-field="annual"]'); + if (!state.automated) { + autoCell.innerHTML = ``; + row.classList.remove('mob-automated'); + } else { + autoCell.innerHTML = '⚡ AUTO'; + row.classList.add('mob-automated'); + } + } } } @@ -1138,15 +1623,32 @@ function updateDisplay() { const perTick = totalRevPerTick(); document.getElementById('total-rev').textContent = formatRate(totalRev); - // Per-tick display (= per day, prominent) - document.getElementById('per-tick-display').textContent = formatPerTick(perTick) + '/day'; + // Per-tick display (= per day, prominent) — color-coded for active effects + const ptEl = document.getElementById('per-tick-display'); + const hasOutage = gameState.powerOutage && Date.now() < gameState.powerOutage.until; + const hasPenalty = gameState.revPenalty && Date.now() < gameState.revPenalty.until; + const hasBonus = gameState.revBonus && Date.now() < gameState.revBonus.until; + const hasDbOut = gameState.dbOutage && Date.now() < gameState.dbOutage.until; + + if (hasOutage) { + ptEl.innerHTML = `⚡ $0.00/day`; + } else if (hasPenalty) { + const pct = Math.round((1 - gameState.revPenalty.mult) * 100); + ptEl.innerHTML = `${formatPerTick(perTick)}/day ▼${pct}%`; + } else if (hasDbOut) { + ptEl.innerHTML = `${formatPerTick(perTick)}/day 💾`; + } else if (hasBonus) { + ptEl.innerHTML = `${formatPerTick(perTick)}/day ▲×${gameState.revBonus.mult}`; + } else { + ptEl.textContent = formatPerTick(perTick) + '/day'; + } // Stock price in header (Phase 2.1) const stockCell = document.getElementById('stock-price-cell'); if (stockCell) { if (gameState.isPublic) { const sp = getStockPrice(); - stockCell.innerHTML = `📈 ${formatMoney(sp)}`; + stockCell.innerHTML = `Stock: ${formatMoney(sp)}`; } else { stockCell.innerHTML = ''; } @@ -1209,12 +1711,27 @@ function updateDisplay() { document.getElementById('status-text').textContent = `⚡ SYSTEMS OFFLINE — back in ${secsLeft}s`; } else if (gameState.powerOutage) { gameState.powerOutage = null; + gameState.dbOutage = null; + gameState.miniTaskBlocked = null; document.getElementById('status-text').textContent = '✅ Systems restored'; setTimeout(() => { if (document.getElementById('status-text').textContent === '✅ Systems restored') { document.getElementById('status-text').textContent = 'Ready'; } }, 3000); + } else if (gameState.dbOutage && Date.now() < gameState.dbOutage.until) { + const secsLeft = Math.ceil((gameState.dbOutage.until - Date.now()) / 1000); + const arc = ARCS[gameState.arc]; + const name = arc ? arc.sources[gameState.dbOutage.sourceIndex].name || 'Department' : 'Department'; + document.getElementById('status-text').textContent = `💾 ${name} offline — DB recovering ${secsLeft}s`; + } else if (gameState.dbOutage) { + gameState.dbOutage = null; + document.getElementById('status-text').textContent = '✅ Database restored'; + setTimeout(() => { + if (document.getElementById('status-text').textContent === '✅ Database restored') { + document.getElementById('status-text').textContent = 'Ready'; + } + }, 3000); } else if (gameState.revPenalty && Date.now() < gameState.revPenalty.until) { const secsLeft = Math.ceil((gameState.revPenalty.until - Date.now()) / 1000); const hireFroze = gameState.hireFrozen && Date.now() < gameState.hireFrozen; @@ -1232,14 +1749,21 @@ function updateDisplay() { } else if (gameState.hireFrozen && Date.now() < gameState.hireFrozen) { const secsLeft = Math.ceil((gameState.hireFrozen - Date.now()) / 1000); document.getElementById('status-text').textContent = `🚫 Hiring frozen — ${secsLeft}s remaining`; + } else if (gameState.miniTaskBlocked && Date.now() < gameState.miniTaskBlocked.until) { + const secsLeft = Math.ceil((gameState.miniTaskBlocked.until - Date.now()) / 1000); + document.getElementById('status-text').textContent = `📧 Email down — no approvals for ${secsLeft}s`; } else { if (gameState.hireFrozen) gameState.hireFrozen = null; + if (gameState.miniTaskBlocked) gameState.miniTaskBlocked = null; } // Don't overwrite mini-task feedback messages // Update P&L section updateTaxPanel(); + // Update overtime row + updateOvertimeRow(); + // Update Board Room tab visibility and content updateBoardRoomTab(); if (gameState.activeTab === 'boardroom') { @@ -1261,6 +1785,18 @@ function unlockSource(index) { buildGrid(); updateDisplay(); flashCash(); + mobileHaptic('success'); + // Animate newly unlocked card on mobile + if (isMobile()) { + mobileCashPulse(); + const newRow = document.getElementById(`source-row-${index}`); + if (newRow) { + newRow.classList.add('mob-just-unlocked'); + setTimeout(() => newRow.classList.remove('mob-just-unlocked'), 600); + // Scroll to the new card + setTimeout(() => newRow.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100); + } + } } function hireEmployee(index) { @@ -1277,6 +1813,7 @@ function hireEmployee(index) { updateGridValues(); updateDisplay(); flashCash(); + mobileHaptic('light'); } function hireMax(index) { @@ -1338,6 +1875,7 @@ function upgradeSource(index) { updateGridValues(); updateDisplay(); flashCash(); + mobileHaptic('medium'); } function automateSource(index) { @@ -1346,6 +1884,10 @@ function automateSource(index) { const cost = automateCost(state); if (gameState.cash < cost) return; + // Prevent double-tap on mobile + if (isMobile() && automateSource._lastTap && Date.now() - automateSource._lastTap < 400) return; + automateSource._lastTap = Date.now(); + gameState.cash -= cost; state.automated = true; addCapitalExpense(cost); @@ -1358,6 +1900,19 @@ function automateSource(index) { updateGridValues(); updateDisplay(); flashCash(); + mobileHaptic('success'); + + // Focus tip — show once per game after first automate + if (isFeatureEnabled('managementFocus') && !gameState.focusTipShown) { + gameState.focusTipShown = true; + setTimeout(() => { + document.getElementById('status-text').textContent = '💡 Tip: Click department names to boost their revenue'; + setTimeout(() => { + const st = document.getElementById('status-text'); + if (st && st.textContent.startsWith('💡')) st.textContent = 'Ready'; + }, 5000); + }, 2000); + } } function collectSource(index) { @@ -1383,6 +1938,8 @@ function collectSource(index) { updateGridValues(); updateDisplay(); flashCash(); + mobileHaptic('light'); + mobileCashPulse(); } // ===== DEPRECIATION ===== @@ -1428,6 +1985,7 @@ function flashCash() { // ===== TAX DEBT SYSTEM ===== function processQuarterlyTax() { + const hasCPA = hasBoardRoomUpgrade('cpa'); const depreciation = getQuarterlyDepreciation(); const taxableIncome = gameState.quarterRevenue - depreciation; const taxRate = getBoardRoomTaxRate(); @@ -1451,23 +2009,69 @@ function processQuarterlyTax() { gameState.quarterExpenses = 0; gameState.quarterTaxPaid = 0; + const currentDay = Math.floor(gameState.gameElapsedSecs / SECS_PER_DAY); + const quarter = Math.floor(currentDay / 90); + const qLabel = `Q${(quarter % 4) + 1}`; + + // CPA auto-pay: handle taxes silently + if (hasCPA) { + if (taxOwed <= 0) { + // No tax — CPA files a zero return silently + return; + } + if (gameState.cash >= taxOwed) { + // Can afford — auto-pay + gameState.cash -= taxOwed; + gameState.quarterTaxPaid += taxOwed; + gameState.totalTaxPaid += taxOwed; + document.getElementById('status-text').textContent = `📋 CPA paid ${qLabel} taxes (${formatMoney(taxOwed)})`; + setTimeout(() => { document.getElementById('status-text').textContent = 'Ready'; }, 4000); + updateDisplay(); + flashCash(); + return; + } else { + // Can't afford — CPA defers (creates debt like Ignore, but silently) + if (!gameState.taxDebts) gameState.taxDebts = []; + gameState.taxDebts.push({ + original: taxOwed, + current: taxOwed, + dayCreated: currentDay, + daysOverdue: 0, + stage: 'notice1', + quarter: qLabel, + }); + document.getElementById('status-text').textContent = `📋 CPA deferred ${qLabel} taxes — insufficient funds`; + setTimeout(() => { document.getElementById('status-text').textContent = 'Ready'; }, 4000); + updateTaxPanel(); + return; + } + } + if (taxOwed <= 0) { - const currentDay = Math.floor(gameState.gameElapsedSecs / SECS_PER_DAY); - const quarter = Math.floor(currentDay / 90); - const qLabel = `Q${(quarter % 4) + 1}`; showEventToast('IRS', `${qLabel} Quarterly Tax Assessment`, - `Revenue: ${formatMoney(qRev)}\nDepreciation: (${formatMoney(qDep)})\nTaxable income: ${formatMoney(Math.max(0, taxableIncome))}\n\nNo tax owed this quarter.`, - [{ label: 'OK', effect: () => `No tax liability for ${qLabel}. Keep investing!` }]); + `
+
Revenue${formatMoney(qRev)}
+
Depreciation(${formatMoney(qDep)})
+
Taxable Income${formatMoney(Math.max(0, taxableIncome))}
+
+
Tax Owed$0 ✓
+
`, + [{ label: 'OK', effect: () => `No tax liability for ${qLabel}. Keep investing!` }], + { html: true }); return; } - const currentDay = Math.floor(gameState.gameElapsedSecs / SECS_PER_DAY); - const quarter = Math.floor(currentDay / 90); - const qLabel = `Q${(quarter % 4) + 1}`; - - const amtNote = isAMT ? `\n⚠️ AMT applies (${Math.round(amtRate*100)}% of revenue > regular tax)` : ''; + const amtNote = isAMT ? `
⚠️ AMT${Math.round(amtRate*100)}% of revenue > regular tax
` : ''; showEventToast('IRS', `${qLabel} Quarterly Tax Assessment`, - `Revenue: ${formatMoney(qRev)}\nDepreciation: (${formatMoney(qDep)})\nTaxable income: ${formatMoney(taxableIncome)}\n\nTax owed (${isAMT ? 'AMT ' + Math.round(amtRate*100) + '%' : Math.round(taxRate*100) + '%'}): ${formatMoney(taxOwed)}${amtNote}`, + `
+
Revenue${formatMoney(qRev)}
+
Depreciation(${formatMoney(qDep)})
+
Taxable Income${formatMoney(taxableIncome)}
+
+
Tax Rate${isAMT ? 'AMT ' + Math.round(amtRate*100) + '%' : Math.round(taxRate*100) + '%'}
+
Tax Owed${formatMoney(taxOwed)}
+ ${amtNote} +
`, [ { label: `Pay ${formatMoney(taxOwed)}`, disabledLabel: 'Not enough cash', @@ -1491,12 +2095,33 @@ function processQuarterlyTax() { updateTaxPanel(); return `You ignored the ${qLabel} tax bill. ${formatMoney(taxOwed)} added to tax liability. Interest is accruing...`; }}, - ], { closable: false }); + ], { closable: false, html: true }); } function processTaxDebts() { if (!gameState.taxDebts || gameState.taxDebts.length === 0) return; + // CPA auto-settle: try to pay off debts before they escalate + if (hasBoardRoomUpgrade('cpa')) { + for (let i = gameState.taxDebts.length - 1; i >= 0; i--) { + const debt = gameState.taxDebts[i]; + if (debt.settled) continue; + if (gameState.cash >= debt.current) { + gameState.cash -= debt.current; + gameState.quarterTaxPaid += debt.current; + gameState.totalTaxPaid += debt.current; + document.getElementById('status-text').textContent = `📋 CPA settled ${debt.quarter} tax debt (${formatMoney(debt.current)})`; + setTimeout(() => { document.getElementById('status-text').textContent = 'Ready'; }, 4000); + gameState.taxDebts.splice(i, 1); + _lastTaxPanelHash = ''; + updateTaxPanel(); + updateDisplay(); + flashCash(); + } + } + if (gameState.taxDebts.length === 0) return; + } + let needsUpdate = false; for (const debt of gameState.taxDebts) { // Accrue 1% daily interest @@ -1645,10 +2270,18 @@ function updateTaxPanel() { panel.classList.remove('hidden'); const sourceCount = SOURCE_STATS.length; - let rowNum = sourceCount + 4; + let rowNum = sourceCount + 3; let html = ''; - // ===== P&L SECTION ===== + // ===== P&L SECTION (collapsible) ===== + const pnlCollapsed = gameState.pnlCollapsed || false; + const pnlArrow = pnlCollapsed ? '▶' : '▼'; + + const currentDay = Math.floor(gameState.gameElapsedSecs / SECS_PER_DAY); + const daysIntoQuarter = currentDay - gameState.lastQuarterDay; + const daysToTax = Math.max(0, 90 - daysIntoQuarter); + const garnishActive = gameState.taxDebts && gameState.taxDebts.some(d => d.stage === 'garnish'); + // Separator html += `
${rowNum++}
@@ -1658,123 +2291,119 @@ function updateTaxPanel() {
`; - const currentDay = Math.floor(gameState.gameElapsedSecs / SECS_PER_DAY); - const daysIntoQuarter = currentDay - gameState.lastQuarterDay; - const daysToTax = Math.max(0, 90 - daysIntoQuarter); - - // P&L Header + // P&L Header (clickable to collapse) html += `
${rowNum++}
-
PROFIT & LOSS
+
${pnlArrow} PROFIT & LOSS
-
This Qtr
-
Lifetime
+
${pnlCollapsed ? '' : 'This Qtr'}
+
${pnlCollapsed ? '' : 'Lifetime'}
-
Tax due in ${daysToTax}d
+
${pnlCollapsed ? '' : `Tax due in ${daysToTax}d`}
`; - const totalExpenses = gameState.totalSpentHires + gameState.totalSpentUpgrades + gameState.totalSpentAuto; - const qDepreciation = getQuarterlyDepreciation(); - - // Revenue - html += `
-
${rowNum++}
-
Revenue
-
-
${formatMoney(gameState.quarterRevenue)}
-
${formatMoney(gameState.totalEarned)}
-
-
-
`; + if (!pnlCollapsed) { + const totalExpenses = gameState.totalSpentHires + gameState.totalSpentUpgrades + gameState.totalSpentAuto; + const qDepreciation = getQuarterlyDepreciation(); - // Garnishment (if active) - const garnishActive = gameState.taxDebts && gameState.taxDebts.some(d => d.stage === 'garnish'); - if (garnishActive) { - const garnishLoss = Math.floor(gameState.quarterRevenue * 0.15 / 0.85); // estimate pre-garnish revenue lost + // Revenue html += `
${rowNum++}
-
🔴 IRS Garnishment
+
Revenue
-
−15%
-
-
settle debt to remove
+
${formatMoney(gameState.quarterRevenue)}
+
${formatMoney(gameState.totalEarned)}
+
`; - } - // Capital Spending (this quarter) - html += `
-
${rowNum++}
-
Capital Spending
-
-
(${formatMoney(gameState.quarterExpenses)})
-
(${formatMoney(totalExpenses)})
-
not tax-deductible
-
-
`; + // Garnishment (if active) + if (garnishActive) { + html += `
+
${rowNum++}
+
🔴 IRS Garnishment
+
+
−15%
+
+
settle debt to remove
+
+
`; + } - // Depreciation - const capCount = gameState.capitalExpenses ? gameState.capitalExpenses.length : 0; - html += `
-
${rowNum++}
-
Depreciation
-
-
(${formatMoney(qDepreciation)})
-
-
${capCount} assets depreciating
-
-
`; + // Capital Spending + html += `
+
${rowNum++}
+
Capital Spending
+
+
(${formatMoney(gameState.quarterExpenses)})
+
(${formatMoney(totalExpenses)})
+
not tax-deductible
+
+
`; - // Taxes paid - html += `
-
${rowNum++}
-
Taxes Paid
-
-
(${formatMoney(gameState.quarterTaxPaid)})
-
(${formatMoney(gameState.totalTaxPaid)})
-
-
-
`; + // Depreciation + const capCount = gameState.capitalExpenses ? gameState.capitalExpenses.length : 0; + html += `
+
${rowNum++}
+
Depreciation
+
+
(${formatMoney(qDepreciation)})
+
+
${capCount} assets depreciating
+
+
`; - // Taxable Income (revenue - depreciation, what IRS taxes — or AMT if higher) - const qTaxable = Math.max(0, gameState.quarterRevenue - qDepreciation); - const currentTaxRate = getBoardRoomTaxRate(); - const currentAMTRate = getBoardRoomAMTRate(); - const qRegularTax = Math.floor(qTaxable * currentTaxRate); - const qAMT = Math.floor(gameState.quarterRevenue * currentAMTRate); - const amtApplies = qAMT > qRegularTax && gameState.quarterRevenue > 0; - const effectiveTaxable = amtApplies ? gameState.quarterRevenue : qTaxable; - const taxRatePct = Math.round(currentTaxRate * 100); - const amtRatePct = Math.round(currentAMTRate * 100); - const taxableLabel = amtApplies ? 'AMT Taxable Income' : 'Taxable Income'; - const taxableNote = amtApplies ? `⚠️ AMT floor (${amtRatePct}% of revenue)` : 'rev − depreciation'; - html += `
-
${rowNum++}
-
${taxableLabel}
-
-
${formatMoney(effectiveTaxable)}
-
-
${taxableNote}
-
-
`; + // Taxes paid + html += `
+
${rowNum++}
+
Taxes Paid
+
+
(${formatMoney(gameState.quarterTaxPaid)})
+
(${formatMoney(gameState.totalTaxPaid)})
+
+
+
`; - // Net Income (double-underline like real accounting) - const qNet = gameState.quarterRevenue - gameState.quarterExpenses - gameState.quarterTaxPaid; - const ltNet = gameState.totalEarned - totalExpenses - gameState.totalTaxPaid; - const qColor = qNet >= 0 ? '#217346' : '#c00'; - const ltColor = ltNet >= 0 ? '#217346' : '#c00'; + // Taxable Income + const qTaxable = Math.max(0, gameState.quarterRevenue - qDepreciation); + const currentTaxRate = getBoardRoomTaxRate(); + const currentAMTRate = getBoardRoomAMTRate(); + const qRegularTax = Math.floor(qTaxable * currentTaxRate); + const qAMT = Math.floor(gameState.quarterRevenue * currentAMTRate); + const amtApplies = qAMT > qRegularTax && gameState.quarterRevenue > 0; + const effectiveTaxable = amtApplies ? gameState.quarterRevenue : qTaxable; + const taxRatePct = Math.round(currentTaxRate * 100); + const amtRatePct = Math.round(currentAMTRate * 100); + const taxableLabel = amtApplies ? 'AMT Taxable Income' : 'Taxable Income'; + const taxableNote = amtApplies ? `⚠️ AMT floor (${amtRatePct}% of revenue)` : 'rev − depreciation'; + html += `
+
${rowNum++}
+
${taxableLabel}
+
+
${formatMoney(effectiveTaxable)}
+
+
${taxableNote}
+
+
`; - html += `
-
${rowNum++}
-
Net Income
-
-
${qNet < 0 ? '(' + formatMoney(-qNet) + ')' : formatMoney(qNet)}
-
${ltNet < 0 ? '(' + formatMoney(-ltNet) + ')' : formatMoney(ltNet)}
-
-
-
`; + // Net Income + const qNet = gameState.quarterRevenue - gameState.quarterExpenses - gameState.quarterTaxPaid; + const ltNet = gameState.totalEarned - totalExpenses - gameState.totalTaxPaid; + const qColor = qNet >= 0 ? '#217346' : '#c00'; + const ltColor = ltNet >= 0 ? '#217346' : '#c00'; + + html += `
+
${rowNum++}
+
Net Income
+
+
${qNet < 0 ? '(' + formatMoney(-qNet) + ')' : formatMoney(qNet)}
+
${ltNet < 0 ? '(' + formatMoney(-ltNet) + ')' : formatMoney(ltNet)}
+
+
+
`; + } // ===== INVESTOR RELATIONS SECTION (Phase 2.1) ===== if (gameState.isPublic) { @@ -1800,7 +2429,8 @@ function updateTaxPanel() { const trackColor = onTrack ? '#217346' : '#c00'; const trackDetail = `${onTrack ? '+' : ''}${formatCompact(trackDiff)} (${trackPct >= 0 ? '+' : ''}${trackPct.toFixed(1)}%)`; const streakVal = gameState.earningsStreak; - const streakStr = streakVal > 0 ? `🔥 ${streakVal} beat${streakVal > 1 ? 's' : ''}` : + const irStreakMult = streakVal >= 1 ? Math.min(2.0, 1 + streakVal * 0.1) : 1; + const streakStr = streakVal > 0 ? `🔥 ${streakVal} beat${streakVal > 1 ? 's' : ''} (${irStreakMult.toFixed(1)}× RE)` : streakVal < 0 ? `❄️ ${Math.abs(streakVal)} miss${Math.abs(streakVal) > 1 ? 'es' : ''}` : '—'; // Separator @@ -1843,7 +2473,7 @@ function updateTaxPanel() {
${rowNum++}
Revenue vs Target
${trackLabel}
-
${trackDetail}
+
${trackPct >= 0 ? '+' : ''}${trackPct.toFixed(1)}%
${formatCompact(qRev)} / ${formatCompact(target)}
@@ -1871,10 +2501,16 @@ function updateTaxPanel() { const btnStyle = (active) => active ? 'cursor:pointer;font-weight:700;color:#fff;background:#0078d4;padding:1px 6px;border-radius:2px;font-size:11px;margin-right:3px' : 'cursor:pointer;color:#0078d4;border:1px solid #0078d4;padding:1px 5px;border-radius:2px;font-size:10px;margin-right:3px;background:transparent'; - let cfoButtons = `Manual`; + let cfoManual = `Manual`; const labels = { 1: '👶 1', 2: '📊 2', 3: '🎩 3' }; + const tooltips = { + 1: 'The Intern — auto-sets guidance randomly (often wrong)', + 2: 'Competent CFO — analyzes trends, ~70% optimal', + 3: 'Elite CFO — factors in everything, ~90% optimal' + }; + let cfoLevels = ''; for (let lvl = 1; lvl <= maxCFOLevel; lvl++) { - cfoButtons += `${labels[lvl]}`; + cfoLevels += `${labels[lvl]}`; } // Record for active CFO const record = gameState.cfoRecords[activeCFO]; @@ -1888,8 +2524,8 @@ function updateTaxPanel() { html += `
${rowNum++}
Finance Dept
-
${cfoButtons}
-
+
${cfoManual}
+
${cfoLevels}
${activeCFO > 0 ? 'Record' : ''}
${recordStr}
@@ -1925,14 +2561,41 @@ function updateTaxPanel() {
`; // Retained Earnings + const lastQRE = gameState.lastQuarterRE || 0; + const lastQStr = lastQRE > 0 ? `Last Q: +${lastQRE}` : ''; + // ETA: find cheapest purchasable upgrade, estimate quarters to afford + let etaStr = ''; + if (lastQRE > 0) { + const available = BOARD_ROOM_UPGRADES.filter(u => { + const owned = getBoardRoomUpgradeCount(u.id); + if (owned >= u.maxCount) return false; + if (u.requires && !hasBoardRoomUpgrade(u.requires)) return false; + return true; + }); + const nextUpgrade = available.sort((a, b) => getUpgradeCost(a) - getUpgradeCost(b))[0]; + if (nextUpgrade) { + const cost = getUpgradeCost(nextUpgrade); + const remaining = cost - gameState.retainedEarnings; + if (remaining > 0) { + const quartersNeeded = Math.ceil(remaining / lastQRE); + const etaSecs = quartersNeeded * 90; + const etaTime = etaSecs >= 3600 ? `${Math.floor(etaSecs/3600)}h${Math.floor((etaSecs%3600)/60)}m` + : etaSecs >= 60 ? `${Math.floor(etaSecs/60)}m${etaSecs%60 ? etaSecs%60 + 's' : ''}` + : `${etaSecs}s`; + etaStr = `${nextUpgrade.name} in ~${quartersNeeded}Q (${etaTime})`; + } else { + etaStr = `${nextUpgrade.name} — affordable!`; + } + } + } html += `
${rowNum++}
Retained Earnings
${gameState.retainedEarnings.toLocaleString()} RE
-
+
${lastQStr}
-
Spend in Board Room tab
+
${etaStr}
`; @@ -2007,7 +2670,6 @@ function updateTaxPanel() { } // Only rebuild DOM if content actually changed (prevents click-swallowing race) - // Hash key parts that change: P&L numbers, tax debt count/amounts/stages, days-to-tax, IR data const hashParts = [ gameState.quarterRevenue|0, gameState.totalEarned|0, gameState.quarterExpenses|0, gameState.quarterTaxPaid|0, gameState.totalTaxPaid|0, @@ -2025,6 +2687,7 @@ function updateTaxPanel() { // Phase 2.2: Board Room effects on display getBoardRoomTaxRate(), getBoardRoomRevMultiplier(), + gameState.pnlCollapsed ? 1 : 0, ].join('|'); if (hashParts !== _lastTaxPanelHash) { @@ -2106,6 +2769,7 @@ function gameTick() { if (currentDay - gameState.lastQuarterDay >= 90) { processQuarterlyTax(); gameState.lastQuarterDay = currentDay; + gameState.overtimeClicks = 0; // reset overtime each quarter } // Tax debt processing (each tick = 1 day) @@ -2136,9 +2800,9 @@ function gameTick() { } else if (!document.getElementById('event-toast').classList.contains('hidden')) { // Toast already visible } else { - if (Math.random() < 0.015 && gameState.totalPlayTime > 30) { + if (Math.random() < 0.02 * EVENT_FREQ_MULT && gameState.totalPlayTime > 30) { triggerRandomEvent(); - gameState.eventCooldown = 60 + Math.floor(Math.random() * 60); + gameState.eventCooldown = Math.floor((30 + Math.floor(Math.random() * 30)) / (EVENT_FREQ_MULT || 1)); } } @@ -2148,10 +2812,27 @@ function gameTick() { showSeriesA(); } + // Close the Deal spawning + if (isFeatureEnabled('closeTheDeals') && !gameState.dealActive) { + if (gameState.dealCooldown === undefined || gameState.dealCooldown === null) { + gameState.dealCooldown = 60 + Math.floor(Math.random() * 120); + } + if (gameState.dealCooldown > 0) { + gameState.dealCooldown--; + } else if (totalRevPerTick() > 0 && !isPowerOut && + gameState.totalPlayTime > 60) { + spawnDeal(); + gameState.dealCooldown = 90 + Math.floor(Math.random() * 150); // 1.5-4 min + } + } + // Valuation chart sampleValuation(); drawValuationChart(); + // Management focus decay + decayFocus(); + updateToastButtons(); updateGridValues(); updateDisplay(); @@ -2159,8 +2840,13 @@ function gameTick() { // ===== EVENTS ===== function triggerRandomEvent() { - const event = EVENTS[Math.floor(Math.random() * EVENTS.length)]; - showEvent(event); + const totalWeight = EVENTS.reduce((sum, e) => sum + (e.weight || 1), 0); + let roll = Math.random() * totalWeight; + for (const event of EVENTS) { + roll -= (event.weight || 1); + if (roll <= 0) { showEvent(event); return; } + } + showEvent(EVENTS[EVENTS.length - 1]); // fallback } let eventToastTimer = null; @@ -2168,7 +2854,11 @@ let _eventToastActions = null; // track actions with cashRequired for live updat function showEvent(event) { // Handle dynamic events that generate content at trigger time - if (event.dynamic && event.setup) { + if (event.generate) { + const result = event.generate(); + if (!result) return; + event = result; + } else if (event.dynamic && event.setup) { const result = event.setup(gameState); if (!result) return; // couldn't generate (e.g., no unlocked sources) event = { ...event, body: result.body, actions: result.actions }; @@ -2179,21 +2869,31 @@ function showEvent(event) { const toast = document.getElementById('event-toast'); - // Restore saved position if available - const savedPos = localStorage.getItem('quarterClose_toastPos'); - if (savedPos) { - const pos = JSON.parse(savedPos); - toast.style.left = pos.left + 'px'; - toast.style.top = pos.top + 'px'; - toast.style.transform = 'none'; + // On mobile, let CSS handle positioning; on desktop, restore saved position + if (isMobile()) { + toast.style.left = ''; + toast.style.top = ''; + toast.style.transform = ''; } else { - toast.style.left = '50%'; - toast.style.top = '50%'; - toast.style.transform = 'translate(-50%, -50%)'; + const savedPos = localStorage.getItem('quarterClose_toastPos'); + if (savedPos) { + const pos = JSON.parse(savedPos); + toast.style.left = pos.left + 'px'; + toast.style.top = pos.top + 'px'; + toast.style.transform = 'none'; + } else { + toast.style.left = '50%'; + toast.style.top = '50%'; + toast.style.transform = 'translate(-50%, -50%)'; + } } document.getElementById('toast-sender').textContent = event.sender; - document.getElementById('toast-body').textContent = event.body; + if (event.html) { + document.getElementById('toast-body').innerHTML = event.body; + } else { + document.getElementById('toast-body').textContent = event.body; + } const actionsDiv = document.getElementById('toast-actions'); actionsDiv.innerHTML = ''; @@ -2252,6 +2952,7 @@ function showEvent(event) { if (btn.disabled) return; if (eventToastTimer) { clearTimeout(eventToastTimer); eventToastTimer = null; } _eventToastActions = null; + mobileHaptic('medium'); const result = action.effect(gameState); document.getElementById('status-text').textContent = result; setTimeout(() => { @@ -2306,15 +3007,66 @@ function updateToastButtons() { } } -// ===== BOSS KEY ===== -function toggleBossMode() { - gameState.bossMode = !gameState.bossMode; - const gameVisible = !gameState.bossMode && gameState.arc; - document.getElementById('game-view').classList.toggle('hidden', !gameVisible); +// ===== DEBUG MODE — tap cash label 7× quickly ===== +let debugTapCount = 0; +let debugTapTimer = null; +let debugMode = false; + +function initDebugTap() { + const cashLabel = document.querySelector('.cash-label'); + if (!cashLabel) return; + cashLabel.style.cursor = 'default'; + cashLabel.addEventListener('click', () => { + debugTapCount++; + clearTimeout(debugTapTimer); + debugTapTimer = setTimeout(() => { debugTapCount = 0; }, 2000); + if (debugTapCount >= 7) { + debugTapCount = 0; + debugMode = !debugMode; + document.getElementById('debug-tools').classList.toggle('hidden', !debugMode); + document.getElementById('status-text').textContent = debugMode ? '🧪 Debug mode enabled' : '🧪 Debug mode disabled'; + } + }); +} + +// ===== DEBUG: TRIGGER EVENTS ===== +function toggleDebugEventDropdown() { + const dd = document.getElementById('debug-event-dropdown'); + if (!dd.classList.contains('hidden')) { + dd.classList.add('hidden'); + return; + } + dd.innerHTML = ''; + EVENTS.forEach((event) => { + const btn = document.createElement('div'); + btn.style.cssText = 'padding:5px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid #eee;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; + btn.textContent = event.debugLabel || `${event.sender || 'Dynamic'} — ${(event.subject || '(dynamic event)').replace(/[\u{1F300}-\u{1FAFF}]/gu, '').trim()}`; + btn.onmouseenter = () => btn.style.background = '#e8f0fe'; + btn.onmouseleave = () => btn.style.background = ''; + btn.onclick = () => { dd.classList.add('hidden'); showEvent(event); }; + dd.appendChild(btn); + }); + dd.classList.remove('hidden'); +} + +// Close dropdown on outside click +document.addEventListener('click', (e) => { + const dd = document.getElementById('debug-event-dropdown'); + if (dd && !dd.classList.contains('hidden') && !e.target.closest('#debug-event-dropdown') && !e.target.textContent.includes('Event ▾')) { + dd.classList.add('hidden'); + } +}); + +// ===== BOSS KEY ===== +function toggleBossMode() { + gameState.bossMode = !gameState.bossMode; + const gameVisible = !gameState.bossMode && gameState.arc; + document.getElementById('game-view').classList.toggle('hidden', !gameVisible); document.getElementById('boss-view').classList.toggle('hidden', !gameState.bossMode); if (gameState.bossMode) { document.getElementById('event-toast').classList.add('hidden'); + document.getElementById('deal-popup').classList.add('hidden'); document.getElementById('mini-task-bar').classList.add('hidden'); document.title = 'Book1 - Excel'; } else { @@ -2336,6 +3088,8 @@ function saveGame() { upgradeLevel: s.upgradeLevel, automated: s.automated, pendingCollect: s.pendingCollect, + prestigeLevel: s.prestigeLevel || 0, + breakthroughMult: s.breakthroughMult || 1, })), seriesAShown: gameState.seriesAShown, totalPlayTime: gameState.totalPlayTime, @@ -2372,6 +3126,11 @@ function saveGame() { activeCFOLevel: gameState.activeCFOLevel || 0, cfoRecords: gameState.cfoRecords || {}, revenueHistory: gameState.revenueHistory || [], + lastQuarterRE: gameState.lastQuarterRE || 0, + featureToggles: gameState.featureToggles || DEFAULT_FEATURES, + eventFreqMult: EVENT_FREQ_MULT, + overtimeClicks: gameState.overtimeClicks || 0, + focusTipShown: gameState.focusTipShown || false, savedAt: Date.now(), }; @@ -2430,18 +3189,26 @@ function loadGame() { if (gameState.retainedEarnings > 100000) gameState.retainedEarnings = 0; gameState.analystBaseline = data.analystBaseline || 1.0; if (gameState.analystBaseline > 2.5) gameState.analystBaseline = 1.5; // cap inflated saves + if (gameState.analystBaseline < 0.5) gameState.analystBaseline = 0.8; // rescue crushed saves gameState.earningsStreak = data.earningsStreak || 0; gameState.currentGuidance = data.currentGuidance || null; gameState.guidanceTarget = data.guidanceTarget || 0; gameState.lastEarningsDay = data.lastEarningsDay || 0; gameState.earningsQuarterRevenue = data.earningsQuarterRevenue || 0; gameState.ipoStockPriceStart = data.ipoStockPriceStart || 0; - gameState._earningsMultiplier = data._earningsMultiplier || 1.0; + gameState._earningsMultiplier = Math.max(0.1, data._earningsMultiplier || 1.0); + // Rescue saves where multiplier was destroyed by repeated misses + if (gameState._earningsMultiplier < 0.3) gameState._earningsMultiplier = 0.5; // Phase 2.2: Board Room gameState.boardRoomPurchases = data.boardRoomPurchases || {}; gameState.activeCFOLevel = data.activeCFOLevel || 0; gameState.cfoRecords = data.cfoRecords || {}; gameState.revenueHistory = data.revenueHistory || []; + gameState.lastQuarterRE = data.lastQuarterRE || 0; + gameState.featureToggles = data.featureToggles || { ...DEFAULT_FEATURES }; + EVENT_FREQ_MULT = data.eventFreqMult != null ? data.eventFreqMult : 1.0; + gameState.overtimeClicks = data.overtimeClicks || 0; + gameState.focusTipShown = data.focusTipShown || false; gameState.activeTab = 'operations'; // Rebuild sources for selected arc @@ -2452,6 +3219,10 @@ function loadGame() { upgradeLevel: 0, automated: false, pendingCollect: 0, + prestigeLevel: 0, + breakthroughMult: 1, + focus: 0, + lastFocusClick: 0, })); if (data.sources) { @@ -2462,6 +3233,9 @@ function loadGame() { gameState.sources[i].upgradeLevel = saved.upgradeLevel; gameState.sources[i].automated = saved.automated; gameState.sources[i].pendingCollect = saved.pendingCollect || 0; + gameState.sources[i].prestigeLevel = saved.prestigeLevel || 0; + gameState.sources[i].breakthroughMult = saved.breakthroughMult || 1; + // Focus is transient — don't load from save (resets on load) } }); } @@ -2488,10 +3262,14 @@ function loadGame() { // Show game view (skip arc select) document.getElementById('arc-select').classList.add('hidden'); document.getElementById('game-view').classList.remove('hidden'); - buildGrid(); - updateDisplay(); - updateTaxPanel(); - updateBoardRoomTab(); + try { + buildGrid(); + updateDisplay(); + updateTaxPanel(); + updateBoardRoomTab(); + } catch (renderErr) { + console.error('Render error after load (non-fatal):', renderErr); + } return true; } catch (e) { @@ -2514,6 +3292,8 @@ function resetGame() { gameState.revPenalty = null; gameState.revBonus = null; gameState.powerOutage = null; + gameState.dbOutage = null; + gameState.miniTaskBlocked = null; gameState.hireFrozen = null; gameState.taxDebts = []; gameState.quarterRevenue = 0; @@ -2545,6 +3325,11 @@ function resetGame() { gameState.activeCFOLevel = 0; gameState.cfoRecords = {}; gameState.revenueHistory = []; + gameState.lastQuarterRE = 0; + gameState.featureToggles = { ...DEFAULT_FEATURES }; + EVENT_FREQ_MULT = 1.0; + gameState.overtimeClicks = 0; + gameState.focusTipShown = false; gameState.eventCooldown = 0; gameState.miniTaskCooldown = 0; gameState.miniTaskActive = false; @@ -2585,6 +3370,8 @@ function dismissSeriesA() { let toastDragState = null; function initToastDrag() { + // No drag on mobile + if (isMobile()) return; const header = document.getElementById('toast-header'); header.style.cursor = 'move'; @@ -2617,6 +3404,38 @@ function initToastDrag() { toastDragState = null; } }); + + // Deal popup drag + const dealHeader = document.getElementById('deal-header'); + let dealDragState = null; + + dealHeader.addEventListener('mousedown', (e) => { + e.preventDefault(); + const popup = document.getElementById('deal-popup'); + const rect = popup.getBoundingClientRect(); + popup.style.transform = 'none'; + popup.style.left = rect.left + 'px'; + popup.style.top = rect.top + 'px'; + dealDragState = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top }; + }); + + document.addEventListener('mousemove', (e) => { + if (!dealDragState) return; + const dx = e.clientX - dealDragState.startX; + const dy = e.clientY - dealDragState.startY; + const popup = document.getElementById('deal-popup'); + popup.style.left = (dealDragState.origLeft + dx) + 'px'; + popup.style.top = (dealDragState.origTop + dy) + 'px'; + }); + + document.addEventListener('mouseup', () => { + if (dealDragState) { + const popup = document.getElementById('deal-popup'); + const rect = popup.getBoundingClientRect(); + localStorage.setItem('quarterClose_dealPos', JSON.stringify({ left: rect.left, top: rect.top })); + dealDragState = null; + } + }); } // ===== BOSS VIEW GENERATION ===== @@ -2703,6 +3522,267 @@ function updateAutosaveToggle() { if (el) el.classList.toggle('off', !autosaveEnabled); } +// ===== DATA MENU ===== +let dataMenuOpen = false; + +function toggleDataMenu(e) { + e.stopPropagation(); + dataMenuOpen = !dataMenuOpen; + const dropdown = document.getElementById('data-dropdown'); + dropdown.classList.toggle('open', dataMenuOpen); + closeFileMenu(); +} + +function closeDataMenu() { + dataMenuOpen = false; + const dropdown = document.getElementById('data-dropdown'); + if (dropdown) dropdown.classList.remove('open'); +} + +// ===== FEATURE TOGGLES ===== +const DEFAULT_FEATURES = { + closeTheDeals: true, + overtime: true, + managementFocus: true, +}; + +function getFeatureToggles() { + if (!gameState.featureToggles) { + gameState.featureToggles = { ...DEFAULT_FEATURES }; + } + return gameState.featureToggles; +} + +function isFeatureEnabled(key) { + return getFeatureToggles()[key] !== false; +} + +function toggleFeature(key, enabled) { + getFeatureToggles()[key] = enabled; +} + +// ===== GAME OPTIONS MODAL ===== +function showGameOptions() { + closeDataMenu(); + const toggles = getFeatureToggles(); + document.getElementById('toggle-deals').checked = toggles.closeTheDeals !== false; + document.getElementById('toggle-overtime').checked = toggles.overtime !== false; + document.getElementById('toggle-focus').checked = toggles.managementFocus !== false; + document.getElementById('event-freq-slider').value = Math.round(EVENT_FREQ_MULT * 100); + const initTag = EVENT_FREQ_MULT === 0 ? 'off' : EVENT_FREQ_MULT <= 1 ? '' : EVENT_FREQ_MULT <= 3 ? '🔥' : EVENT_FREQ_MULT <= 6 ? '💀' : '☠️'; + document.getElementById('event-freq-label').textContent = `📬 Events: ${EVENT_FREQ_MULT.toFixed(1)}× ${initTag}`; + document.getElementById('options-modal').classList.remove('hidden'); +} + +function dismissOptions() { + document.getElementById('options-modal').classList.add('hidden'); +} + +// ===== MANAGEMENT FOCUS ===== +function clickFocus(sourceIndex) { + if (!isFeatureEnabled('managementFocus')) return; + const state = gameState.sources[sourceIndex]; + if (!state || !state.unlocked || state.employees === 0) return; + + if (!state.focus) state.focus = 0; + state.focus = Math.min(10, state.focus + 1); + state.lastFocusClick = Date.now(); + + // Brief cell flash + const row = document.getElementById(`source-row-${sourceIndex}`); + if (row) { + const nameCell = row.querySelector('[data-field="name"]'); + if (nameCell) { + nameCell.style.transition = 'background 0.15s'; + nameCell.style.background = '#e8f5e9'; + setTimeout(() => { nameCell.style.background = ''; }, 300); + } + } +} + +function decayFocus() { + if (!isFeatureEnabled('managementFocus')) return; + const now = Date.now(); + for (const state of gameState.sources) { + if (!state.focus || state.focus <= 0) continue; + const elapsed = now - (state.lastFocusClick || 0); + // Lose 1 focus point every 10 seconds since last click + const shouldHave = Math.max(0, (state.focus || 0) - Math.floor(elapsed / 10000)); + if (shouldHave < state.focus) { + state.focus = shouldHave; + } + } +} + +// ===== CLOSE THE DEAL ===== +const DEAL_CLIENTS = [ + 'Acme Corp', 'Globex Industries', 'Initech', 'Umbrella Corp', 'Wayne Enterprises', + 'Stark Industries', 'Cyberdyne Systems', 'Weyland-Yutani', 'Soylent Corp', 'Oscorp', + 'Tyrell Corporation', 'Massive Dynamic', 'Pied Piper', 'Hooli', 'Vandelay Industries', +]; + +let dealTimer = null; + +function spawnDeal() { + const rev = totalRevPerTick(); + if (rev <= 0) return; + + const seconds = 30 + Math.floor(Math.random() * 31); // 30-60s of revenue + const amount = rev * seconds; + const client = DEAL_CLIENTS[Math.floor(Math.random() * DEAL_CLIENTS.length)]; + const clicksNeeded = Math.max(10, Math.min(30, 15 + Math.floor(Math.log10(Math.max(1, totalAnnualRev())) * 2))); + const timeLimit = 12000; // 12 seconds + + gameState.dealActive = { + client, + amount, + clicksNeeded, + clicksDone: 0, + timeLimit, + startedAt: Date.now(), + }; + + // Use dedicated deal popup (not the event toast) + const popup = document.getElementById('deal-popup'); + const savedPos = localStorage.getItem('quarterClose_dealPos'); + if (isMobile()) { + // Let CSS handle positioning on mobile + popup.style.left = ''; + popup.style.top = ''; + popup.style.transform = ''; + } else if (savedPos) { + const pos = JSON.parse(savedPos); + popup.style.left = pos.left + 'px'; + popup.style.top = pos.top + 'px'; + popup.style.transform = 'none'; + } else { + popup.style.left = '50%'; + popup.style.top = '50%'; + popup.style.transform = 'translate(-50%, -50%)'; + } + + document.getElementById('deal-client').textContent = client; + document.getElementById('deal-body').textContent = `Enterprise contract worth ${formatMoney(amount)}. Click to get signatures before they walk!`; + document.getElementById('deal-progress-text').textContent = `Signatures: 0 / ${clicksNeeded}`; + document.getElementById('deal-bar').style.width = '0%'; + + const timerFill = document.getElementById('deal-timer-fill'); + timerFill.style.animation = 'none'; + timerFill.offsetHeight; // force reflow + timerFill.style.animation = `toast-btn-fill ${timeLimit / 1000}s linear forwards`; + + popup.classList.remove('hidden'); + + // Timer — auto-fail + dealTimer = setTimeout(() => { + failDeal(); + }, timeLimit); +} + +function clickDeal() { + if (!gameState.dealActive) return; + const deal = gameState.dealActive; + deal.clicksDone++; + mobileHaptic('light'); + + // Update progress + const progress = document.getElementById('deal-progress-text'); + if (progress) progress.textContent = `Signatures: ${deal.clicksDone} / ${deal.clicksNeeded}`; + const bar = document.getElementById('deal-bar'); + if (bar) bar.style.width = (deal.clicksDone / deal.clicksNeeded * 100) + '%'; + + if (deal.clicksDone >= deal.clicksNeeded) { + // Success! + if (dealTimer) { clearTimeout(dealTimer); dealTimer = null; } + gameState.cash += deal.amount; + gameState.totalEarned += deal.amount; + gameState.quarterRevenue += deal.amount; + if (gameState.isPublic) gameState.earningsQuarterRevenue += deal.amount; + + document.getElementById('status-text').textContent = `🤝 Closed ${formatMoney(deal.amount)} deal with ${deal.client}!`; + setTimeout(() => { + const st = document.getElementById('status-text'); + if (st && st.textContent.startsWith('🤝')) st.textContent = 'Ready'; + }, 3000); + + gameState.dealActive = null; + document.getElementById('deal-popup').classList.add('hidden'); + updateDisplay(); + mobileHaptic('success'); + mobileCashPulse(); + } +} + +function failDeal() { + if (!gameState.dealActive) return; + const deal = gameState.dealActive; + dealTimer = null; + + document.getElementById('status-text').textContent = `😔 ${deal.client} walked — deal lost`; + setTimeout(() => { + const st = document.getElementById('status-text'); + if (st && st.textContent.startsWith('😔')) st.textContent = 'Ready'; + }, 3000); + + gameState.dealActive = null; + document.getElementById('deal-popup').classList.add('hidden'); +} + +// ===== OVERTIME ===== +function clickOvertime() { + if (!isFeatureEnabled('overtime')) return; + const rev = totalRevPerTick(); + if (rev <= 0) return; + + if (!gameState.overtimeClicks) gameState.overtimeClicks = 0; + + // Base = 5 seconds of revenue, diminishing returns + const diminish = 1 / (1 + gameState.overtimeClicks * 0.15); + const amount = rev * 5 * diminish; + + gameState.cash += amount; + gameState.totalEarned += amount; + gameState.quarterRevenue += amount; + if (gameState.isPublic) gameState.earningsQuarterRevenue += amount; + gameState.overtimeClicks++; + + // Status bar feedback + document.getElementById('status-text').textContent = `⏰ Overtime! +${formatMoney(amount)}`; + setTimeout(() => { + const st = document.getElementById('status-text'); + if (st && st.textContent.startsWith('⏰')) st.textContent = 'Ready'; + }, 2000); + + updateOvertimeRow(); + updateDisplay(); + mobileHaptic('medium'); + mobileCashPulse(); +} + +function updateOvertimeRow() { + const row = document.getElementById('overtime-row'); + if (!row) return; + + const enabled = isFeatureEnabled('overtime'); + const hasRev = totalRevPerTick() > 0; + + if (!enabled || !hasRev) { + row.style.display = 'none'; + return; + } + row.style.display = ''; + + const clicks = gameState.overtimeClicks || 0; + const rev = totalRevPerTick(); + const nextDiminish = 1 / (1 + clicks * 0.15); + const nextAmount = rev * 5 * nextDiminish; + const pct = Math.round(nextDiminish * 100); + + document.getElementById('overtime-clicks').textContent = clicks > 0 ? `${clicks} this Q` : ''; + document.getElementById('overtime-next').textContent = `+${formatMoney(nextAmount)}`; + document.getElementById('overtime-diminish').textContent = clicks > 0 ? `${pct}% efficiency` : ''; +} + function showAbout() { closeFileMenu(); document.getElementById('about-modal').classList.remove('hidden'); @@ -2721,6 +3801,16 @@ function dismissHelp() { document.getElementById('help-modal').classList.add('hidden'); } +function showHelpTab(tabId) { + document.querySelectorAll('.help-page').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active')); + document.getElementById('help-' + tabId).classList.add('active'); + // Find the tab button that matches + document.querySelectorAll('.help-tab').forEach(t => { + if (t.getAttribute('onclick').includes(tabId)) t.classList.add('active'); + }); +} + let pendingConfirmAction = null; function confirmNewGame() { @@ -2752,6 +3842,9 @@ document.addEventListener('click', (e) => { if (fileMenuOpen && !e.target.closest('#file-menu')) { closeFileMenu(); } + if (dataMenuOpen && !e.target.closest('#data-menu')) { + closeDataMenu(); + } // Formula bar: update cell reference on click const cell = e.target.closest('.cell'); @@ -2861,6 +3954,7 @@ function resetBoardRoom() { gameState.activeCFOLevel = 0; gameState.cfoRecords = {}; gameState.revenueHistory = []; + gameState.lastQuarterRE = 0; if (gameState.activeTab === 'boardroom') renderBoardRoom(); _lastTaxPanelHash = ''; updateTaxPanel(); @@ -2962,6 +4056,8 @@ function processEarnings() { } } } + // Floor analyst baseline — can't go below 0.5 + gameState.analystBaseline = Math.max(0.5, gameState.analystBaseline); // Apply stock price change via valuation manipulation // We affect the market sentiment to create the price jump @@ -2971,17 +4067,24 @@ function processEarnings() { // But we want a discrete jump, so we'll adjust a persistent earnings multiplier instead if (!gameState._earningsMultiplier) gameState._earningsMultiplier = 1.0; gameState._earningsMultiplier *= (1 + stockChange); + // Floor at 0.1 so valuation can't be completely destroyed + gameState._earningsMultiplier = Math.max(0.1, gameState._earningsMultiplier); + // Gentle mean-reversion toward 1.0 (5% per quarter) + gameState._earningsMultiplier += (1.0 - gameState._earningsMultiplier) * 0.05; const newPrice = getStockPrice(); // Reset for next quarter const oldQuarterRev = gameState.earningsQuarterRevenue; gameState.earningsQuarterRevenue = 0; + gameState.lastQuarterRE = reEarned; // Show earnings modal with next quarter guidance selection const marginPct = (margin * 100).toFixed(1); const resultEmoji = result === 'BEAT' ? '📈' : result === 'MISS' ? '📉' : '➡️'; - const streakText = gameState.earningsStreak > 1 ? `🔥 ${gameState.earningsStreak} consecutive beats` : + const streakBonus = gameState.earningsStreak >= 1 ? + Math.min(2.0, 1 + gameState.earningsStreak * 0.1) : 1; + const streakText = gameState.earningsStreak > 1 ? `🔥 ${gameState.earningsStreak} consecutive beats (${streakBonus.toFixed(1)}× RE)` : gameState.earningsStreak < -1 ? `❄️ ${Math.abs(gameState.earningsStreak)} consecutive misses` : ''; const analystText = gameState.earningsStreak >= 3 ? 'Analyst Upgrade ⬆️' : gameState.earningsStreak <= -2 ? 'Analyst Downgrade ⬇️' : ''; @@ -3040,7 +4143,18 @@ function processEarnings() { function showEarningsModal(data) { const guidanceLabel = GUIDANCE_LEVELS[data.guidanceKey].label; - const body = `Revenue: ${formatMoney(data.revenue)}\nGuidance: ${formatMoney(data.target)} (${guidanceLabel})\nResult: ${data.result} (${data.marginPct > 0 ? '+' : ''}${data.marginPct}%) ${data.resultEmoji}\n\nStock: ${data.stockChange}\nRetained Earnings: +${data.reEarned} RE${data.streakText ? '\n' + data.streakText : ''}${data.analystText ? '\n' + data.analystText : ''}\n\nSet next quarter guidance:`; + const body = `
+
Revenue${formatMoney(data.revenue)}
+
Guidance${formatMoney(data.target)} (${guidanceLabel})
+
Result${data.result} ${data.resultEmoji} (${data.marginPct > 0 ? '+' : ''}${data.marginPct}%)
+
+
Stock${data.stockChange}
+
RE Earned+${data.reEarned} RE
+ ${data.streakText ? `
Streak${data.streakText}
` : ''} + ${data.analystText ? `
Analysts${data.analystText}
` : ''} +
+
Set next quarter guidance:
+
`; // Build actions — 4 guidance buttons + close const actions = Object.entries(GUIDANCE_LEVELS).map(([key, level]) => ({ @@ -3053,7 +4167,7 @@ function showEarningsModal(data) { })); showEventToast('Investor Relations', `${data.qLabel} EARNINGS REPORT`, - body, actions, { expiresMs: 0, closable: false }); + body, actions, { expiresMs: 0, closable: false, html: true }); } function trackEarningsRevenue(amount) { @@ -3079,6 +4193,8 @@ function switchTab(tab) { const tabOps = document.getElementById('tab-operations'); const tabBR = document.getElementById('tab-board-room'); + const gridArea = document.getElementById('grid-container'); + if (tab === 'boardroom') { revenueRows.classList.add('hidden'); taxPanel.classList.add('hidden'); @@ -3086,6 +4202,7 @@ function switchTab(tab) { boardRoom.classList.remove('hidden'); tabOps.classList.remove('active'); tabBR.classList.add('active'); + gridArea.classList.add('boardroom-layout'); buildBoardRoom(); } else { revenueRows.classList.remove('hidden'); @@ -3094,6 +4211,7 @@ function switchTab(tab) { boardRoom.classList.add('hidden'); tabOps.classList.add('active'); tabBR.classList.remove('active'); + gridArea.classList.remove('boardroom-layout'); _lastTaxPanelHash = ''; // force rebuild updateTaxPanel(); buildFillerRows(); @@ -3107,6 +4225,8 @@ function updateBoardRoomTab() { } else { tabBR.classList.add('hidden'); } + // Also update mobile nav + if (typeof updateMobileNav === 'function') updateMobileNav(); } let _lastBoardRoomHash = ''; @@ -3124,7 +4244,7 @@ function buildBoardRoom() { _lastBoardRoomHash = hashParts; let html = ''; - let rowNum = 4; // starts after the header rows (1-3) + let rowNum = 3; // starts after the header rows (1-2) // Board Room header html += `
@@ -3148,11 +4268,50 @@ function buildBoardRoom() {
`; + // Group upgrades by category, sort each group by cost ascending + const categoryOrder = ['Finance', 'Revenue', 'Tax', 'Investor', 'Protection']; + const categoryLabels = { + Finance: '📊 Finance', + Revenue: '💰 Revenue', + Tax: '🏛️ Tax', + Investor: '📈 Investor Relations', + Protection: '🛡️ Protection', + }; + const grouped = {}; for (const upgrade of BOARD_ROOM_UPGRADES) { + if (!grouped[upgrade.category]) grouped[upgrade.category] = []; + grouped[upgrade.category].push(upgrade); + } + // Sort each group by effective cost + for (const cat of Object.keys(grouped)) { + grouped[cat].sort((a, b) => getUpgradeCost(a) - getUpgradeCost(b)); + } + + let totalUpgradeRows = 0; + for (const cat of categoryOrder) { + const upgrades = grouped[cat]; + if (!upgrades || upgrades.length === 0) continue; + + // Category header row + html += `
+
${rowNum++}
+
${categoryLabels[cat] || cat}
+
+
+
+
+
+
+
+
`; + totalUpgradeRows++; + + for (const upgrade of upgrades) { const owned = getBoardRoomUpgradeCount(upgrade.id); const isOwned = owned > 0 && upgrade.maxCount !== Infinity; const requiresMet = !upgrade.requires || hasBoardRoomUpgrade(upgrade.requires); - const canAfford = gameState.retainedEarnings >= upgrade.cost; + const cost = getUpgradeCost(upgrade); + const canAfford = gameState.retainedEarnings >= cost; const isLocked = !requiresMet; let statusCell = ''; @@ -3175,18 +4334,31 @@ function buildBoardRoom() { const costColor = isOwned ? '#999' : isLocked ? '#ccc' : canAfford ? '#7b1fa2' : '#c00'; + const costLabel = upgrade.maxCount === Infinity && owned > 0 + ? `${cost.toLocaleString()} RE (×${owned})` + : `${cost.toLocaleString()} RE`; + + // Dynamic description for Growth Initiative — show total bonus + let desc = upgrade.desc; + if (upgrade.id === 'growth_initiative' && owned > 0) { + const totalBonus = ((Math.pow(1.02, owned) - 1) * 100).toFixed(1); + desc = `+2% revenue per purchase (stacks). Current: +${totalBonus}% total (×${owned})`; + } + html += `
${rowNum++}
${upgrade.name}
${upgrade.category}
-
${upgrade.cost.toLocaleString()} RE
+
${costLabel}
${statusCell}
-
-
${upgrade.desc}
+
${desc}
+
`; - } + totalUpgradeRows++; + } // end upgrade loop + } // end category loop // Filler rows for the board room view const ROW_HEIGHT = 28; @@ -3198,7 +4370,7 @@ function buildBoardRoom() { const bottomChrome = (revBar ? revBar.offsetHeight : 0) + (sheetTabs ? sheetTabs.offsetHeight : 0) + (statusBar ? statusBar.offsetHeight : 0); - const usedRows = BOARD_ROOM_UPGRADES.length + 3; // header + sep + upgrades + const usedRows = totalUpgradeRows + 3; // header + sep + upgrades + category headers const available = viewportHeight - gridBottom - bottomChrome - (usedRows * ROW_HEIGHT); const fillerCount = Math.max(3, Math.ceil(available / ROW_HEIGHT) + 1); @@ -3221,9 +4393,10 @@ function purchaseBoardRoomUpgrade(id) { const owned = getBoardRoomUpgradeCount(id); if (owned >= upgrade.maxCount) return; if (upgrade.requires && !hasBoardRoomUpgrade(upgrade.requires)) return; - if (gameState.retainedEarnings < upgrade.cost) return; + const cost = getUpgradeCost(upgrade); + if (gameState.retainedEarnings < cost) return; - gameState.retainedEarnings -= upgrade.cost; + gameState.retainedEarnings -= cost; gameState.boardRoomPurchases[id] = (gameState.boardRoomPurchases[id] || 0) + 1; // Auto-activate Finance Dept when first purchased @@ -3251,9 +4424,18 @@ function init() { generateBossGrid(); initChartDrag(); initToastDrag(); + initDebugTap(); // Delegated click handler for tax panel buttons (survives innerHTML rebuilds) document.getElementById('tax-panel').addEventListener('click', (e) => { + // P&L collapse toggle + const togglePnl = e.target.closest('[data-toggle-pnl]'); + if (togglePnl) { + gameState.pnlCollapsed = !gameState.pnlCollapsed; + _lastTaxPanelHash = ''; // force rebuild + updateTaxPanel(); + return; + } const settleBtn = e.target.closest('[data-settle]'); if (settleBtn) { e.stopPropagation(); @@ -3282,7 +4464,33 @@ function init() { if (!loaded) { showArcSelect(); } - setInterval(gameTick, 1000); + + // Use mobile-aware tick if on mobile + setInterval(function() { + gameTick(); + if (typeof mobileTickUpdate === 'function') mobileTickUpdate(); + }, 1000); + + // Mobile initialization + if (isMobile()) { + if (typeof updateMobileNav === 'function') updateMobileNav(); + // Skip splash on mobile + const splash = document.getElementById('splash-screen'); + if (splash) splash.remove(); + } else { + // Splash screen animation (desktop only) + const splash = document.getElementById('splash-screen'); + if (splash) { + requestAnimationFrame(() => { + const bar = document.getElementById('splash-progress-bar'); + if (bar) bar.style.width = '100%'; + }); + setTimeout(() => { + splash.classList.add('fade-out'); + setTimeout(() => splash.remove(), 500); + }, 2500); + } + } } // Expose for inline onclick @@ -3535,6 +4743,12 @@ function drawValuationChart() { function formatCompact(n) { const sign = n < 0 ? '-' : ''; const a = Math.abs(n); + if (a >= 1e33) return sign + '$' + (a / 1e33).toFixed(1) + 'Dc'; + if (a >= 1e30) return sign + '$' + (a / 1e30).toFixed(1) + 'No'; + if (a >= 1e27) return sign + '$' + (a / 1e27).toFixed(1) + 'Oc'; + if (a >= 1e24) return sign + '$' + (a / 1e24).toFixed(1) + 'Sp'; + if (a >= 1e21) return sign + '$' + (a / 1e21).toFixed(1) + 'Sx'; + if (a >= 1e18) return sign + '$' + (a / 1e18).toFixed(1) + 'Qi'; if (a >= 1e15) return sign + '$' + (a / 1e15).toFixed(1) + 'Q'; if (a >= 1e12) return sign + '$' + (a / 1e12).toFixed(1) + 'T'; if (a >= 1e9) return sign + '$' + (a / 1e9).toFixed(1) + 'B'; @@ -3543,11 +4757,20 @@ function formatCompact(n) { return sign + '$' + Math.floor(a); } +function setEventFreqMult(val) { + EVENT_FREQ_MULT = parseInt(val) / 100; + const tag = EVENT_FREQ_MULT === 0 ? 'off' : EVENT_FREQ_MULT <= 1 ? '' : EVENT_FREQ_MULT <= 3 ? '🔥' : EVENT_FREQ_MULT <= 6 ? '💀' : '☠️'; + document.getElementById('event-freq-label').textContent = `📬 Events: ${EVENT_FREQ_MULT.toFixed(1)}× ${tag}`; + saveGame(); +} + +window.setEventFreqMult = setEventFreqMult; window.unlockSource = unlockSource; window.hireEmployee = hireEmployee; window.upgradeSource = upgradeSource; window.automateSource = automateSource; window.collectSource = collectSource; +window.restructureSource = restructureSource; window.dismissEvent = dismissEvent; window.dismissOffline = dismissOffline; window.dismissSeriesA = dismissSeriesA; @@ -3565,6 +4788,682 @@ window.dismissConfirm = dismissConfirm; window.showHelp = showHelp; window.dismissHelp = dismissHelp; window.switchTab = switchTab; +// Additional window exposures for mobile and new features +window.toggleBossMode = toggleBossMode; +window.showGameOptions = showGameOptions; +window.dismissOptions = dismissOptions; +window.toggleFeature = toggleFeature; +window.hireMax = hireMax; +window.upgradeMax = upgradeMax; +window.settleTaxDebt = settleTaxDebt; +window.settleAllTax = settleAllTax; +window.triggerIRS = triggerIRS; +window.triggerBonus = triggerBonus; +window.clickDeal = clickDeal; +window.clickOvertime = clickOvertime; +window.showHelpTab = showHelpTab; +window.toggleDataMenu = toggleDataMenu; +window.saveGame = saveGame; +window.toggleDebugEventDropdown = toggleDebugEventDropdown; + +// ============================================ +// MOBILE SUPPORT +// ============================================ + +function isMobile() { + return window.matchMedia('(max-width: 600px)').matches; +} + +// Haptic feedback for mobile — light tap feel +function mobileHaptic(style) { + if (!isMobile() || !navigator.vibrate) return; + switch (style) { + case 'light': navigator.vibrate(10); break; + case 'medium': navigator.vibrate(20); break; + case 'heavy': navigator.vibrate([15, 30, 15]); break; + case 'success': navigator.vibrate([10, 40, 20]); break; + case 'error': navigator.vibrate([30, 20, 30]); break; + default: navigator.vibrate(10); + } +} + +// Cash header pulse animation on collection +function mobileCashPulse() { + if (!isMobile()) return; + const header = document.getElementById('row-cash'); + if (!header) return; + header.classList.remove('cash-pulse'); + void header.offsetWidth; // force reflow + header.classList.add('cash-pulse'); +} + +// Mark collect buttons that have pending revenue +function mobileUpdateCollectHighlights() { + if (!isMobile() || !gameState.arc) return; + document.querySelectorAll('.cell-btn.btn-collect').forEach(btn => { + // Collect buttons show "💰 Collect" (no pending) or "💰 Collect $X" (has pending) + const text = btn.textContent; + const hasPending = text.includes('$') && !text.includes('$0.00'); + btn.classList.toggle('has-pending', hasPending); + }); +} + +let _mobileActiveTab = 'operations'; +let _lastMobilePnlHash = ''; +let _lastMobileBRHash = ''; + +function mobileSwitchTab(tab) { + _mobileActiveTab = tab; + + // Update nav buttons + document.querySelectorAll('.mob-nav-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tab); + }); + + // Show/hide views + const gridContainer = document.getElementById('grid-container'); + const pnlView = document.getElementById('mobile-pnl-view'); + const brView = document.getElementById('mobile-boardroom-view'); + const settingsView = document.getElementById('mobile-settings-view'); + const cashHeader = document.getElementById('mobile-cash-header'); + + // Hide all + gridContainer.style.display = 'none'; + pnlView.classList.add('hidden'); + brView.classList.add('hidden'); + settingsView.classList.add('hidden'); + + // Show mini cash header on non-ops tabs + if (tab !== 'operations' && cashHeader) { + cashHeader.classList.add('visible'); + updateMobileCashHeader(); + } else if (cashHeader) { + cashHeader.classList.remove('visible'); + } + + // Re-trigger tab fade animation + const retrigger = (el) => { el.style.animation = 'none'; el.offsetHeight; el.style.animation = ''; }; + + switch (tab) { + case 'operations': + gridContainer.style.display = ''; + retrigger(gridContainer); + gridContainer.scrollTo({ top: 0, behavior: 'smooth' }); + if (gameState.activeTab !== 'operations') { + gameState.activeTab = 'operations'; + } + break; + case 'pnl': + pnlView.classList.remove('hidden'); + retrigger(pnlView); + pnlView.scrollTo({ top: 0 }); + _lastMobilePnlHash = ''; + buildMobilePnL(); + break; + case 'boardroom': + brView.classList.remove('hidden'); + retrigger(brView); + brView.scrollTo({ top: 0 }); + _lastMobileBRHash = ''; + buildMobileBoardRoom(); + break; + case 'settings': + settingsView.classList.remove('hidden'); + retrigger(settingsView); + settingsView.scrollTo({ top: 0 }); + updateMobileSettings(); + break; + } + mobileHaptic('light'); +} +window.mobileSwitchTab = mobileSwitchTab; + +// ===== SWIPE GESTURE SUPPORT ===== +(function initMobileSwipe() { + if (typeof window === 'undefined') return; + const MOB_TABS = ['operations', 'pnl', 'boardroom', 'settings']; + let _swipeStartX = 0, _swipeStartY = 0, _swiping = false; + const MIN_SWIPE = 60, MAX_Y_DRIFT = 80; + + function getVisibleTabs() { + return MOB_TABS.filter(t => { + if (t === 'boardroom') { + const btn = document.getElementById('mob-nav-boardroom'); + return btn && !btn.classList.contains('hidden'); + } + return true; + }); + } + + document.addEventListener('touchstart', function(e) { + if (!isMobile()) return; + _swipeStartX = e.touches[0].clientX; + _swipeStartY = e.touches[0].clientY; + _swiping = true; + }, { passive: true }); + + document.addEventListener('touchend', function(e) { + if (!_swiping || !isMobile()) return; + _swiping = false; + const dx = e.changedTouches[0].clientX - _swipeStartX; + const dy = Math.abs(e.changedTouches[0].clientY - _swipeStartY); + if (Math.abs(dx) < MIN_SWIPE || dy > MAX_Y_DRIFT) return; + + const tabs = getVisibleTabs(); + const curIdx = tabs.indexOf(_mobileActiveTab); + if (curIdx < 0) return; + + if (dx < 0 && curIdx < tabs.length - 1) { + mobileSwitchTab(tabs[curIdx + 1]); + } else if (dx > 0 && curIdx > 0) { + mobileSwitchTab(tabs[curIdx - 1]); + } + }, { passive: true }); +})(); + +function updateMobileNav() { + if (!isMobile()) return; + const brBtn = document.getElementById('mob-nav-boardroom'); + if (brBtn) { + if (gameState.isPublic) { + brBtn.classList.remove('hidden'); + } else { + brBtn.classList.add('hidden'); + } + } + + // Show tax alert badge on P&L tab when debts exist + const pnlBtn = document.querySelector('.mob-nav-btn[data-tab="pnl"]'); + if (pnlBtn) { + const hasTaxDebt = gameState.taxDebts && gameState.taxDebts.length > 0; + let badge = pnlBtn.querySelector('.mob-nav-badge'); + if (hasTaxDebt && !badge) { + badge = document.createElement('span'); + badge.className = 'mob-nav-badge'; + badge.textContent = '!'; + pnlBtn.appendChild(badge); + } else if (!hasTaxDebt && badge) { + badge.remove(); + } + } +} + +function updateMobileCashHeader() { + const amountEl = document.getElementById('mob-cash-amount'); + const perdayEl = document.getElementById('mob-cash-perday'); + const revyrEl = document.getElementById('mob-cash-revyr'); + const stockEl = document.getElementById('mob-cash-stock'); + if (!amountEl || !perdayEl || !gameState.arc) return; + amountEl.textContent = formatMoney(gameState.cash); + + // Show penalty/bonus indicators on mobile cash header + const perTick = totalRevPerTick(); + const hasOutage = gameState.powerOutage && Date.now() < gameState.powerOutage.until; + const hasPenalty = gameState.revPenalty && Date.now() < gameState.revPenalty.until; + const hasBonus = gameState.revBonus && Date.now() < gameState.revBonus.until; + const hasDbOut = gameState.dbOutage && Date.now() < gameState.dbOutage.until; + + if (hasOutage) { + perdayEl.innerHTML = `⚡ $0.00/day`; + } else if (hasPenalty) { + const pct = Math.round((1 - gameState.revPenalty.mult) * 100); + perdayEl.innerHTML = `${formatPerTick(perTick)}/day ▼${pct}%`; + } else if (hasDbOut) { + perdayEl.innerHTML = `${formatPerTick(perTick)}/day 💾`; + } else if (hasBonus) { + perdayEl.innerHTML = `${formatPerTick(perTick)}/day ▲×${gameState.revBonus.mult}`; + } else { + perdayEl.textContent = formatPerTick(perTick) + '/day'; + } + + if (revyrEl) { + revyrEl.textContent = formatRate(totalAnnualRev()); + } + if (stockEl) { + if (gameState.isPublic) { + stockEl.textContent = '📈 ' + formatMoney(getStockPrice()); + } else { + stockEl.textContent = ''; + } + } +} + +function updateMobileSettings() { + const statsEl = document.getElementById('mob-stats'); + if (!statsEl || !gameState.arc) return; + + const totalRev = totalAnnualRev(); + const perTick = totalRevPerTick(); + + statsEl.innerHTML = ` +
Game Date: ${document.getElementById('game-date').textContent}
+
Revenue/sec: ${formatStatMoney(totalRev / SECS_PER_YEAR)}/s
+
Revenue/day: ${formatStatMoney(perTick)}/d
+
Revenue/year: ${formatRate(totalRev)}
+
Total Earned: ${formatMoney(gameState.totalEarned)}
+
Clicks: ${gameState.totalClicks.toLocaleString()}
+
Play Time: ${formatDuration(gameState.totalPlayTime)}
+ ${gameState.isPublic ? `
Stock Price: ${formatMoney(getStockPrice())}
+
Valuation: ${formatCompact(getCompanyValuation())}
+
Retained Earnings: ${gameState.retainedEarnings.toLocaleString()} RE
` : ''} + `; + + // Active effects + const now = Date.now(); + const effects = []; + if (gameState.powerOutage && now < gameState.powerOutage.until) { + const secs = Math.ceil((gameState.powerOutage.until - now) / 1000); + effects.push(`⚡ Power Outage (${secs}s)`); + } + if (gameState.revPenalty && now < gameState.revPenalty.until) { + const secs = Math.ceil((gameState.revPenalty.until - now) / 1000); + const pct = Math.round((1 - gameState.revPenalty.mult) * 100); + effects.push(`📉 Revenue -${pct}% (${secs}s)`); + } + if (gameState.revBonus && now < gameState.revBonus.until) { + const secs = Math.ceil((gameState.revBonus.until - now) / 1000); + effects.push(`📈 Revenue ×${gameState.revBonus.mult} (${secs}s)`); + } + if (gameState.dbOutage && now < gameState.dbOutage.until) { + const secs = Math.ceil((gameState.dbOutage.until - now) / 1000); + effects.push(`💾 DB Outage (${secs}s)`); + } + if (gameState.hireFrozen && now < gameState.hireFrozen) { + const secs = Math.ceil((gameState.hireFrozen - now) / 1000); + effects.push(`🚫 Hiring Frozen (${secs}s)`); + } + const garnishActive = gameState.taxDebts && gameState.taxDebts.some(d => d.stage === 'garnish'); + if (garnishActive) effects.push('🔴 IRS Garnishment (-15%)'); + + if (effects.length > 0) { + statsEl.innerHTML += `
Active Effects:
`; + effects.forEach(e => { statsEl.innerHTML += `
${e}
`; }); + } + + // Update autosave button text + const btn = document.getElementById('mob-autosave-btn'); + if (btn) { + btn.textContent = `Auto-save: ${autosaveEnabled ? 'ON' : 'OFF'}`; + } +} + +function toggleAutosaveMobile() { + autosaveEnabled = !autosaveEnabled; + if (autosaveEnabled) { + autosaveInterval = setInterval(() => { saveGame(); }, 30000); + document.getElementById('status-text').textContent = 'Auto-save enabled'; + } else { + clearInterval(autosaveInterval); + document.getElementById('status-text').textContent = 'Auto-save disabled'; + } + updateMobileSettings(); + setTimeout(() => { document.getElementById('status-text').textContent = 'Ready'; }, 2000); +} +window.toggleAutosaveMobile = toggleAutosaveMobile; + +// ===== MOBILE P&L RENDERING ===== +function buildMobilePnL() { + if (!isMobile() || !gameState.arc) return; + const container = document.getElementById('mobile-pnl-content'); + if (!container) return; + + const currentDay = Math.floor(gameState.gameElapsedSecs / SECS_PER_DAY); + const daysIntoQuarter = currentDay - gameState.lastQuarterDay; + const daysToTax = Math.max(0, 90 - daysIntoQuarter); + const totalExpenses = gameState.totalSpentHires + gameState.totalSpentUpgrades + gameState.totalSpentAuto; + const qDepreciation = getQuarterlyDepreciation(); + const qTaxable = Math.max(0, gameState.quarterRevenue - qDepreciation); + const currentTaxRate = getBoardRoomTaxRate(); + const currentAMTRate = getBoardRoomAMTRate(); + const qRegularTax = Math.floor(qTaxable * currentTaxRate); + const qAMT = Math.floor(gameState.quarterRevenue * currentAMTRate); + const amtApplies = qAMT > qRegularTax && gameState.quarterRevenue > 0; + const qNet = gameState.quarterRevenue - gameState.quarterExpenses - gameState.quarterTaxPaid; + const ltNet = gameState.totalEarned - totalExpenses - gameState.totalTaxPaid; + const garnishActive = gameState.taxDebts && gameState.taxDebts.some(d => d.stage === 'garnish'); + + // Hash for change detection + const hashParts = [ + gameState.quarterRevenue|0, gameState.totalEarned|0, + gameState.quarterExpenses|0, gameState.quarterTaxPaid|0, + daysToTax, + gameState.taxDebts ? gameState.taxDebts.map(d => `${d.current|0}:${d.stage}`).join(',') : '', + gameState.isPublic ? 1 : 0, + gameState.earningsQuarterRevenue|0, + gameState.retainedEarnings|0, + gameState.earningsStreak|0, + gameState.currentGuidance || '', + gameState.activeCFOLevel, + gameState.cash|0, + ].join('|'); + + if (hashParts === _lastMobilePnlHash) return; + _lastMobilePnlHash = hashParts; + + let html = ''; + + // P&L Card + html += `
+
+ 📊 Profit & Loss + Tax in ${daysToTax}d +
+
+ Revenue (Qtr) + ${formatMoney(gameState.quarterRevenue)} +
+
+ Revenue (Lifetime) + ${formatMoney(gameState.totalEarned)} +
+ ${garnishActive ? `
+ 🔴 IRS Garnishment + −15% +
` : ''} +
+ Capital Spending (Qtr) + (${formatMoney(gameState.quarterExpenses)}) +
+
+ Depreciation + (${formatMoney(qDepreciation)}) +
+
+ Taxes Paid (Qtr) + (${formatMoney(gameState.quarterTaxPaid)}) +
+
+ ${amtApplies ? '⚠️ AMT Taxable' : 'Taxable Income'} + ${formatMoney(amtApplies ? gameState.quarterRevenue : qTaxable)} +
+
+ Net Income (Qtr) + ${qNet < 0 ? '(' + formatMoney(-qNet) + ')' : formatMoney(qNet)} +
+
+ Net Income (Lifetime) + ${ltNet < 0 ? '(' + formatMoney(-ltNet) + ')' : formatMoney(ltNet)} +
+
`; + + // Investor Relations Card (if public) + if (gameState.isPublic) { + const earningsDaysSince = currentDay - gameState.lastEarningsDay; + const earningsDaysLeft = Math.max(0, EARNINGS_QUARTER_DAYS - earningsDaysSince); + const earningsQLabel = getEarningsQuarterLabel(); + const stockPrice = getStockPrice(); + const stockPriceStart = gameState.ipoStockPriceStart || stockPrice; + const qtdChange = stockPriceStart > 0 ? ((stockPrice - stockPriceStart) / stockPriceStart * 100) : 0; + const qtdColor = qtdChange >= 0 ? '#217346' : '#c00'; + const qtdStr = (qtdChange >= 0 ? '+' : '') + qtdChange.toFixed(1) + '%'; + const guidanceKey = gameState.currentGuidance || 'in-line'; + const guidanceLevel = GUIDANCE_LEVELS[guidanceKey]; + const target = gameState.guidanceTarget; + const qRev = gameState.earningsQuarterRevenue; + const daysElapsed = earningsDaysSince || 1; + const expectedAtThisPoint = target > 0 ? (target * daysElapsed / EARNINGS_QUARTER_DAYS) : 0; + const onTrack = qRev >= expectedAtThisPoint; + const trackColor = onTrack ? '#217346' : '#c00'; + const trackLabel = onTrack ? '✅ On Track' : '⚠️ Off Track'; + const streakVal = gameState.earningsStreak; + const streakStr = streakVal > 0 ? `🔥 ${streakVal} beat${streakVal > 1 ? 's' : ''}` : + streakVal < 0 ? `❄️ ${Math.abs(streakVal)} miss${Math.abs(streakVal) > 1 ? 'es' : ''}` : '—'; + + html += `
+
+ 📈 Investor Relations + Earnings in ${earningsDaysLeft}d +
+
+ Quarter + ${earningsQLabel} +
+
+ Stock Price + ${formatMoney(stockPrice)} ${qtdStr} +
+
+ Revenue vs Target + ${trackLabel} +
+
+ Progress + ${formatCompact(qRev)} / ${formatCompact(target)} +
+
+ Guidance + ${guidanceLevel.emoji} ${guidanceLevel.label} (${guidanceLevel.reMult}× RE) +
+
+ Streak + ${streakStr} +
+
+ Analyst Expectation + ${(gameState.analystBaseline).toFixed(2)}× +
+
+ Retained Earnings + ${gameState.retainedEarnings.toLocaleString()} RE +
`; + + // CFO selector + const maxCFOLevel = getFinanceDeptLevel(); + if (maxCFOLevel > 0) { + const activeCFO = gameState.activeCFOLevel; + const labels = { 0: 'Manual', 1: '👶 Intern', 2: '📊 CFO', 3: '🎩 Elite' }; + let cfoHtml = '
'; + for (let lvl = 0; lvl <= maxCFOLevel; lvl++) { + cfoHtml += ``; + } + cfoHtml += '
'; + const record = gameState.cfoRecords[activeCFO]; + const recordStr = activeCFO > 0 && record && record.total > 0 + ? `Record: ${record.beats}/${record.total} (${Math.round(record.beats / record.total * 100)}%)` + : ''; + html += `
Finance Dept${recordStr ? ' — ' + recordStr : ''}
${cfoHtml}`; + } + + html += '
'; + } + + // Tax Debts + if (gameState.taxDebts && gameState.taxDebts.length > 0) { + const stageLabels = { + notice1: '1st Notice', + notice2: '⚠ 2nd Notice', + garnish: '🔴 Garnishment', + seizure: '🚨 Seizure', + }; + + html += `
+
🏛️ Tax Liability
`; + + for (let i = 0; i < gameState.taxDebts.length; i++) { + const d = gameState.taxDebts[i]; + const interest = d.current - d.original; + const qLabel = d.quarter || ''; + + // Next escalation countdown (matching desktop updateTaxPanel) + const daysToNext = d.stage === 'notice1' ? (30 - d.daysOverdue) : + d.stage === 'notice2' ? (90 - d.daysOverdue) : + d.stage === 'garnish' ? (180 - d.daysOverdue) : 0; + const nextLabel = d.stage === 'notice1' ? '2nd Notice' : + d.stage === 'notice2' ? 'Garnish' : + d.stage === 'garnish' ? 'Seizure' : '—'; + const nextText = daysToNext > 0 ? `${nextLabel} in ${daysToNext}d` : ''; + + html += `
+
${qLabel} Assessment
+
+ Original + ${formatMoney(d.original)} +
+
+ Interest + ${formatMoney(interest)} +
+
+ Total Due + ${formatMoney(d.current)} +
+
+ Status + ${stageLabels[d.stage]} (${d.daysOverdue}d) +
+ ${nextText ? `
+ Next Escalation + ⏳ ${nextText} +
` : ''} + +
`; + } + + if (gameState.taxDebts.length > 1) { + const total = totalTaxOwed(); + html += ``; + } + + html += '
'; + } + + container.innerHTML = html; +} + +// ===== MOBILE BOARD ROOM RENDERING ===== +function buildMobileBoardRoom() { + if (!isMobile() || !gameState.arc) return; + const container = document.getElementById('mobile-boardroom-content'); + if (!container) return; + + const hashParts = [ + gameState.retainedEarnings, + JSON.stringify(gameState.boardRoomPurchases), + gameState.cash|0, + ].join('|'); + if (hashParts === _lastMobileBRHash && container.innerHTML !== '') return; + _lastMobileBRHash = hashParts; + + let html = ''; + + // RE Balance header + html += `
+
+ 🏢 Board Room + ${gameState.retainedEarnings.toLocaleString()} RE +
+
`; + + // Group upgrades by category (matching desktop buildBoardRoom) + const categoryOrder = ['Finance', 'Revenue', 'Tax', 'Investor', 'Protection']; + const categoryLabels = { + Finance: '📊 Finance', + Revenue: '💰 Revenue', + Tax: '🏛️ Tax', + Investor: '📈 Investor Relations', + Protection: '🛡️ Protection', + }; + const grouped = {}; + for (const upgrade of BOARD_ROOM_UPGRADES) { + if (!grouped[upgrade.category]) grouped[upgrade.category] = []; + grouped[upgrade.category].push(upgrade); + } + for (const cat of Object.keys(grouped)) { + grouped[cat].sort((a, b) => getUpgradeCost(a) - getUpgradeCost(b)); + } + + for (const cat of categoryOrder) { + const upgrades = grouped[cat]; + if (!upgrades || upgrades.length === 0) continue; + + // Category header + html += `
${categoryLabels[cat] || cat}
`; + + for (const upgrade of upgrades) { + const owned = getBoardRoomUpgradeCount(upgrade.id); + const isOwned = owned > 0 && upgrade.maxCount !== Infinity; + const requiresMet = !upgrade.requires || hasBoardRoomUpgrade(upgrade.requires); + const cost = getUpgradeCost(upgrade); + const canAfford = gameState.retainedEarnings >= cost; + const isLocked = !requiresMet; + + let cardClass = 'mob-br-card'; + if (isOwned) cardClass += ' owned'; + if (isLocked) cardClass += ' locked'; + + let statusHtml = ''; + if (isOwned) { + const label = upgrade.maxCount === 1 ? '✅ Owned' : `✅ Owned (×${owned})`; + statusHtml = `${label}`; + } else if (isLocked) { + const reqUpgrade = BOARD_ROOM_UPGRADES.find(u => u.id === upgrade.requires); + const reqName = reqUpgrade ? reqUpgrade.name : upgrade.requires; + statusHtml = `🔒 Requires ${reqName}`; + } else { + const countLabel = upgrade.maxCount === Infinity && owned > 0 ? ` (have ${owned})` : ''; + statusHtml = ``; + } + + // Dynamic description for Growth Initiative — show total bonus + let desc = upgrade.desc; + if (upgrade.id === 'growth_initiative' && owned > 0) { + const totalBonus = ((Math.pow(1.02, owned) - 1) * 100).toFixed(1); + desc = `+2% revenue per purchase (stacks). Current: +${totalBonus}% total (×${owned})`; + } + + const costLabel = upgrade.maxCount === Infinity && owned > 0 + ? `${cost.toLocaleString()} RE (×${owned})` + : `${cost.toLocaleString()} RE`; + + html += `
+
${upgrade.name}
+
${desc}
+ +
`; + } // end upgrade loop + } // end category loop + + container.innerHTML = html; + + // Re-attach buy button handlers via delegation + container.onclick = (e) => { + const buyBtn = e.target.closest('[data-buy-upgrade]'); + if (buyBtn) { + e.stopPropagation(); + purchaseBoardRoomUpgrade(buyBtn.dataset.buyUpgrade); + _lastMobileBRHash = ''; // force rebuild + buildMobileBoardRoom(); + } + }; +} + +// ===== MOBILE TICK UPDATE ===== +function mobileTickUpdate() { + if (!isMobile()) return; + + updateMobileNav(); + mobileUpdateCollectHighlights(); + + // Always update cash header if visible + if (_mobileActiveTab !== 'operations') { + updateMobileCashHeader(); + } + + // Update active mobile tab content + if (_mobileActiveTab === 'pnl') { + buildMobilePnL(); + } else if (_mobileActiveTab === 'boardroom') { + buildMobileBoardRoom(); + } else if (_mobileActiveTab === 'settings') { + updateMobileSettings(); + } +} document.addEventListener('DOMContentLoaded', init); -window.addEventListener('resize', () => { if (gridBuilt) buildFillerRows(); }); +window.addEventListener('resize', () => { + if (gridBuilt) buildFillerRows(); + if (typeof updateMobileNav === 'function') updateMobileNav(); +}); diff --git a/index.html b/index.html index 5eaea8d..89e68a3 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + @@ -20,9 +20,46 @@ Q4 Financials - Operations.xlsx - Quarter Close - + + +
+
+
+
+ + + + + +
+
v0.2.0
+
+
+ + + + + + + + + + + + Q4 Financials.xlsx +
+

Quarter Close

+

LOOK BUSY. GET RICH.

+
+
+
+
+

An idle tycoon game disguised as a spreadsheet

+
+
+ -
+ +
1
+
💰 Cash
+
$0.00
+
$0.00/day
+
+
+
Total Rev/yr:
+
$0.00
+
+
+ + +
+
2
DEPARTMENT
Staff
Rev/day
-
-
-
+
Hire
+
Upgrade
+
Actions
Rev/yr
- -
-
2
-
Total Cash
-
-
-
$0.00
-
$0.00/day
-
Total Rev/yr:
-
$0.00
-
-
- - -
-
3
-
-
-
-
-
-
-
-
-
-
@@ -204,7 +233,7 @@ - +
@@ -217,6 +246,16 @@
+ +
+ + + + + + +
+
@@ -239,6 +278,55 @@ 100%
+ + + + + + + + + + + +
+ + + + +
@@ -251,7 +339,7 @@ Book1 - Excel
- +
@@ -297,6 +385,18 @@
+ + +