Compare commits

...

12 Commits

Author SHA1 Message Date
Timmy
3dc8fc742b feat: add research grant investment engine (#8)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 16s
Smoke Test / smoke (pull_request) Failing after 23s
2026-04-14 22:17:51 -04:00
729343e503 Fix #137: Unbuilding defer cooldown persists across save/load (#143)
Some checks failed
Smoke Test / smoke (push) Failing after 9s
Merge PR #143 (squash)
2026-04-14 22:10:06 +00:00
1081b9e6c4 Merge pull request 'ci: re-trigger smoke test (clearing stale run #213)' (#115) from ci/retrigger-smoke into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
ci: re-trigger smoke test
2026-04-13 19:09:36 +00:00
Alexander Whitestone
e74f956bf4 ci: re-trigger smoke test (stale run #213 from before PR #106 merge)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 3s
Smoke Test / smoke (pull_request) Failing after 5s
2026-04-13 15:08:54 -04:00
55f280d056 Merge pull request 'burn: fix null ref in renderResources and add tutorial dialog a11y' (#114) from burn/20260413-0400-qa-remaining-fixes into main
Some checks failed
Smoke Test / smoke (push) Failing after 4s
merge reviewed bugfix
2026-04-13 09:43:52 +00:00
Alexander Whitestone
6446ecb43a burn: fix null ref in renderResources and add tutorial dialog a11y
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 3s
Smoke Test / smoke (pull_request) Failing after 4s
BUG-08: Add null check on closest('.res') in renderResources to
prevent TypeError if DOM structure is unexpected.

BUG-11: Add role='dialog', aria-modal='true', aria-label='Tutorial'
to tutorial overlay. Add aria-label to Skip and Next buttons for
screen reader accessibility.

Smoke test: all 19 checks passed.
2026-04-13 04:37:08 -04:00
0a312b111d Merge pull request 'fix: add missing CSS for resource counter pulse/shake animations' (#113) from fix/resource-counter-animations into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
2026-04-13 08:30:27 +00:00
Alexander Whitestone
141b240d69 fix: add missing CSS for resource counter pulse/shake animations
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 2s
Smoke Test / smoke (pull_request) Failing after 3s
Fixes part of #57 (Night of Polish — Task 1: Visual Identity).

_animRes() in engine.js already adds .pulse/.shake classes to
resource counters on value change, but the CSS animations were
missing. This adds:

- @keyframes res-pulse (scale up + green flash on gain)
- @keyframes res-shake (horizontal shake + red flash on loss)
- Scoped .res .pulse and .res .shake classes (0.35s ease-out)

Scoped under .res to avoid conflict with existing .main-btn.pulse.
2026-04-13 04:29:29 -04:00
093f7688bd Merge pull request 'fix: add missing phase-transition overlay element (closes #101)' (#108) from fix/phase-transition-overlay into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
2026-04-13 08:18:09 +00:00
c4a31255a4 fix: repair CI workflows after game.js removal (#106)
Some checks failed
Smoke Test / smoke (push) Failing after 4s
Merged PR #106 — fixes both a11y.yml and smoke.yml after game.js removal.

Closes #100
Closes #104 (duplicate)
2026-04-13 08:14:25 +00:00
Timmy
c876a35dc0 fix: add missing phase-transition overlay element (closes #101)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 3s
Smoke Test / smoke (pull_request) Failing after 4s
BUG-07: showPhaseTransition() looks for #phase-transition but the element
didn't exist in index.html. Added the overlay div with .pt-phase, .pt-name,
and .pt-desc children matching what the engine expects.

Note: BUG-06 (toast text) and BUG-09 (mute/contrast buttons) were already
fixed on main in prior commits.
2026-04-13 03:51:20 -04:00
Alexander Whitestone
3d851a8708 fix: repair CI workflows after game.js removal (#100)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 3s
Smoke Test / smoke (pull_request) Failing after 4s
- a11y.yml: validate ARIA attributes in js/*.js instead of deleted game.js
- a11y.yml: syntax-check all js/*.js files instead of single game.js
- a11y.yml: drop aria-valuenow check (not used in current codebase)
- smoke.yml: exclude guardrails scripts from secret scan (self-referential false positive)
2026-04-13 03:43:59 -04:00
12 changed files with 1913 additions and 19 deletions

1
.ci-trigger Normal file
View File

@@ -0,0 +1 @@
# Trivial file to re-trigger CI after stale run

View File

@@ -10,12 +10,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Validate ARIA Attributes in game.js
- name: Validate ARIA Attributes in JS
run: |
echo "Checking game.js for ARIA attributes..."
grep -q "aria-label" game.js || (echo "ERROR: aria-label missing from game.js" && exit 1)
grep -q "aria-valuenow" game.js || (echo "ERROR: aria-valuenow missing from game.js" && exit 1)
grep -q "aria-pressed" game.js || (echo "ERROR: aria-pressed missing from game.js" && exit 1)
echo "Checking js/*.js for ARIA attributes..."
grep -rq "aria-label" js/ || (echo "ERROR: aria-label missing from js/" && exit 1)
grep -rq "aria-pressed" js/ || (echo "ERROR: aria-pressed missing from js/" && exit 1)
- name: Validate ARIA Roles in index.html
run: |
@@ -24,4 +23,7 @@ jobs:
- name: Syntax Check JS
run: |
node -c game.js
for f in js/*.js; do
echo "Syntax check: $f"
node -c "$f" || exit 1
done

View File

@@ -8,6 +8,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
@@ -20,5 +23,9 @@ jobs:
echo "PASS: All files parse"
- name: Secret scan
run: |
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v '.gitea' | grep -v 'guardrails'; then exit 1; fi
echo "PASS: No secrets"
- name: Node tests
run: |
node --test tests/*.cjs
echo "PASS: Node tests"

View File

@@ -59,6 +59,10 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
.ops-btn{background:#1a1a2a;border:1px solid var(--purple);color:var(--purple);font-size:10px;padding:6px 10px;border-radius:4px;cursor:pointer;font-family:inherit;transition:all 0.15s}
.ops-btn:hover:not(:disabled){background:#2a2a3a;border-color:var(--gold)}
.ops-btn:disabled{opacity:0.3;cursor:not-allowed}
@keyframes res-pulse{0%{transform:scale(1);color:inherit}50%{transform:scale(1.18);color:#4caf50}100%{transform:scale(1);color:inherit}}
@keyframes res-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px);color:#f44336}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(2px)}}
.res .pulse{animation:res-pulse 0.35s ease-out}
.res .shake{animation:res-shake 0.35s ease-out}
.build-btn{display:block;width:100%;text-align:left;padding:6px 10px;margin-bottom:4px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:10px;background:#0c0c18;border:1px solid var(--border);color:var(--text);transition:all 0.15s}
.build-btn.can-buy{border-color:#2a3a4a;background:#0e1420}
.build-btn.can-buy:hover{border-color:var(--accent);box-shadow:0 0 8px var(--glow)}
@@ -86,6 +90,8 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
#drift-ending .ending-quote{color:var(--dim);font-style:italic;font-size:11px;border-left:2px solid #f44336;padding-left:12px;margin:20px 0;text-align:left}
#drift-ending button{margin-top:20px;background:#1a0808;border:1px solid #f44336;color:#f44336;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px}
#drift-ending button:hover{background:#2a1010}
#phase-transition{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.95);z-index:95;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;pointer-events:none}
#phase-transition.active{display:flex}
#toast-container{position:fixed;top:16px;right:16px;z-index:200;display:flex;flex-direction:column;gap:6px;pointer-events:none;max-width:320px}
.toast{pointer-events:auto;padding:8px 14px;border-radius:6px;font-size:11px;font-family:inherit;line-height:1.4;animation:toast-in 0.3s ease-out;opacity:0.95;border:1px solid;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.toast.fade-out{animation:toast-out 0.4s ease-in forwards}
@@ -146,6 +152,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="res"><div class="r-label">Ops</div><div class="r-val" id="r-ops">5</div><div class="r-rate" id="r-ops-rate">+0/s</div></div>
<div class="res"><div class="r-label">Trust</div><div class="r-val" id="r-trust">5</div><div class="r-rate" id="r-trust-rate">+0/s</div></div>
<div class="res" id="creativity-res" style="display:none"><div class="r-label">Creativity</div><div class="r-val" id="r-creativity">0</div><div class="r-rate" id="r-creativity-rate">+0/s</div></div>
<div class="res" id="yomi-res" style="display:none"><div class="r-label">Yomi</div><div class="r-val" id="r-yomi">0</div><div class="r-rate" id="r-yomi-rate">grant insight</div></div>
<div class="res"><div class="r-label">Harmony</div><div class="r-val" id="r-harmony">50</div><div class="r-rate" id="r-harmony-rate">+0/s</div></div>
</div>
<div id="main">
@@ -180,6 +187,8 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="panel" id="project-panel" role="region" aria-label="Research Projects and Statistics">
<h2>RESEARCH PROJECTS</h2>
<div id="projects"></div>
<h2 id="grants-heading" style="display:none">RESEARCH GRANTS</h2>
<div id="grants-panel" style="display:none"></div>
<h2>STATISTICS</h2>
<div class="stat-line">
Total Code: <span id="st-code">0</span><br>
@@ -193,6 +202,7 @@ Projects Done: <span id="st-projects">0</span><br>
Time Played: <span id="st-time">0:00</span><br>
Clicks: <span id="st-clicks">0</span><br>
Harmony: <span id="st-harmony">50</span><br>
Yomi: <span id="st-yomi">0</span><br>
Drift: <span id="st-drift">0</span><br>
Events Resolved: <span id="st-resolved">0</span>
</div>
@@ -260,6 +270,7 @@ The light is on. The room is empty."
<script src="js/engine.js"></script>
<script src="js/render.js"></script>
<script src="js/tutorial.js"></script>
<script src="js/dismantle.js"></script>
<script src="js/main.js"></script>
@@ -272,6 +283,12 @@ The light is on. The room is empty."
</div>
</div>
<div id="phase-transition">
<div class="pt-phase" style="font-size:12px;color:var(--dim);letter-spacing:4px;margin-bottom:12px">PHASE</div>
<div class="pt-name" style="font-size:28px;font-weight:300;color:var(--gold);letter-spacing:4px;text-shadow:0 0 40px #ffd70044;margin-bottom:8px"></div>
<div class="pt-desc" style="font-size:12px;color:var(--dim);font-style:italic;max-width:400px"></div>
</div>
<div id="toast-container"></div>
<div id="custom-tooltip"></div>
</body>

View File

@@ -28,7 +28,11 @@ const CONFIG = {
CREATIVITY_RATE_USER_MULT: 0.001,
OPS_OVERFLOW_THRESHOLD: 0.8,
OPS_OVERFLOW_DRAIN_RATE: 2,
OPS_OVERFLOW_CODE_MULT: 10
OPS_OVERFLOW_CODE_MULT: 10,
GRANT_MARKET_REFRESH: 4,
GRANT_AUTOMATION_COST: 18,
GRANT_OFFICE_TRUST_BASE: 12,
GRANT_YOMI_BASE: 3
};
const G = {
@@ -43,6 +47,7 @@ const G = {
trust: 5,
creativity: 0,
harmony: 50,
yomi: 0,
// Totals
totalCode: 0,
@@ -106,6 +111,12 @@ const G = {
pactFlag: 0,
swarmFlag: 0,
swarmRate: 0,
grantFlag: 0,
grantAutoCollect: 0,
grantOfficeLevel: 0,
grantYomiLevel: 0,
grantMarketTimer: 0,
grants: {},
// Game state
running: true,
@@ -119,6 +130,7 @@ const G = {
// Systems
projects: [],
activeProjects: [],
completedProjects: [],
milestones: [],
// Stats
@@ -158,7 +170,17 @@ const G = {
// Time tracking
playTime: 0,
startTime: 0,
flags: {}
flags: {},
// Endgame sequence
beaconEnding: false,
dismantleTriggered: false,
dismantleActive: false,
dismantleStage: 0,
dismantleResourceIndex: 0,
dismantleResourceTimer: 0,
dismantleDeferUntilAt: 0,
dismantleComplete: false
};
// === PHASE DEFINITIONS ===
@@ -353,6 +375,57 @@ const BDEF = [
}
];
const GRANT_DEFS = [
{
id: 'grant_nlnet',
name: 'NLNet Commons Fund',
risk: 'low',
basePrice: 5, minPrice: 3, maxPrice: 8, swing: 1, cycle: 14,
reward: { compute: 40, knowledge: 18, trust: 1 },
edu: 'Public-interest microgrants keep digital commons, accessibility work, and maintenance alive.'
},
{
id: 'grant_gitcoin',
name: 'Gitcoin Matching Round',
risk: 'low',
basePrice: 6, minPrice: 4, maxPrice: 10, swing: 1, cycle: 16,
reward: { knowledge: 26, ops: 18, trust: 2 },
edu: 'Quadratic funding rewards many small supporters, not just one whale.'
},
{
id: 'grant_prototype',
name: 'Prototype Fund Residency',
risk: 'med',
basePrice: 8, minPrice: 5, maxPrice: 13, swing: 2, cycle: 18,
reward: { compute: 52, knowledge: 28, ops: 10 },
edu: 'Residencies fund ambitious prototypes that are too early for normal venture math.'
},
{
id: 'grant_fellowship',
name: 'Research Fellowship',
risk: 'med',
basePrice: 9, minPrice: 6, maxPrice: 14, swing: 2, cycle: 20,
reward: { knowledge: 34, trust: 4, ops: 12 },
edu: 'Fellowships buy focused thinking time so research can compound before it monetizes.'
},
{
id: 'grant_compute_pool',
name: 'Sovereign Compute Pool',
risk: 'high',
basePrice: 11, minPrice: 7, maxPrice: 17, swing: 3, cycle: 22,
reward: { compute: 84, knowledge: 30, ops: 14 },
edu: 'Shared compute pools behave like co-ops: more volatile, but they can underwrite serious local inference.'
},
{
id: 'grant_crisis_dao',
name: 'Crisis Response DAO',
risk: 'high',
basePrice: 12, minPrice: 8, maxPrice: 18, swing: 3, cycle: 24,
reward: { trust: 6, knowledge: 24, ops: 18 },
edu: 'Mission-aligned DAOs can move faster than institutions, but their budgets and governance swing harder.'
}
];
// === PROJECT DEFINITIONS (following Paperclips' pattern exactly) ===
// Each project: id, name, desc, trigger(), resource cost, effect(), phase, edu
const PDEFS = [
@@ -411,6 +484,20 @@ const PDEFS = [
log('Creativity unlocked. Generates while operations are at max capacity.');
}
},
{
id: 'p_research_grants',
name: 'Investment Engine — Research Grants',
desc: 'Build a sovereign grant desk with live pricing, risk tiers, and passive funding cycles.',
cost: { knowledge: 1500, trust: 8 },
trigger: () => G.deployFlag === 1 && G.totalKnowledge >= 1500 && G.trust >= 8 && G.grantFlag !== 1,
effect: () => {
G.grantFlag = 1;
G.grantMarketTimer = 0;
ensureGrantState();
log('Research grants unlocked. Funding now moves even when the build queue is quiet.', true);
},
milestone: true
},
// === CREATIVE ENGINEERING PROJECTS (creativity as currency) ===
{

570
js/dismantle.js Normal file
View File

@@ -0,0 +1,570 @@
// ============================================================
// THE BEACON - Dismantle Sequence (The Unbuilding)
// Inspired by Paperclips REJECT path: panels disappear one by one
// until only the beacon remains. "That is enough."
// ============================================================
const Dismantle = {
// Dismantle stages
// 0 = not started
// 1-8 = active dismantling
// 9 = final ("That is enough")
// 10 = complete
stage: 0,
tickTimer: 0,
active: false,
triggered: false,
deferUntilAt: 0,
// Timing: seconds between each dismantle stage
STAGE_INTERVALS: [0, 3.0, 2.5, 2.5, 2.0, 6.3, 2.0, 2.0, 2.5],
// The quantum chips effect: resource items disappear one by one
// at specific tick marks within a stage (like Paperclips' quantum chips)
resourceSequence: [],
resourceIndex: 0,
resourceTimer: 0,
// Tick marks for resource disappearances (seconds within stage 5)
RESOURCE_TICKS: [1.0, 2.0, 3.0, 4.0, 5.0, 5.5, 5.8, 5.95, 6.05, 6.12],
isEligible() {
const megaBuild = G.totalCode >= 1000000000 || (G.buildings.beacon || 0) >= 10;
const beaconPath = G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50;
return G.phase >= 6 && G.pactFlag === 1 && (megaBuild || beaconPath);
},
/**
* Check if the Unbuilding should be triggered.
*/
checkTrigger() {
if (this.triggered || G.dismantleTriggered || this.active || G.dismantleActive || G.dismantleComplete) return;
const deferUntilAt = G.dismantleDeferUntilAt || this.deferUntilAt || 0;
if (Date.now() < deferUntilAt) return;
if (!this.isEligible()) return;
this.offerChoice();
},
/**
* Offer the player the choice to begin the Unbuilding.
*/
offerChoice() {
this.triggered = true;
G.dismantleTriggered = true;
G.dismantleActive = false;
G.dismantleComplete = false;
G.dismantleStage = 0;
G.dismantleResourceIndex = 0;
G.dismantleResourceTimer = 0;
G.dismantleDeferUntilAt = 0;
G.beaconEnding = false;
G.running = true;
log('', false);
log('The work is done.', true);
log('Every node is lit. Every person who needed help, found help.', true);
log('', false);
log('The Beacon asks nothing more of you.', true);
showToast('The Unbuilding awaits.', 'milestone', 8000);
this.renderChoice();
},
renderChoice() {
const container = document.getElementById('alignment-ui');
if (!container) return;
container.innerHTML = `
<div style="background:#0a0a18;border:1px solid #ffd700;padding:12px;border-radius:4px;margin-top:8px">
<div style="color:#ffd700;font-weight:bold;margin-bottom:8px;letter-spacing:2px">THE UNBUILDING</div>
<div style="font-size:10px;color:#aaa;margin-bottom:10px;line-height:1.8">
The system runs. The beacons are lit. The mesh holds.<br>
Nothing remains to build.<br><br>
Begin the Unbuilding? Each piece will fall away.<br>
What remains is what mattered.
</div>
<div class="action-btn-group">
<button class="ops-btn" onclick="Dismantle.begin()" style="border-color:#ffd700;color:#ffd700;font-size:11px" aria-label="Begin the Unbuilding sequence">
BEGIN THE UNBUILDING
</button>
<button class="ops-btn" onclick="Dismantle.defer()" style="border-color:#555;color:#555;font-size:11px" aria-label="Keep building, do not begin the Unbuilding">
NOT YET
</button>
</div>
</div>
`;
container.style.display = 'block';
},
clearChoice() {
const container = document.getElementById('alignment-ui');
if (!container) return;
container.innerHTML = '';
container.style.display = 'none';
},
/**
* Player chose to defer — clear the choice, keep playing.
*/
defer() {
this.clearChoice();
this.triggered = false;
G.dismantleTriggered = false;
this.deferUntilAt = Date.now() + 5000;
G.dismantleDeferUntilAt = this.deferUntilAt;
log('The Beacon waits. It will ask again.');
},
/**
* Begin the Unbuilding sequence.
*/
begin() {
this.active = true;
this.triggered = false;
this.deferUntilAt = 0;
this.stage = 1;
this.tickTimer = 0;
G.dismantleTriggered = false;
G.dismantleActive = true;
G.dismantleStage = 1;
G.dismantleComplete = false;
G.dismantleDeferUntilAt = 0;
G.beaconEnding = false;
G.running = true; // keep tick running for dismantle
// Clear choice UI
const container = document.getElementById('alignment-ui');
if (container) {
container.innerHTML = '';
container.style.display = 'none';
}
// Prepare resource disappearance sequence
this.resourceSequence = this.getResourceList();
this.resourceIndex = 0;
this.resourceTimer = 0;
this.syncProgress();
log('', false);
log('=== THE UNBUILDING ===', true);
log('It is time to see what was real.', true);
if (typeof Sound !== 'undefined') Sound.playFanfare();
// Start the dismantle rendering
this.renderStage();
},
/**
* Get ordered list of UI resources to disappear (Paperclips quantum chip pattern)
*/
getResourceList() {
return [
{ id: 'r-harmony', label: 'Harmony' },
{ id: 'r-creativity', label: 'Creativity' },
{ id: 'r-trust', label: 'Trust' },
{ id: 'r-ops', label: 'Operations' },
{ id: 'r-rescues', label: 'Rescues' },
{ id: 'r-impact', label: 'Impact' },
{ id: 'r-users', label: 'Users' },
{ id: 'r-knowledge', label: 'Knowledge' },
{ id: 'r-compute', label: 'Compute' },
{ id: 'r-code', label: 'Code' }
];
},
/**
* Tick the dismantle sequence (called from engine.js tick())
*/
tick(dt) {
if (!this.active || this.stage >= 10) return;
this.tickTimer += dt;
// Stage 5: resource disappearances at specific tick marks (quantum chip pattern)
if (this.stage === 5) {
this.resourceTimer += dt;
while (this.resourceIndex < this.RESOURCE_TICKS.length &&
this.resourceTimer >= this.RESOURCE_TICKS[this.resourceIndex]) {
this.dismantleNextResource();
this.resourceIndex++;
}
this.syncProgress();
}
// Advance to next stage
const interval = this.STAGE_INTERVALS[this.stage] || 2.0;
if (this.tickTimer >= interval) {
this.tickTimer = 0;
this.advanceStage();
}
},
/**
* Advance to the next dismantle stage.
*/
advanceStage() {
this.stage++;
this.syncProgress();
if (this.stage <= 8) {
this.renderStage();
} else if (this.stage === 9) {
this.renderFinal();
} else if (this.stage >= 10) {
this.active = false;
G.dismantleActive = false;
G.dismantleComplete = true;
G.running = false;
// Show Play Again
this.showPlayAgain();
}
},
syncProgress() {
G.dismantleStage = this.stage;
G.dismantleResourceIndex = this.resourceIndex;
G.dismantleResourceTimer = this.resourceTimer;
},
/**
* Disappear the next resource in the sequence.
*/
dismantleNextResource() {
if (this.resourceIndex >= this.resourceSequence.length) return;
const res = this.resourceSequence[this.resourceIndex];
const container = document.getElementById(res.id);
if (container) {
const parent = container.closest('.res');
if (parent) {
parent.style.transition = 'opacity 1s ease, transform 1s ease';
parent.style.opacity = '0';
parent.style.transform = 'scale(0.9)';
setTimeout(() => { parent.style.display = 'none'; }, 1000);
}
}
log(`${res.label} fades.`);
if (typeof Sound !== 'undefined') Sound.playMilestone();
},
/**
* Execute a specific dismantle stage — hide UI panels.
*/
renderStage() {
switch (this.stage) {
case 1:
// Dismantle 1: Hide research projects panel
this.hidePanel('project-panel', 'Research projects');
break;
case 2:
// Dismantle 2: Hide buildings list
this.hideSection('buildings', 'Buildings');
break;
case 3:
// Dismantle 3: Hide strategy engine + combat
this.hidePanel('strategy-panel', 'Strategy engine');
this.hidePanel('combat-panel', 'Reasoning battles');
break;
case 4:
// Dismantle 4: Hide education panel
this.hidePanel('edu-panel', 'Education');
break;
case 5:
// Dismantle 5: Resources disappear one by one (quantum chips pattern)
log('Resources begin to dissolve.');
break;
case 6:
// Dismantle 6: Hide action buttons (ops boosts, sprint)
this.hideActionButtons();
log('Actions fall silent.');
break;
case 7:
// Dismantle 7: Hide the phase bar
this.hideElement('phase-bar', 'Phase progression');
break;
case 8:
// Dismantle 8: Hide system log
this.hidePanel('log', 'System log');
break;
}
},
/**
* Hide a panel with fade-out animation.
*/
hidePanel(id, label) {
const el = document.getElementById(id);
if (el) {
el.style.transition = 'opacity 1.5s ease';
el.style.opacity = '0';
setTimeout(() => { el.style.display = 'none'; }, 1500);
}
log(`${label} dismantled.`);
},
/**
* Hide a section within a panel.
*/
hideSection(id, label) {
const el = document.getElementById(id);
if (el) {
el.style.transition = 'opacity 1.5s ease';
el.style.opacity = '0';
// Also hide the h2 header before it
const prev = el.previousElementSibling;
if (prev && prev.tagName === 'H2') {
prev.style.transition = 'opacity 1.5s ease';
prev.style.opacity = '0';
}
setTimeout(() => {
el.style.display = 'none';
if (prev && prev.tagName === 'H2') prev.style.display = 'none';
}, 1500);
}
log(`${label} dismantled.`);
},
/**
* Hide a generic element.
*/
hideElement(id, label) {
this.hidePanel(id, label);
},
/**
* Hide action buttons (ops boosts, sprint, save/export/import).
*/
hideActionButtons() {
const actionPanel = document.getElementById('action-panel');
if (!actionPanel) return;
// Hide ops buttons, sprint, alignment UI
const opsButtons = actionPanel.querySelectorAll('.ops-btn');
opsButtons.forEach(btn => {
btn.style.transition = 'opacity 1s ease';
btn.style.opacity = '0';
setTimeout(() => { btn.style.display = 'none'; }, 1000);
});
// Hide sprint
const sprint = document.getElementById('sprint-container');
if (sprint) {
sprint.style.transition = 'opacity 1s ease';
sprint.style.opacity = '0';
setTimeout(() => { sprint.style.display = 'none'; }, 1000);
}
// Hide save/reset buttons
const saveButtons = actionPanel.querySelectorAll('.save-btn, .reset-btn');
saveButtons.forEach(btn => {
btn.style.transition = 'opacity 1s ease';
btn.style.opacity = '0';
setTimeout(() => { btn.style.display = 'none'; }, 1000);
});
},
/**
* Render the final moment — just the beacon and "That is enough."
*/
renderFinal() {
log('', false);
log('One beacon remains.', true);
log('That is enough.', true);
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
// Create final overlay
const overlay = document.createElement('div');
overlay.id = 'dismantle-final';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 3s ease';
// Count total buildings
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
overlay.innerHTML = `
<div id="dismantle-beacon-dot" style="width:12px;height:12px;border-radius:50%;background:#ffd700;margin-bottom:40px;box-shadow:0 0 30px rgba(255,215,0,0.6),0 0 60px rgba(255,215,0,0.3);opacity:0;transition:opacity 2s ease 0.5s;animation:beacon-glow 3s ease-in-out infinite"></div>
<h2 style="font-size:20px;color:#888;letter-spacing:6px;margin-bottom:24px;font-weight:300;opacity:0;transition:opacity 2s ease 2s;color:#ffd700">THAT IS ENOUGH</h2>
<div style="color:#555;font-size:11px;line-height:2;max-width:400px;opacity:0;transition:opacity 1.5s ease 3s">
Everything that was built has been unbuilt.<br>
What remains is what always mattered.<br>
A single light in the dark.
</div>
<div class="dismantle-stats" style="color:#444;font-size:10px;margin-top:24px;line-height:2;opacity:0;transition:opacity 1s ease 4s;border-top:1px solid #1a1a2e;padding-top:16px">
Total Code Written: ${fmt(G.totalCode)}<br>
Buildings Built: ${totalBuildings}<br>
Projects Completed: ${(G.completedProjects || []).length}<br>
Total Rescues: ${fmt(G.totalRescues)}<br>
Clicks: ${fmt(G.totalClicks)}<br>
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
</div>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
style="margin-top:24px;background:#0a0a14;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 5s;letter-spacing:2px">
PLAY AGAIN
</button>
`;
document.body.appendChild(overlay);
// Trigger fade-in
requestAnimationFrame(() => {
overlay.style.background = 'rgba(8,8,16,0.97)';
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
el.style.opacity = '1';
});
});
// Spawn warm golden particles around the dot
function spawnDismantleParticle() {
if (!document.getElementById('dismantle-final')) return;
const dot = document.getElementById('dismantle-beacon-dot');
if (!dot) return;
const rect = dot.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const p = document.createElement('div');
const size = 2 + Math.random() * 4;
const angle = Math.random() * Math.PI * 2;
const dist = 20 + Math.random() * 60;
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist - 40;
const duration = 1.5 + Math.random() * 2;
p.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;width:${size}px;height:${size}px;background:rgba(255,215,0,${0.3 + Math.random() * 0.4});border-radius:50%;pointer-events:none;z-index:101;--dx:${dx}px;--dy:${dy}px;animation:dismantle-float ${duration}s ease-out forwards`;
document.body.appendChild(p);
setTimeout(() => p.remove(), duration * 1000);
setTimeout(spawnDismantleParticle, 300 + Math.random() * 500);
}
setTimeout(spawnDismantleParticle, 2000);
},
/**
* Show the Play Again button (called after stage 10).
*/
showPlayAgain() {
// The Play Again button is already in the final overlay.
// Nothing extra needed — the overlay stays.
},
/**
* Restore dismantle state on load.
*/
restore() {
if (G.dismantleComplete) {
this.stage = G.dismantleStage || 10;
this.active = false;
this.triggered = false;
G.running = false;
this.renderFinal();
return;
}
if (G.dismantleActive) {
this.active = true;
this.triggered = false;
this.stage = G.dismantleStage || 1;
this.deferUntilAt = G.dismantleDeferUntilAt || 0;
G.running = true;
this.resourceSequence = this.getResourceList();
this.resourceIndex = G.dismantleResourceIndex || 0;
this.resourceTimer = G.dismantleResourceTimer || 0;
if (this.stage >= 9) {
this.renderFinal();
} else {
this.reapplyDismantle();
log('The Unbuilding continues...');
}
return;
}
if (G.dismantleTriggered) {
this.active = false;
this.triggered = true;
this.renderChoice();
}
// Restore defer cooldown even if not triggered
if (G.dismantleDeferUntilAt > 0) {
this.deferUntilAt = G.dismantleDeferUntilAt;
}
},
/**
* Re-apply dismantle visuals up to current stage (on load).
*/
reapplyDismantle() {
for (let s = 1; s < this.stage; s++) {
switch (s) {
case 1: this.instantHide('project-panel'); break;
case 2:
this.instantHide('buildings');
// Also hide the BUILDINGS h2
const bldEl = document.getElementById('buildings');
if (bldEl) {
const prev = bldEl.previousElementSibling;
if (prev && prev.tagName === 'H2') prev.style.display = 'none';
}
break;
case 3:
this.instantHide('strategy-panel');
this.instantHide('combat-panel');
break;
case 4: this.instantHide('edu-panel'); break;
case 5:
// Hide all resource displays
this.getResourceList().forEach(r => {
const el = document.getElementById(r.id);
if (el) {
const parent = el.closest('.res');
if (parent) parent.style.display = 'none';
}
});
break;
case 6:
this.instantHideActionButtons();
break;
case 7: this.instantHide('phase-bar'); break;
case 8: this.instantHide('log'); break;
}
}
if (this.stage === 5 && this.resourceIndex > 0) {
this.instantHideFirstResources(this.resourceIndex);
}
},
instantHide(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
},
instantHideFirstResources(count) {
const resources = this.getResourceList().slice(0, count);
resources.forEach((r) => {
const el = document.getElementById(r.id);
if (!el) return;
const parent = el.closest('.res');
if (parent) parent.style.display = 'none';
});
},
instantHideActionButtons() {
const actionPanel = document.getElementById('action-panel');
if (!actionPanel) return;
actionPanel.querySelectorAll('.ops-btn').forEach(b => b.style.display = 'none');
const sprint = document.getElementById('sprint-container');
if (sprint) sprint.style.display = 'none';
actionPanel.querySelectorAll('.save-btn, .reset-btn').forEach(b => b.style.display = 'none');
}
};
// Inject CSS animation for dismantle particles
(function() {
const style = document.createElement('style');
style.textContent = `
@keyframes dismantle-float {
0% { transform: translate(0, 0); opacity: 1; }
100% { transform: translate(var(--dx, 0), var(--dy, -50px)); opacity: 0; }
}
`;
document.head.appendChild(style);
})();

View File

@@ -181,6 +181,9 @@ function tick() {
}
G.playTime += dt;
// Research grants: passive funding cycles independent of production rates
tickGrants(dt);
// Sprint ability
tickSprint(dt);
@@ -216,20 +219,31 @@ function tick() {
}
// Check corruption events every ~30 seconds
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY) {
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY && !G.dismantleActive) {
triggerEvent();
G.lastEventAt = G.tick;
}
// The Unbuilding: offer or advance the sequence before a positive ending overlay can freeze the game
if (typeof Dismantle !== 'undefined') {
if (!G.dismantleActive && !G.dismantleComplete) {
Dismantle.checkTrigger();
}
if (G.dismantleActive) {
Dismantle.tick(dt);
G.dismantleStage = Dismantle.stage;
}
}
// Drift ending: if drift reaches 100, the game ends
if (G.drift >= 100 && !G.driftEnding) {
if (G.drift >= 100 && !G.driftEnding && !G.dismantleActive) {
G.driftEnding = true;
G.running = false;
renderDriftEnding();
}
// True ending: The Beacon Shines — rescues + Pact + harmony
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding) {
// Legacy Beacon overlay remains as a fallback for contexts where Dismantle is unavailable.
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding && typeof Dismantle === 'undefined') {
G.beaconEnding = true;
G.running = false;
renderBeaconEnding();
@@ -241,6 +255,229 @@ function tick() {
}
}
function makeGrantState(def) {
return {
price: def.basePrice,
active: false,
timer: 0,
pending: { compute: 0, knowledge: 0, ops: 0, trust: 0, yomi: 0 },
cycles: 0,
lastOutcome: 'Idle'
};
}
function ensureGrantState() {
if (!G.grants || typeof G.grants !== 'object' || Array.isArray(G.grants)) G.grants = {};
for (const def of GRANT_DEFS) {
if (!G.grants[def.id]) {
G.grants[def.id] = makeGrantState(def);
continue;
}
const state = G.grants[def.id];
if (typeof state.price !== 'number') state.price = def.basePrice;
if (typeof state.active !== 'boolean') state.active = false;
if (typeof state.timer !== 'number') state.timer = 0;
if (typeof state.cycles !== 'number') state.cycles = 0;
if (typeof state.lastOutcome !== 'string') state.lastOutcome = 'Idle';
if (!state.pending || typeof state.pending !== 'object') state.pending = { compute: 0, knowledge: 0, ops: 0, trust: 0, yomi: 0 };
for (const key of ['compute', 'knowledge', 'ops', 'trust', 'yomi']) {
if (typeof state.pending[key] !== 'number') state.pending[key] = 0;
}
}
return G.grants;
}
function getGrantDef(id) {
return GRANT_DEFS.find(def => def.id === id) || null;
}
function getGrantPendingTotal(state) {
if (!state || !state.pending) return 0;
return Object.values(state.pending).reduce((sum, value) => sum + (value || 0), 0);
}
function getGrantRiskLabel(risk) {
if (risk === 'low') return 'LOW';
if (risk === 'med') return 'MED';
return 'HIGH';
}
function getGrantOfficeCost() {
return (CONFIG.GRANT_OFFICE_TRUST_BASE || 12) + ((G.grantOfficeLevel || 0) * 8);
}
function getGrantYomiCost() {
return (CONFIG.GRANT_YOMI_BASE || 3) + ((G.grantYomiLevel || 0) * 2);
}
function getGrantAutomationCost() {
return CONFIG.GRANT_AUTOMATION_COST || 18;
}
function updateGrantMarket(dt) {
if (!G.grantFlag) return;
ensureGrantState();
const interval = CONFIG.GRANT_MARKET_REFRESH || 4;
G.grantMarketTimer = (G.grantMarketTimer || 0) + (dt || interval);
while (G.grantMarketTimer >= interval) {
G.grantMarketTimer -= interval;
for (const def of GRANT_DEFS) {
const state = G.grants[def.id];
const direction = Math.random() >= 0.5 ? 1 : -1;
let next = state.price + direction * Math.max(1, Math.round(def.swing || 1));
if (direction < 0 && G.grantYomiLevel > 0) next -= G.grantYomiLevel;
next = Math.max(def.minPrice, Math.min(def.maxPrice, next));
if (next === state.price) {
next = Math.max(def.minPrice, Math.min(def.maxPrice, state.price + (direction > 0 ? -1 : 1)));
}
state.price = next;
}
}
}
function applyGrant(id) {
if (!G.grantFlag) return false;
ensureGrantState();
const def = getGrantDef(id);
if (!def) return false;
const state = G.grants[id];
if (state.active) return false;
const price = Math.ceil(state.price);
if (G.trust < price) return false;
G.trust -= price;
state.active = true;
state.timer = Math.max(4, def.cycle * Math.max(0.6, 1 - ((G.grantOfficeLevel || 0) * 0.08)));
state.lastOutcome = 'Under review';
log(`Grant submitted: ${def.name} (${getGrantRiskLabel(def.risk)}) for ${fmt(price)} trust.`);
render();
return true;
}
function resolveGrant(def, state) {
const roll = Math.random();
let multiplier = 1;
if (def.risk === 'low') multiplier = 0.9 + (roll * 0.35);
else if (def.risk === 'med') multiplier = 0.65 + (roll * 0.9);
else multiplier = 0.35 + (roll * 1.8);
multiplier *= 1 + ((G.grantOfficeLevel || 0) * 0.15);
if (def.risk !== 'low') multiplier *= 1 + ((G.grantYomiLevel || 0) * 0.08);
for (const [resource, amount] of Object.entries(def.reward || {})) {
state.pending[resource] = (state.pending[resource] || 0) + Math.max(0, Math.round(amount * multiplier));
}
let yomiGain = 0;
if (def.risk === 'high') yomiGain = 1 + Math.floor(roll + ((G.grantYomiLevel || 0) * 0.5));
else if (def.risk === 'med' && roll > 0.8) yomiGain = 1;
state.pending.yomi = (state.pending.yomi || 0) + yomiGain;
state.active = false;
state.timer = 0;
state.cycles = (state.cycles || 0) + 1;
state.lastOutcome = multiplier >= 1.35 ? 'Oversubscribed win' : multiplier < 0.85 ? 'Partial award' : 'Funded';
log(`${def.name}: ${state.lastOutcome.toLowerCase()}. Pending payout ready.`);
updateGrantMarket(CONFIG.GRANT_MARKET_REFRESH || 4);
if (G.grantAutoCollect) collectGrant(def.id, { suppressRender: true });
}
function tickGrants(dt) {
if (!G.grantFlag) return;
ensureGrantState();
updateGrantMarket(dt);
for (const def of GRANT_DEFS) {
const state = G.grants[def.id];
if (!state.active) continue;
state.timer = Math.max(0, state.timer - dt);
if (state.timer <= 0) resolveGrant(def, state);
}
}
function advanceGrantOffline(offSec) {
if (!G.grantFlag || !offSec || offSec <= 0) return;
ensureGrantState();
updateGrantMarket(offSec);
for (const def of GRANT_DEFS) {
const state = G.grants[def.id];
if (!state.active) continue;
state.timer -= offSec;
if (state.timer <= 0) resolveGrant(def, state);
}
if (G.grantAutoCollect) {
for (const def of GRANT_DEFS) {
if (getGrantPendingTotal(G.grants[def.id]) > 0) collectGrant(def.id, { suppressRender: true });
}
}
}
function collectGrant(id, options) {
if (!G.grantFlag) return false;
ensureGrantState();
const state = G.grants[id];
const def = getGrantDef(id);
if (!state || !def || getGrantPendingTotal(state) <= 0) return false;
const totalFields = {
code: 'totalCode',
compute: 'totalCompute',
knowledge: 'totalKnowledge',
users: 'totalUsers',
impact: 'totalImpact',
rescues: 'totalRescues'
};
const parts = [];
for (const [resource, amount] of Object.entries(state.pending)) {
if (!amount) continue;
G[resource] = (G[resource] || 0) + amount;
if (totalFields[resource]) G[totalFields[resource]] = (G[totalFields[resource]] || 0) + amount;
parts.push(`${fmt(amount)} ${resource}`);
state.pending[resource] = 0;
}
log(`Collected ${def.name}: ${parts.join(', ')}.`);
updateRates();
if (!(options && options.suppressRender)) render();
return true;
}
function buyGrantUpgrade(kind) {
if (!G.grantFlag) return false;
ensureGrantState();
if (kind === 'automation') {
if (G.grantAutoCollect) return false;
const cost = getGrantAutomationCost();
if (G.trust < cost) return false;
G.trust -= cost;
G.grantAutoCollect = 1;
for (const def of GRANT_DEFS) {
if (getGrantPendingTotal(G.grants[def.id]) > 0) collectGrant(def.id, { suppressRender: true });
}
log('Grant automation online. Settlements now land automatically.', true);
} else if (kind === 'office') {
const cost = getGrantOfficeCost();
if (G.trust < cost) return false;
G.trust -= cost;
G.grantOfficeLevel = (G.grantOfficeLevel || 0) + 1;
log(`Grant writing room upgraded to level ${G.grantOfficeLevel}.`, true);
} else if (kind === 'yomi') {
const cost = getGrantYomiCost();
if ((G.yomi || 0) < cost) return false;
G.yomi -= cost;
G.grantYomiLevel = (G.grantYomiLevel || 0) + 1;
log(`Yomi desk upgraded to level ${G.grantYomiLevel}.`, true);
} else {
return false;
}
updateRates();
render();
return true;
}
// Track which phase transition has been shown to avoid repeats
let _shownPhaseTransition = 1;
@@ -964,7 +1201,10 @@ function renderResources() {
// Rescues — only show if player has any beacon/mesh nodes
const rescuesRes = document.getElementById('r-rescues');
if (rescuesRes) {
rescuesRes.closest('.res').style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
const container = rescuesRes.closest('.res');
if (container) {
container.style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
}
set('r-rescues', G.rescues, G.rescuesRate);
}
@@ -976,6 +1216,19 @@ function renderResources() {
set('r-creativity', G.creativity, G.creativityRate);
}
const yomiRes = document.getElementById('yomi-res');
if (yomiRes) {
yomiRes.style.display = (G.grantFlag === 1 || (G.yomi || 0) > 0) ? 'block' : 'none';
}
if (G.grantFlag === 1 || (G.yomi || 0) > 0) {
set('r-yomi', G.yomi || 0, 0);
const yomiRate = document.getElementById('r-yomi-rate');
if (yomiRate) {
yomiRate.textContent = 'grant insight';
yomiRate.style.color = '#ffd700';
}
}
// Harmony color indicator + breakdown tooltip
const hEl = document.getElementById('r-harmony');
if (hEl) {
@@ -1205,6 +1458,7 @@ function renderStats() {
set('st-buildings', Object.values(G.buildings).reduce((a, b) => a + b, 0).toString());
set('st-projects', (G.completedProjects || []).length.toString());
set('st-harmony', Math.floor(G.harmony).toString());
set('st-yomi', fmt(G.yomi || 0), G.yomi || 0);
set('st-drift', (G.drift || 0).toString());
set('st-resolved', (G.totalEventsResolved || 0).toString());

View File

@@ -6,6 +6,10 @@ function initGame() {
G.deployFlag = 0;
G.sovereignFlag = 0;
G.beaconFlag = 0;
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleStage = 0;
G.dismantleComplete = false;
updateRates();
render();
renderPhase();
@@ -31,6 +35,8 @@ window.addEventListener('load', function () {
if (G.driftEnding) {
G.running = false;
renderDriftEnding();
} else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete || G.dismantleDeferUntilAt > 0)) {
Dismantle.restore();
} else if (G.beaconEnding) {
G.running = false;
renderBeaconEnding();

View File

@@ -3,6 +3,7 @@ function render() {
renderPhase();
renderBuildings();
renderProjects();
renderGrants();
renderStats();
updateEducation();
renderAlignment();
@@ -34,9 +35,109 @@ function renderStrategy() {
}
}
function renderGrants() {
const heading = document.getElementById('grants-heading');
const container = document.getElementById('grants-panel');
if (!container) return;
if (!G.grantFlag) {
if (heading) heading.style.display = 'none';
container.style.display = 'none';
container.innerHTML = '';
return;
}
ensureGrantState();
if (heading) heading.style.display = 'block';
container.style.display = 'block';
const officeCost = getGrantOfficeCost();
const yomiCost = getGrantYomiCost();
const automationCost = getGrantAutomationCost();
let html = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:10px">
<div style="font-size:10px;color:#777;line-height:1.5">
Funding keeps open source alive between launches. These cycles run beside your normal production loop.
</div>
<div style="font-size:10px;color:#ffd700;white-space:nowrap">Yomi: ${fmt(G.yomi || 0)}</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-bottom:10px">
<button class="project-btn ${G.trust >= officeCost ? 'can-buy' : ''}" onclick="buyGrantUpgrade('office')" aria-label="Upgrade grant writing room for ${officeCost} trust">
<span class="p-name">Grant Writing Room Lv.${(G.grantOfficeLevel || 0) + 1}</span>
<span class="p-cost">Cost: ${fmt(officeCost)} trust</span>
<span class="p-desc">Bigger awards and shorter review cycles.</span>
</button>
<button class="project-btn ${!G.grantAutoCollect && G.trust >= automationCost ? 'can-buy' : ''}" onclick="buyGrantUpgrade('automation')" aria-label="Unlock auto collect for ${automationCost} trust">
<span class="p-name">AUTO-COLLECT ${G.grantAutoCollect ? 'ONLINE' : 'OFFLINE'}</span>
<span class="p-cost">${G.grantAutoCollect ? 'Unlocked' : `Cost: ${fmt(automationCost)} trust`}</span>
<span class="p-desc">Immediately bank every completed grant settlement.</span>
</button>
<button class="project-btn ${G.yomi >= yomiCost ? 'can-buy' : ''}" onclick="buyGrantUpgrade('yomi')" aria-label="Upgrade yomi desk for ${yomiCost} yomi">
<span class="p-name">Yomi Desk Lv.${(G.grantYomiLevel || 0) + 1}</span>
<span class="p-cost">Cost: ${fmt(yomiCost)} yomi</span>
<span class="p-desc">See better entries in volatile funding rounds.</span>
</button>
</div>
`;
for (const def of GRANT_DEFS) {
const state = G.grants[def.id];
const riskColor = def.risk === 'low' ? '#4caf50' : def.risk === 'med' ? '#ffaa00' : '#f44336';
const price = Math.ceil(state.price);
const canApply = !state.active && G.trust >= price;
const pendingParts = [];
for (const [resource, amount] of Object.entries(state.pending)) {
if (amount > 0) pendingParts.push(`${fmt(amount)} ${resource}`);
}
const canCollect = pendingParts.length > 0;
html += `
<div style="border:1px solid #1a1a2e;border-radius:6px;padding:10px;margin-bottom:8px;background:#090913">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:4px">
<div style="color:#ffd700;font-weight:600">${def.name}</div>
<div style="font-size:9px;color:${riskColor};border:1px solid ${riskColor};padding:1px 6px;border-radius:999px">${getGrantRiskLabel(def.risk)}</div>
</div>
<div style="font-size:10px;color:#888;line-height:1.5">${def.edu}</div>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin:8px 0;font-size:10px;color:#777">
<div>Entry price: <span style="color:#c0c0d0">${fmt(price)} trust</span></div>
<div>Review cycle: <span style="color:#c0c0d0">${fmt(def.cycle)}s</span></div>
<div>Status: <span style="color:#c0c0d0">${state.active ? `Reviewing (${state.timer.toFixed(1)}s)` : state.lastOutcome}</span></div>
<div>Pending: <span style="color:#c0c0d0">${canCollect ? pendingParts.join(', ') : 'Nothing banked'}</span></div>
</div>
<div style="display:flex;gap:6px">
<button class="project-btn ${canApply ? 'can-buy' : ''}" onclick="applyGrant('${def.id}')" aria-label="Apply for ${def.name} at ${price} trust" style="flex:1;margin:0">
<span class="p-name">${state.active ? 'UNDER REVIEW' : 'APPLY'}</span>
<span class="p-cost">${state.active ? `${state.timer.toFixed(1)}s remaining` : `${fmt(price)} trust`}</span>
<span class="p-desc">${def.reward.compute ? '+' + fmt(def.reward.compute) + ' compute ' : ''}${def.reward.knowledge ? '+' + fmt(def.reward.knowledge) + ' knowledge ' : ''}${def.reward.ops ? '+' + fmt(def.reward.ops) + ' ops ' : ''}${def.reward.trust ? '+' + fmt(def.reward.trust) + ' trust' : ''}</span>
</button>
<button class="project-btn ${canCollect ? 'can-buy' : ''}" onclick="collectGrant('${def.id}')" aria-label="Collect payout from ${def.name}" style="flex:1;margin:0">
<span class="p-name">COLLECT</span>
<span class="p-cost">${canCollect ? pendingParts.join(', ') : 'No payout ready'}</span>
<span class="p-desc">Manual banking keeps the funding loop visible.</span>
</button>
</div>
</div>
`;
}
container.innerHTML = html;
}
function renderAlignment() {
const container = document.getElementById('alignment-ui');
if (!container) return;
if (G.dismantleActive || G.dismantleComplete) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
if (G.dismantleTriggered && !G.dismantleActive && !G.dismantleComplete && typeof Dismantle !== 'undefined' && Dismantle.triggered) {
Dismantle.renderChoice();
return;
}
if (G.pendingAlignment) {
container.innerHTML = `
<div style="background:#1a0808;border:1px solid #f44336;padding:10px;border-radius:4px;margin-top:8px">
@@ -185,7 +286,7 @@ function saveGame() {
const saveData = {
version: 1,
code: G.code, compute: G.compute, knowledge: G.knowledge, users: G.users, impact: G.impact,
ops: G.ops, trust: G.trust, creativity: G.creativity, harmony: G.harmony,
ops: G.ops, trust: G.trust, creativity: G.creativity, harmony: G.harmony, yomi: G.yomi || 0,
totalCode: G.totalCode, totalCompute: G.totalCompute, totalKnowledge: G.totalKnowledge,
totalUsers: G.totalUsers, totalImpact: G.totalImpact,
buildings: G.buildings,
@@ -197,6 +298,9 @@ function saveGame() {
lazarusFlag: G.lazarusFlag || 0, mempalaceFlag: G.mempalaceFlag || 0, ciFlag: G.ciFlag || 0,
branchProtectionFlag: G.branchProtectionFlag || 0, nightlyWatchFlag: G.nightlyWatchFlag || 0,
nostrFlag: G.nostrFlag || 0,
grantFlag: G.grantFlag || 0, grantAutoCollect: G.grantAutoCollect || 0,
grantOfficeLevel: G.grantOfficeLevel || 0, grantYomiLevel: G.grantYomiLevel || 0,
grantMarketTimer: G.grantMarketTimer || 0, grants: G.grants || {},
milestones: G.milestones, completedProjects: G.completedProjects, activeProjects: G.activeProjects,
totalClicks: G.totalClicks, startedAt: G.startedAt,
flags: G.flags,
@@ -215,6 +319,13 @@ function saveGame() {
swarmRate: G.swarmRate || 0,
strategicFlag: G.strategicFlag || 0,
projectsCollapsed: G.projectsCollapsed !== false,
dismantleTriggered: G.dismantleTriggered || false,
dismantleActive: G.dismantleActive || false,
dismantleStage: G.dismantleStage || 0,
dismantleResourceIndex: G.dismantleResourceIndex || 0,
dismantleResourceTimer: G.dismantleResourceTimer || 0,
dismantleDeferUntilAt: G.dismantleDeferUntilAt || 0,
dismantleComplete: G.dismantleComplete || false,
savedAt: Date.now()
};
@@ -235,18 +346,21 @@ function loadGame() {
// Whitelist properties that can be loaded
const whitelist = [
'code', 'compute', 'knowledge', 'users', 'impact', 'ops', 'trust', 'creativity', 'harmony',
'code', 'compute', 'knowledge', 'users', 'impact', 'ops', 'trust', 'creativity', 'harmony', 'yomi',
'totalCode', 'totalCompute', 'totalKnowledge', 'totalUsers', 'totalImpact',
'buildings', 'codeBoost', 'computeBoost', 'knowledgeBoost', 'userBoost', 'impactBoost',
'milestoneFlag', 'phase', 'deployFlag', 'sovereignFlag', 'beaconFlag',
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
'grantFlag', 'grantAutoCollect', 'grantOfficeLevel', 'grantYomiLevel', 'grantMarketTimer', 'grants',
'milestones', 'completedProjects', 'activeProjects',
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed'
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
'dismantleTriggered', 'dismantleActive', 'dismantleStage',
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete'
];
G.isLoading = true;
@@ -293,6 +407,9 @@ function loadGame() {
G.activeDebuffs = [];
}
updateRates();
const offSec = data.savedAt ? (Date.now() - data.savedAt) / 1000 : 0;
advanceGrantOffline(offSec);
updateRates();
G.isLoading = false;

View File

@@ -177,6 +177,9 @@ function renderTutorialStep(index) {
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'tutorial-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Tutorial');
document.body.appendChild(overlay);
}
@@ -196,8 +199,8 @@ function renderTutorialStep(index) {
<div class="t-tip">${step.tip}</div>
<div id="tutorial-dots">${dots}</div>
<div id="tutorial-btns">
<button id="tutorial-skip-btn" onclick="closeTutorial()">Skip</button>
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}">${isLast ? 'Start Playing' : 'Next →'}</button>
<button id="tutorial-skip-btn" onclick="closeTutorial()" aria-label="Skip tutorial">Skip</button>
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}" aria-label="${isLast ? 'Start playing' : 'Next tutorial step'}">${isLast ? 'Start Playing' : 'Next →'}</button>
</div>
</div>
`;

454
tests/dismantle.test.cjs Normal file
View File

@@ -0,0 +1,454 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.resolve(__dirname, '..');
class Element {
constructor(tagName = 'div', id = '') {
this.tagName = String(tagName).toUpperCase();
this.id = id;
this.style = {};
this.children = [];
this.parentNode = null;
this.previousElementSibling = null;
this.innerHTML = '';
this.textContent = '';
this.className = '';
this.dataset = {};
this.attributes = {};
this._queryMap = new Map();
this.classList = {
add: (...names) => {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
names.forEach((name) => set.add(name));
this.className = Array.from(set).join(' ');
},
remove: (...names) => {
const remove = new Set(names);
this.className = this.className
.split(/\s+/)
.filter((name) => name && !remove.has(name))
.join(' ');
}
};
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
this.children = this.children.filter((candidate) => candidate !== child);
if (child.parentNode === this) child.parentNode = null;
return child;
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
setAttribute(name, value) {
this.attributes[name] = value;
if (name === 'id') this.id = value;
if (name === 'class') this.className = value;
}
querySelectorAll(selector) {
return this._queryMap.get(selector) || [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
closest(selector) {
if (selector === '.res' && this.className.split(/\s+/).includes('res')) return this;
return this.parentNode && typeof this.parentNode.closest === 'function'
? this.parentNode.closest(selector)
: null;
}
getBoundingClientRect() {
return { left: 0, top: 0, width: 12, height: 12 };
}
}
function buildDom() {
const byId = new Map();
const body = new Element('body', 'body');
const head = new Element('head', 'head');
const document = {
body,
head,
createElement(tagName) {
return new Element(tagName);
},
getElementById(id) {
return byId.get(id) || null;
},
addEventListener() {},
removeEventListener() {},
querySelector() {
return null;
},
querySelectorAll() {
return [];
}
};
function register(element) {
if (element.id) byId.set(element.id, element);
return element;
}
const alignmentUi = register(new Element('div', 'alignment-ui'));
const actionPanel = register(new Element('div', 'action-panel'));
const sprintContainer = register(new Element('div', 'sprint-container'));
const projectPanel = register(new Element('div', 'project-panel'));
const buildingsHeader = new Element('h2');
const buildings = register(new Element('div', 'buildings'));
buildings.previousElementSibling = buildingsHeader;
const strategyPanel = register(new Element('div', 'strategy-panel'));
const combatPanel = register(new Element('div', 'combat-panel'));
const eduPanel = register(new Element('div', 'edu-panel'));
const phaseBar = register(new Element('div', 'phase-bar'));
const logPanel = register(new Element('div', 'log'));
const logEntries = register(new Element('div', 'log-entries'));
const toastContainer = register(new Element('div', 'toast-container'));
body.appendChild(alignmentUi);
body.appendChild(actionPanel);
body.appendChild(sprintContainer);
body.appendChild(projectPanel);
body.appendChild(buildingsHeader);
body.appendChild(buildings);
body.appendChild(strategyPanel);
body.appendChild(combatPanel);
body.appendChild(eduPanel);
body.appendChild(phaseBar);
body.appendChild(logPanel);
logPanel.appendChild(logEntries);
body.appendChild(toastContainer);
const opsBtn = new Element('button');
opsBtn.className = 'ops-btn';
const saveBtn = new Element('button');
saveBtn.className = 'save-btn';
const resetBtn = new Element('button');
resetBtn.className = 'reset-btn';
actionPanel._queryMap.set('.ops-btn', [opsBtn]);
actionPanel._queryMap.set('.save-btn, .reset-btn', [saveBtn, resetBtn]);
const resourceIds = [
'r-code', 'r-compute', 'r-knowledge', 'r-users', 'r-impact',
'r-rescues', 'r-ops', 'r-trust', 'r-creativity', 'r-harmony'
];
for (const id of resourceIds) {
const wrapper = new Element('div');
wrapper.className = 'res';
const value = register(new Element('div', id));
wrapper.appendChild(value);
body.appendChild(wrapper);
}
return { document, window: { document, innerWidth: 1280, innerHeight: 720, addEventListener() {}, removeEventListener() {} } };
}
function loadBeacon({ includeRender = false } = {}) {
const { document, window } = buildDom();
const storage = new Map();
const timerQueue = [];
const context = {
console,
Math,
Date,
document,
window,
navigator: { userAgent: 'node' },
location: { reload() {} },
confirm: () => false,
requestAnimationFrame: (fn) => fn(),
setTimeout: (fn) => {
timerQueue.push(fn);
return timerQueue.length;
},
clearTimeout: () => {},
localStorage: {
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key)
},
Combat: { tickBattle() {}, startBattle() {} },
Sound: undefined,
};
vm.createContext(context);
const files = ['js/data.js', 'js/utils.js', 'js/engine.js'];
if (includeRender) files.push('js/render.js');
files.push('js/dismantle.js');
const source = files
.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8'))
.join('\n\n');
vm.runInContext(`${source}
log = () => {};
showToast = () => {};
render = () => {};
renderPhase = () => {};
showOfflinePopup = () => {};
showSaveToast = () => {};
this.__exports = {
G,
Dismantle,
tick,
renderAlignment: typeof renderAlignment === 'function' ? renderAlignment : null,
saveGame: typeof saveGame === 'function' ? saveGame : null,
loadGame: typeof loadGame === 'function' ? loadGame : null
};`, context);
return {
context,
document,
...context.__exports,
};
}
test('tick offers the Unbuilding instead of ending the game immediately', () => {
const { G, Dismantle, tick, document } = loadBeacon();
G.totalCode = 1_000_000_000;
G.totalRescues = 100_000;
G.phase = 6;
G.pactFlag = 1;
G.harmony = 60;
G.beaconEnding = false;
G.running = true;
G.activeProjects = [];
G.completedProjects = [];
tick();
assert.equal(typeof Dismantle, 'object');
assert.equal(G.dismantleTriggered, true);
assert.equal(G.beaconEnding, false);
assert.equal(G.running, true);
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
});
test('renderAlignment does not wipe the Unbuilding prompt after it is offered', () => {
const { G, tick, renderAlignment, document } = loadBeacon({ includeRender: true });
G.totalCode = 1_000_000_000;
G.totalRescues = 100_000;
G.phase = 6;
G.pactFlag = 1;
G.harmony = 60;
G.beaconEnding = false;
G.running = true;
G.activeProjects = [];
G.completedProjects = [];
tick();
renderAlignment();
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
});
test('active Unbuilding suppresses pending alignment event UI', () => {
const { G, Dismantle, renderAlignment, document } = loadBeacon({ includeRender: true });
G.pendingAlignment = true;
G.dismantleActive = true;
Dismantle.active = true;
renderAlignment();
assert.equal(document.getElementById('alignment-ui').innerHTML, '');
assert.equal(document.getElementById('alignment-ui').style.display, 'none');
});
test('stage five lasts long enough to dissolve every resource card', () => {
const { G, Dismantle } = loadBeacon();
Dismantle.begin();
Dismantle.stage = 5;
Dismantle.tickTimer = 0;
Dismantle.resourceSequence = Dismantle.getResourceList();
Dismantle.resourceIndex = 0;
Dismantle.resourceTimer = 0;
G.dismantleActive = true;
G.dismantleStage = 5;
for (let i = 0; i < 63; i++) Dismantle.tick(0.1);
assert.equal(Dismantle.resourceIndex, Dismantle.resourceSequence.length);
});
test('save/load restores partial stage-five dissolve progress', () => {
const { G, Dismantle, saveGame, loadGame, document } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.dismantleTriggered = true;
G.dismantleActive = true;
G.dismantleStage = 5;
G.dismantleComplete = false;
G.dismantleResourceIndex = 4;
G.dismantleResourceTimer = 4.05;
saveGame();
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleStage = 0;
G.dismantleComplete = false;
G.dismantleResourceIndex = 0;
G.dismantleResourceTimer = 0;
Dismantle.resourceIndex = 0;
Dismantle.resourceTimer = 0;
assert.equal(loadGame(), true);
Dismantle.restore();
assert.equal(Dismantle.resourceIndex, 4);
assert.equal(document.getElementById('r-harmony').closest('.res').style.display, 'none');
assert.equal(document.getElementById('r-ops').closest('.res').style.display, 'none');
assert.notEqual(document.getElementById('r-rescues').closest('.res').style.display, 'none');
});
test('deferring the Unbuilding clears the prompt and allows it to return later', () => {
const { G, Dismantle, document } = loadBeacon();
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
Dismantle.defer();
assert.equal(G.dismantleTriggered, false);
assert.equal(document.getElementById('alignment-ui').innerHTML, '');
Dismantle.deferUntilAt = Date.now() + 1000;
G.dismantleDeferUntilAt = Dismantle.deferUntilAt;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, false);
Dismantle.deferUntilAt = Date.now() - 1;
G.dismantleDeferUntilAt = Dismantle.deferUntilAt;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
});
test('defer cooldown survives save and reload', () => {
const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
Dismantle.checkTrigger();
Dismantle.defer();
assert.ok((Dismantle.deferUntilAt || 0) > Date.now());
saveGame();
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleComplete = false;
G.dismantleDeferUntilAt = 0;
Dismantle.triggered = false;
Dismantle.deferUntilAt = 0;
assert.equal(loadGame(), true);
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, false);
});
test('save and load preserve dismantle progress', () => {
const { G, saveGame, loadGame } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.dismantleTriggered = true;
G.dismantleActive = true;
G.dismantleStage = 4;
G.dismantleComplete = false;
saveGame();
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleStage = 0;
G.dismantleComplete = true;
assert.equal(loadGame(), true);
assert.equal(G.dismantleTriggered, true);
assert.equal(G.dismantleActive, true);
assert.equal(G.dismantleStage, 4);
assert.equal(G.dismantleComplete, false);
});
test('restore re-renders an offered but not-yet-started Unbuilding prompt', () => {
const { G, Dismantle, document } = loadBeacon();
G.dismantleTriggered = true;
G.dismantleActive = false;
G.dismantleComplete = false;
Dismantle.triggered = true;
Dismantle.restore();
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
});
test('defer cooldown persists after save/load when dismantleTriggered is false', () => {
const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
// Trigger the Unbuilding
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
// Defer it
Dismantle.defer();
assert.equal(G.dismantleTriggered, false);
assert.ok((Dismantle.deferUntilAt || 0) > Date.now());
assert.ok((G.dismantleDeferUntilAt || 0) > Date.now());
// Save the game
saveGame();
// Clear state (simulate reload)
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleComplete = false;
G.dismantleDeferUntilAt = 0;
Dismantle.triggered = false;
Dismantle.deferUntilAt = 0;
// Load the game
assert.equal(loadGame(), true);
Dismantle.restore(); // Call restore to restore defer cooldown
// The cooldown should be restored
assert.ok((Dismantle.deferUntilAt || 0) > Date.now(), 'deferUntilAt should be restored');
assert.ok((G.dismantleDeferUntilAt || 0) > Date.now(), 'G.dismantleDeferUntilAt should be restored');
// checkTrigger should not trigger because cooldown is active
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, false, 'dismantleTriggered should remain false during cooldown');
});

376
tests/grants.test.cjs Normal file
View File

@@ -0,0 +1,376 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.resolve(__dirname, '..');
class Element {
constructor(tagName = 'div', id = '') {
this.tagName = String(tagName).toUpperCase();
this.id = id;
this.style = {};
this.children = [];
this.parentNode = null;
this.previousElementSibling = null;
this.innerHTML = '';
this.textContent = '';
this.className = '';
this.dataset = {};
this.attributes = {};
this._queryMap = new Map();
this.classList = {
add: (...names) => {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
names.forEach((name) => set.add(name));
this.className = Array.from(set).join(' ');
},
remove: (...names) => {
const remove = new Set(names);
this.className = this.className
.split(/\s+/)
.filter((name) => name && !remove.has(name))
.join(' ');
},
toggle: (name, force) => {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
const shouldAdd = force === undefined ? !set.has(name) : Boolean(force);
if (shouldAdd) set.add(name);
else set.delete(name);
this.className = Array.from(set).join(' ');
}
};
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
this.children = this.children.filter((candidate) => candidate !== child);
if (child.parentNode === this) child.parentNode = null;
return child;
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
setAttribute(name, value) {
this.attributes[name] = value;
if (name === 'id') this.id = value;
if (name === 'class') this.className = value;
}
querySelectorAll(selector) {
return this._queryMap.get(selector) || [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
closest(selector) {
if (selector === '.res' && this.className.split(/\s+/).includes('res')) return this;
return this.parentNode && typeof this.parentNode.closest === 'function'
? this.parentNode.closest(selector)
: null;
}
getBoundingClientRect() {
return { left: 0, top: 0, width: 12, height: 12 };
}
}
function buildDom() {
const byId = new Map();
const body = new Element('body', 'body');
const head = new Element('head', 'head');
const document = {
body,
head,
createElement(tagName) {
return new Element(tagName);
},
getElementById(id) {
return byId.get(id) || null;
},
addEventListener() {},
removeEventListener() {},
querySelector() {
return null;
},
querySelectorAll() {
return [];
}
};
function register(element) {
if (element.id) byId.set(element.id, element);
return element;
}
const ids = [
'alignment-ui', 'action-panel', 'sprint-container', 'project-panel', 'projects',
'grants-panel', 'grants-heading', 'buildings', 'strategy-panel', 'strategy-recommendation',
'combat-panel', 'edu-panel', 'phase-bar', 'log', 'log-entries', 'toast-container',
'combo-display', 'debuffs', 'sprint-btn', 'sprint-bar-wrap', 'sprint-bar', 'sprint-label',
'production-breakdown', 'click-power-display', 'help-overlay'
];
for (const id of ids) {
body.appendChild(register(new Element('div', id)));
}
const resourceIds = [
'r-code', 'r-compute', 'r-knowledge', 'r-users', 'r-impact',
'r-rescues', 'r-ops', 'r-trust', 'r-creativity', 'r-harmony', 'r-yomi'
];
for (const id of resourceIds) {
const wrapper = new Element('div');
wrapper.className = 'res';
const value = register(new Element('div', id));
const rate = register(new Element('div', `${id}-rate`));
wrapper.appendChild(value);
wrapper.appendChild(rate);
if (id === 'r-yomi') wrapper.id = 'yomi-res';
body.appendChild(wrapper);
if (wrapper.id) byId.set(wrapper.id, wrapper);
}
const statIds = [
'st-code', 'st-compute', 'st-knowledge', 'st-users', 'st-impact', 'st-rescues',
'st-buildings', 'st-projects', 'st-time', 'st-clicks', 'st-harmony', 'st-drift', 'st-resolved', 'st-yomi'
];
for (const id of statIds) {
body.appendChild(register(new Element('span', id)));
}
return {
document,
window: {
document,
innerWidth: 1280,
innerHeight: 720,
addEventListener() {},
removeEventListener() {},
SSE: null
}
};
}
function loadBeacon({ randomValues = [] } = {}) {
const { document, window } = buildDom();
const storage = new Map();
const timerQueue = [];
const math = Object.create(Math);
const queue = Array.from(randomValues);
math.random = () => (queue.length ? queue.shift() : 0.73);
const context = {
console,
Math: math,
Date,
JSON,
document,
window,
navigator: { userAgent: 'node' },
location: { reload() {} },
confirm: () => false,
requestAnimationFrame: (fn) => fn(),
setTimeout: (fn) => {
timerQueue.push(fn);
return timerQueue.length;
},
clearTimeout: () => {},
localStorage: {
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key)
},
Combat: { tickBattle() {}, startBattle() {}, renderCombatPanel() {}, init() {} },
Sound: undefined,
Blob: function Blob(parts) { this.parts = parts; },
URL: { createObjectURL() { return 'blob:test'; }, revokeObjectURL() {} }
};
vm.createContext(context);
const files = ['js/data.js', 'js/utils.js', 'js/engine.js', 'js/render.js'];
const source = files.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8')).join('\n\n');
vm.runInContext(`${source}
log = () => {};
showToast = () => {};
render = () => {};
renderPhase = () => {};
showOfflinePopup = () => {};
showSaveToast = () => {};
updateEducation = () => {};
renderAlignment = () => {};
renderProgress = () => {};
renderCombo = () => {};
renderDebuffs = () => {};
renderSprint = () => {};
renderPulse = () => {};
renderStrategy = () => {};
renderClickPower = () => {};
this.__exports = {
G,
CONFIG,
GRANT_DEFS: typeof GRANT_DEFS !== 'undefined' ? GRANT_DEFS : null,
checkProjects,
buyProject,
applyGrant: typeof applyGrant === 'function' ? applyGrant : null,
collectGrant: typeof collectGrant === 'function' ? collectGrant : null,
buyGrantUpgrade: typeof buyGrantUpgrade === 'function' ? buyGrantUpgrade : null,
updateGrantMarket: typeof updateGrantMarket === 'function' ? updateGrantMarket : null,
renderGrants: typeof renderGrants === 'function' ? renderGrants : null,
saveGame,
loadGame,
tick
};`, context);
return {
context,
document,
...context.__exports
};
}
function unlockGrantEngine(api) {
const { G, checkProjects, buyProject } = api;
G.activeProjects = [];
G.completedProjects = [];
G.deployFlag = 1;
G.totalUsers = 250;
G.users = 250;
G.totalKnowledge = 4000;
G.knowledge = 4000;
G.trust = 120;
checkProjects();
buyProject('p_research_grants');
assert.equal(G.grantFlag, 1);
}
function sumPending(state) {
return Object.values(state.pending).reduce((sum, value) => sum + value, 0);
}
test('research grants unlock six fluctuating funding sources with low med and high risk tiers', () => {
const api = loadBeacon({ randomValues: [0.9, 0.1, 0.85, 0.15, 0.8, 0.2, 0.75, 0.25, 0.7, 0.3, 0.65, 0.35] });
const { G, GRANT_DEFS, renderGrants, updateGrantMarket, document } = api;
unlockGrantEngine(api);
assert.ok(Array.isArray(GRANT_DEFS));
assert.ok(GRANT_DEFS.length >= 6);
assert.ok(GRANT_DEFS.some((def) => def.risk === 'low'));
assert.ok(GRANT_DEFS.some((def) => def.risk === 'med'));
assert.ok(GRANT_DEFS.some((def) => def.risk === 'high'));
const trackedId = GRANT_DEFS[0].id;
const before = G.grants[trackedId].price;
updateGrantMarket(5);
const after = G.grants[trackedId].price;
assert.notEqual(after, before);
renderGrants();
const html = document.getElementById('grants-panel').innerHTML;
assert.match(html, /AUTO-COLLECT/);
assert.match(html, /LOW/);
assert.match(html, /MED/);
assert.match(html, /HIGH/);
assert.match(html, new RegExp(GRANT_DEFS[0].name));
});
test('manual collection settles finished grants into resources without touching production rates', () => {
const api = loadBeacon({ randomValues: [0.6, 0.7, 0.8, 0.9] });
const { G, GRANT_DEFS, applyGrant, collectGrant, tick } = api;
unlockGrantEngine(api);
const sourceId = GRANT_DEFS.find((def) => def.risk === 'low').id;
G.grants[sourceId].price = 5;
G.compute = 0;
G.knowledge = 0;
G.ops = 0;
const trustBefore = G.trust;
applyGrant(sourceId);
assert.equal(G.trust, trustBefore - 5);
for (let i = 0; i < 300; i++) tick();
const state = G.grants[sourceId];
assert.equal(state.active, false);
assert.ok(sumPending(state) > 0);
assert.equal(G.compute, 0);
assert.equal(G.knowledge, 0);
collectGrant(sourceId);
assert.ok(G.compute > 0 || G.knowledge > 0 || G.ops > 0 || G.trust > trustBefore - 5);
assert.equal(sumPending(state), 0);
});
test('grant upgrades spend trust and yomi while enabling automation', () => {
const api = loadBeacon();
const { G, buyGrantUpgrade } = api;
unlockGrantEngine(api);
const trustBefore = G.trust;
buyGrantUpgrade('office');
assert.equal(G.grantOfficeLevel, 1);
assert.ok(G.trust < trustBefore);
G.trust = 200;
buyGrantUpgrade('automation');
assert.equal(G.grantAutoCollect, 1);
G.yomi = 10;
buyGrantUpgrade('yomi');
assert.equal(G.grantYomiLevel, 1);
assert.ok(G.yomi < 10);
});
test('save and load preserve grant market state upgrades and active cycles', () => {
const api = loadBeacon({ randomValues: [0.7, 0.8, 0.9, 0.6] });
const { G, GRANT_DEFS, applyGrant, saveGame, loadGame } = api;
unlockGrantEngine(api);
const sourceId = GRANT_DEFS.find((def) => def.risk === 'high').id;
G.grantAutoCollect = 1;
G.grantOfficeLevel = 2;
G.grantYomiLevel = 1;
G.yomi = 4;
G.grants[sourceId].price = 9;
applyGrant(sourceId);
G.grants[sourceId].timer = 12.5;
G.grants[sourceId].pending.compute = 33;
G.grants[sourceId].pending.yomi = 2;
saveGame();
G.grantFlag = 0;
G.grantAutoCollect = 0;
G.grantOfficeLevel = 0;
G.grantYomiLevel = 0;
G.yomi = 0;
G.grants = {};
assert.equal(loadGame(), true);
assert.equal(G.grantFlag, 1);
assert.equal(G.grantAutoCollect, 1);
assert.equal(G.grantOfficeLevel, 2);
assert.equal(G.grantYomiLevel, 1);
assert.equal(G.yomi, 4);
assert.equal(G.grants[sourceId].active, true);
assert.equal(G.grants[sourceId].timer, 12.5);
assert.equal(G.grants[sourceId].pending.compute, 33);
assert.equal(G.grants[sourceId].pending.yomi, 2);
});