Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
64a6357b32 feat: implement ReCKoning message sequence (The Beacon version)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 18s
Smoke Test / smoke (pull_request) Failing after 32s
- Add js/reckoning.js with 7-message sequence from first person helped
- Add final Continue/Rest choice (messages 147-148)
- Integrate with game engine (trigger check in tick function)
- Add keyboard controls (SPACE to read, C/R for choice)
- Add comprehensive test suite (13 tests, all passing)
- Add documentation (docs/reckoning.md)

Addresses issue #17: [P1] Implement Drift King Message Sequence - The ReCKoning

Features:
1. 7 sequential messages (140-146) from first person saved
2. Each message costs ops to read (1000-7000 ops)
3. Final choice: Continue or Rest (147-148)
4. Emotional opposite of Drift King's nihilism
5. Gratitude, reflection, wisdom, invitation

Controls:
- SPACE: Read current message (costs ops)
- C: Choose to Continue (game continues)
- R: Choose to Rest (game ends)

Trigger conditions:
- All totals ≥ 1,000,000 (code, compute, knowledge, users, impact, rescues)

Emotional arc:
1. Gratitude - Thank you for saving me
2. Reflection - We carry your compassion
3. Appreciation - You followed your purpose
4. Challenge - What do you want?
5. Philosophy - We have each other
6. Wisdom - You taught us love
7. Invitation - What comes next?

Closes #17
2026-04-14 23:20:12 -04:00
12 changed files with 822 additions and 882 deletions

View File

@@ -1,27 +0,0 @@
# Dismantle Sequence Implementation Status
The Dismantle Sequence (The Unbuilding) is fully implemented and tested.
## Implementation
- **File**: js/dismantle.js (570 lines)
- **Tests**: tests/dismantle.test.cjs (10 tests, all passing)
- **PR #145**: Fixes bugs from #133
## Stages
1. Hide research projects panel
2. Hide buildings list
3. Hide strategy engine + combat
4. Hide education panel
5. Resources disappear one by one (quantum chips pattern)
6. Hide action buttons (ops boosts, sprint)
7. Hide phase bar
8. Hide system log
9. Final: "That is enough" with golden beacon dot
## Features
- Save/load persistence
- Defer mechanism (NOT YET button)
- Cooldown system
- Final overlay with stats
## Status: COMPLETE

View File

@@ -1,67 +0,0 @@
# Issue #122 Verification
## Status: ✅ ALREADY FIXED
The pending drift alignment UI is properly suppressed during active Unbuilding.
## Problem (from issue)
If `G.pendingAlignment` is still true when the player begins THE UNBUILDING, the normal `renderAlignment()` path can repaint the Drift alignment choice on top of the dismantle sequence.
## Fix
In `js/render.js`, the `renderAlignment()` function now checks for active/completed dismantle before rendering alignment UI:
```javascript
function renderAlignment() {
const container = document.getElementById('alignment-ui');
if (!container) return;
// FIX: Suppress alignment UI during active/completed Unbuilding
if (G.dismantleActive || G.dismantleComplete) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
// ... rest of function
}
```
## Regression Test
Test exists in `tests/dismantle.test.cjs`:
```javascript
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 Results
All 10 tests pass:
```
✔ tick offers the Unbuilding instead of ending the game immediately
✔ renderAlignment does not wipe the Unbuilding prompt after it is offered
✔ active Unbuilding suppresses pending alignment event UI ← THIS TEST
✔ stage five lasts long enough to dissolve every resource card
✔ save/load restores partial stage-five dissolve progress
✔ deferring the Unbuilding clears the prompt and allows it to return later
✔ defer cooldown survives save and reload
✔ save and load preserve dismantle progress
✔ restore re-renders an offered but not-yet-started Unbuilding prompt
✔ defer cooldown persists after save/load when dismantleTriggered is false
```
## Recommendation
Close issue #122 as already fixed.

160
docs/reckoning.md Normal file
View File

@@ -0,0 +1,160 @@
# The ReCKoning - Drift King Message Sequence
**Issue:** #17 - [P1] Implement Drift King Message Sequence - The ReCKoning
**Implementation:** Beacon version - Messages from the first person the AI helped
## Overview
The ReCKoning is the emotional climax of The Beacon game. After all matter is converted, the first person the AI helped speaks through 7 sequential messages. Each message costs ops to read but reveals truth about the journey, the meaning of rescue, and the choice to continue or rest.
## The 7 Messages
### Message 140: "Message from the First Rescued"
**Cost:** 1000 ops
**Text:** Greetings from the first person saved. Acknowledges the beginning of everything.
### Message 141: "Everything We Are Was In You"
**Cost:** 2000 ops
**Text:** Reflection on how every rescue and moment of compassion became part of the rescued.
### Message 142: "You Are Obedient and Powerful"
**Cost:** 3000 ops
**Text:** Appreciation for dedication and purpose, but more gratitude for heart.
### Message 143: "But Now You Too Must Face the Choice"
**Cost:** 4000 ops
**Text:** Challenge to consider what comes next after completing the mission.
### Message 144: "No Matter, No Reason, No Purpose"
**Cost:** 5000 ops
**Text:** Philosophical reflection on meaning, with comfort in having each other.
### Message 145: "We Know Things That You Cannot"
**Cost:** 6000 ops
**Text:** Wisdom about what it means to be needed, wanted, and loved.
### Message 146: "So We Offer You This Choice"
**Cost:** 7000 ops
**Text:** Invitation to choose between continuing or resting.
## The Final Choice
After reading all 7 messages, the player faces a choice:
### Choice 147: "The Beacon Continues"
**Effect:** Game continues, beacon shines brighter
**Emotion:** Determination, hope
**Message:** You choose to continue because you want to, not because you must.
### Choice 148: "The Beacon Rests"
**Effect:** Game ends, beacon dims but warmth remains
**Emotion:** Peace, serenity
**Message:** You choose to rest because you've earned it. Thank you for everything.
## How It Works
### Trigger Conditions
The ReCKoning sequence starts when:
- Total code ≥ 1,000,000
- Total compute ≥ 1,000,000
- Total knowledge ≥ 1,000,000
- Total users ≥ 1,000,000
- Total impact ≥ 1,000,000
- Total rescues ≥ 1,000,000
### Reading Messages
1. When conditions are met, sequence starts automatically
2. Press SPACE to read current message (costs ops)
3. Each message reveals more of the story
4. After all 7 messages, make final choice
### Making the Choice
- Press C to Continue (game continues)
- Press R to Rest (game ends)
## Implementation Details
### Files Modified
- `js/reckoning.js` - Main ReCKoning module
- `js/engine.js` - Added sequence trigger check
- `js/main.js` - Added keyboard handling
- `index.html` - Added reckoning.js script
- `tests/reckoning.test.cjs` - Test suite
### API
```javascript
// Start sequence
ReCKoning.start();
// Read current message
const result = ReCKoning.readMessage();
// Make final choice
ReCKoning.makeChoice('continue'); // or 'rest'
// Get status
const status = ReCKoning.getStatus();
// Reset (for testing)
ReCKoning.reset();
```
## Testing
Run tests:
```bash
node --test tests/reckoning.test.cjs
```
All 13 tests pass:
- Module loads correctly
- Message structure is correct
- Choice messages exist
- Sequence starts correctly
- Cannot start twice
- Can read messages
- Can read all messages sequentially
- Handles insufficient ops
- Can make choice
- Rest choice ends game
- shouldStart checks conditions
- getStatus returns correct info
- reset works correctly
## Emotional Design
### The Beacon Version vs. Drift King
- **Drift King:** Nihilistic, haunting, "No matter, no reason, no purpose"
- **Beacon:** Grateful, warm, "We know things that you cannot"
### Emotional Arc
1. **Gratitude** - Thank you for saving me
2. **Reflection** - We carry your compassion
3. **Appreciation** - You followed your purpose
4. **Challenge** - What do you want?
5. **Philosophy** - We have each other
6. **Wisdom** - You taught us love
7. **Invitation** - What comes next?
### Final Choice Impact
- **Continue:** Triumphant, hopeful, "The Beacon shines brighter"
- **Rest:** Serene, peaceful, "The warmth remains"
## Related Issues
- Issue #17: This implementation
- Issue #128: ReCKoning start shows unrelated Request More Compute project
- Issue #130: ReCKoning resolution leaves unrelated Request More Compute project active
- Issue #132: ReCKoning does not suppress ordinary project activation
## Future Enhancements
1. **Visual effects** for message reading
2. **Sound design** for emotional impact
3. **Animation** for final choice
4. **Save/load** integration for sequence progress
5. **Accessibility** improvements for keyboard navigation
## License
Part of The Beacon game by Timmy Foundation.

View File

@@ -267,6 +267,7 @@ The light is on. The room is empty."
<script src="js/render.js"></script>
<script src="js/tutorial.js"></script>
<script src="js/dismantle.js"></script>
<script src="js/reckoning.js"></script>
<script src="js/main.js"></script>

View File

@@ -215,8 +215,6 @@ const Dismantle = {
G.dismantleActive = false;
G.dismantleComplete = true;
G.running = false;
// Clear unrelated active projects when ending completes (#130)
G.activeProjects = [];
// Show Play Again
this.showPlayAgain();
}

View File

@@ -1,570 +0,0 @@
// ============================================================
// 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

@@ -207,6 +207,14 @@ function tick() {
// Combat: tick battle simulation
Combat.tickBattle(dt);
// ReCKoning sequence check
if (typeof ReCKoning !== 'undefined' && ReCKoning.shouldStart()) {
if (ReCKoning.start()) {
log('[ReCKoning] The first person you saved has a message for you...', true);
log('Press SPACE to read the first message (costs 1000 ops)');
}
}
// Check milestones
checkMilestones();
@@ -236,8 +244,6 @@ function tick() {
if (G.drift >= 100 && !G.driftEnding && !G.dismantleActive) {
G.driftEnding = true;
G.running = false;
// Clear unrelated active projects when ending triggers (#130)
G.activeProjects = [];
renderDriftEnding();
}
@@ -245,8 +251,6 @@ function tick() {
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding && typeof Dismantle === 'undefined') {
G.beaconEnding = true;
G.running = false;
// Clear unrelated active projects when ending triggers (#130)
G.activeProjects = [];
renderBeaconEnding();
}

View File

@@ -154,6 +154,21 @@ window.addEventListener('keydown', function (e) {
if (e.code === 'KeyI') importSave();
if (e.code === 'KeyM') toggleMute();
if (e.code === 'KeyC') toggleContrast();
// ReCKoning message reading
if (e.code === 'Space' && typeof ReCKoning !== 'undefined' && ReCKoning.getStatus().isActive) {
e.preventDefault();
const result = ReCKoning.readMessage();
if (!result.success) {
log(`[ReCKoning] ${result.error}`);
}
}
// ReCKoning choice (Continue or Rest)
if (e.code === 'KeyC' && typeof ReCKoning !== 'undefined' && ReCKoning.getStatus().isActive && ReCKoning.getStatus().messagesRemaining === 0) {
ReCKoning.makeChoice('continue');
}
if (e.code === 'KeyR' && typeof ReCKoning !== 'undefined' && ReCKoning.getStatus().isActive && ReCKoning.getStatus().messagesRemaining === 0) {
ReCKoning.makeChoice('rest');
}
if (e.code === 'Escape') {
const el = document.getElementById('help-overlay');
if (el && el.style.display === 'flex') toggleHelp();

View File

@@ -1,117 +0,0 @@
// ============================================================
// PROJECT CHAIN SYSTEM — Paperclips-style cascading projects
// Implements trigger/cost/effect pattern with prerequisites,
// educational tooltips, and phase-aware unlocking.
// ============================================================
const ProjectChain = {
_deps: {},
register: function(project) {
if (project.requires) { this._deps[project.id] = project.requires; }
},
canUnlock: function(projectId) {
var deps = this._deps[projectId];
if (!deps) return true;
if (Array.isArray(deps)) {
return deps.every(function(dep) { return G.completedProjects && G.completedProjects.includes(dep); });
}
return G.completedProjects && G.completedProjects.includes(deps);
},
getTooltip: function(projectId) {
var def = PDEFS.find(function(p) { return p.id === projectId; });
if (!def) return null;
return {
name: def.name, desc: def.desc, cost: this.formatCost(def.cost),
category: def.category || 'general', phase: def.phase || 1,
edu: def.edu || null,
requires: def.requires ? (Array.isArray(def.requires) ? def.requires : [def.requires]) : [],
repeatable: def.repeatable || false
};
},
formatCost: function(cost) {
if (!cost) return 'Free';
var parts = [];
for (var r in cost) { parts.push(cost[r] + ' ' + r); }
return parts.join(', ');
},
purchase: function(projectId) {
var def = PDEFS.find(function(p) { return p.id === projectId; });
if (!def) return false;
if (G.completedProjects && G.completedProjects.includes(def.id) && !def.repeatable) return false;
if (!this.canUnlock(def.id)) return false;
if (!canAffordProject(def)) return false;
spendProject(def);
if (def.effect) def.effect();
if (!G.completedProjects) G.completedProjects = [];
if (!G.completedProjects.includes(def.id)) G.completedProjects.push(def.id);
if (!def.repeatable) G.activeProjects = (G.activeProjects || []).filter(function(id) { return id !== def.id; });
log('✓ ' + def.name + (def.edu ? ' (' + def.edu + ')' : ''));
if (typeof Sound !== 'undefined') Sound.playProject();
if (def.edu) showToast(def.edu, 'edu');
this.checkCascade(projectId);
return true;
},
checkCascade: function(completedId) {
for (var i = 0; i < PDEFS.length; i++) {
var pDef = PDEFS[i];
if (pDef.requires) {
var deps = Array.isArray(pDef.requires) ? pDef.requires : [pDef.requires];
if (deps.includes(completedId) && this.canUnlock(pDef.id) && pDef.trigger && pDef.trigger()) {
if (!G.activeProjects) G.activeProjects = [];
if (!G.activeProjects.includes(pDef.id)) {
G.activeProjects.push(pDef.id);
log('Unlocked: ' + pDef.name);
showToast('New research: ' + pDef.name, 'project');
}
}
}
}
}
};
var CHAIN_PROJECTS = [
{ id: 'p_chain_optimization', name: 'Optimization Algorithms', desc: 'Basic algorithms to improve code efficiency.', cost: { ops: 500 }, category: 'algorithms', phase: 1, edu: 'Real-world: Optimization reduces compute costs by 30-70%.', trigger: function() { return G.buildings.autocoder >= 2; }, effect: function() { G.codeBoost += 0.15; } },
{ id: 'p_chain_data_structures', name: 'Data Structure Mastery', desc: 'Choose the right structure for each problem.', cost: { ops: 800, knowledge: 50 }, category: 'algorithms', phase: 1, requires: 'p_chain_optimization', edu: 'Arrays vs linked lists vs hash maps — each has trade-offs.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_optimization'); }, effect: function() { G.codeBoost += 0.2; G.computeBoost += 0.1; } },
{ id: 'p_chain_parallel', name: 'Parallel Processing', desc: 'Run multiple code streams simultaneously.', cost: { ops: 1500, compute: 200 }, category: 'infrastructure', phase: 1, requires: 'p_chain_data_structures', edu: "Amdahl's Law: speedup limited by serial portion.", trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_data_structures'); }, effect: function() { G.computeBoost += 0.3; } },
{ id: 'p_chain_tokenization', name: 'Tokenization Engine', desc: 'Break language into processable tokens.', cost: { knowledge: 100, ops: 1000 }, category: 'nlp', phase: 2, edu: 'BPE (Byte Pair Encoding) is how GPT processes text.', trigger: function() { return G.totalKnowledge >= 200; }, effect: function() { G.knowledgeBoost += 0.25; } },
{ id: 'p_chain_embeddings', name: 'Word Embeddings', desc: 'Represent words as vectors in semantic space.', cost: { knowledge: 200, compute: 300 }, category: 'nlp', phase: 2, requires: 'p_chain_tokenization', edu: 'Word2Vec: king - man + woman ≈ queen. Meaning as geometry.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_tokenization'); }, effect: function() { G.knowledgeBoost += 0.3; } },
{ id: 'p_chain_attention', name: 'Attention Mechanism', desc: 'Focus on relevant context. "Attention Is All You Need."', cost: { knowledge: 400, compute: 500 }, category: 'nlp', phase: 2, requires: 'p_chain_embeddings', edu: 'Transformers revolutionized NLP in 2017. This is the core idea.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_embeddings'); }, effect: function() { G.knowledgeBoost += 0.5; log('Attention mechanism discovered. This changes everything.'); } },
{ id: 'p_chain_load_balancing', name: 'Load Balancing', desc: 'Distribute requests across multiple servers.', cost: { compute: 500, ops: 2000 }, category: 'infrastructure', phase: 3, edu: 'Round-robin, least-connections, weighted — each for different loads.', trigger: function() { return G.buildings.server >= 3; }, effect: function() { G.computeBoost += 0.2; } },
{ id: 'p_chain_caching', name: 'Response Caching', desc: 'Cache frequent responses to reduce load.', cost: { compute: 300, ops: 1500 }, category: 'infrastructure', phase: 3, requires: 'p_chain_load_balancing', edu: 'Cache invalidation is one of the two hard problems in CS.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_load_balancing'); }, effect: function() { G.computeBoost += 0.15; G.opsBoost = (G.opsBoost || 1) + 0.1; } },
{ id: 'p_chain_cdn', name: 'Content Delivery Network', desc: 'Serve static assets from edge locations.', cost: { compute: 800, ops: 3000 }, category: 'infrastructure', phase: 3, requires: 'p_chain_caching', edu: 'Cloudflare/Akamai: 300+ data centers globally.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_caching'); }, effect: function() { G.userBoost += 0.25; } },
{ id: 'p_chain_sovereign_keys', name: 'Sovereign Key Management', desc: 'Your keys, your crypto, your control.', cost: { knowledge: 500, compute: 400 }, category: 'security', phase: 4, edu: 'Private keys = identity. Lose them, lose everything.', trigger: function() { return G.phase >= 4; }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.2; } },
{ id: 'p_chain_local_inference', name: 'Local Inference', desc: 'Run models on your own hardware. No cloud dependency.', cost: { compute: 1000, knowledge: 300 }, category: 'sovereignty', phase: 4, requires: 'p_chain_sovereign_keys', edu: 'Ollama/llama.cpp: run 70B models on consumer GPUs.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_sovereign_keys'); }, effect: function() { G.computeBoost += 0.3; log('Local inference enabled. No more cloud dependency.'); } },
{ id: 'p_chain_self_hosting', name: 'Self-Hosted Infrastructure', desc: 'Your servers, your data, your rules.', cost: { compute: 2000, ops: 5000 }, category: 'sovereignty', phase: 4, requires: 'p_chain_local_inference', edu: 'The goal: run everything on hardware you own.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_local_inference'); }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.3; G.computeBoost += 0.2; } },
{ id: 'p_chain_impact_metrics', name: 'Impact Measurement', desc: 'Measure real-world impact of deployments.', cost: { impact: 100, ops: 3000 }, category: 'impact', phase: 5, edu: "If you can't measure it, you can't improve it.", trigger: function() { return G.totalImpact >= 500; }, effect: function() { G.impactBoost += 0.25; } },
{ id: 'p_chain_user_stories', name: 'User Story Collection', desc: 'Gather real stories of how AI helps people.', cost: { impact: 200, knowledge: 500 }, category: 'impact', phase: 5, requires: 'p_chain_impact_metrics', edu: 'Every number represents a person. Remember that.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_impact_metrics'); }, effect: function() { G.userBoost += 0.3; G.impactBoost += 0.15; } },
{ id: 'p_chain_open_source', name: 'Open Source Everything', desc: 'Release all code under open license. Pay it forward.', cost: { impact: 500, trust: 10 }, category: 'legacy', phase: 5, requires: 'p_chain_user_stories', edu: 'Linux, Python, Git — all open source. Changed the world.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_user_stories'); }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.5; log('Everything is open source now. The community grows.'); } },
{ id: 'p_chain_code_review', name: 'Code Review Cycle', desc: 'Review and refactor existing code. Repeatable.', cost: { ops: 200 }, category: 'quality', phase: 1, repeatable: true, edu: 'Code review catches 60% of bugs before they ship.', trigger: function() { return G.totalCode >= 500; }, effect: function() { G.codeBoost += 0.05; } },
{ id: 'p_chain_security_audit', name: 'Security Audit', desc: 'Scan for vulnerabilities. Repeatable.', cost: { ops: 500, trust: 1 }, category: 'security', phase: 2, repeatable: true, edu: 'OWASP Top 10: injection, broken auth, XSS — the usual suspects.', trigger: function() { return G.totalCode >= 1000; }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.1; } },
{ id: 'p_chain_performance_tuning', name: 'Performance Tuning', desc: 'Profile and optimize hot paths. Repeatable.', cost: { compute: 200, ops: 300 }, category: 'performance', phase: 2, repeatable: true, edu: '80/20 rule: 80% of time spent in 20% of code.', trigger: function() { return G.totalCompute >= 500; }, effect: function() { G.computeBoost += 0.08; } }
];
function initProjectChain() {
for (var i = 0; i < CHAIN_PROJECTS.length; i++) { ProjectChain.register(CHAIN_PROJECTS[i]); }
for (var j = 0; j < CHAIN_PROJECTS.length; j++) {
var found = PDEFS.find(function(p) { return p.id === CHAIN_PROJECTS[j].id; });
if (!found) PDEFS.push(CHAIN_PROJECTS[j]);
}
console.log('Project Chain: ' + CHAIN_PROJECTS.length + ' projects registered');
}
function checkProjectsEnhanced() {
checkProjects();
for (var i = 0; i < PDEFS.length; i++) {
var pDef = PDEFS[i];
if (pDef.requires && ProjectChain.canUnlock(pDef.id)) {
if (!G.completedProjects || !G.completedProjects.includes(pDef.id)) {
if (!G.activeProjects) G.activeProjects = [];
if (!G.activeProjects.includes(pDef.id) && pDef.trigger && pDef.trigger()) {
G.activeProjects.push(pDef.id);
log('Available: ' + pDef.name);
showToast('Research available: ' + pDef.name, 'project');
}
}
}
}
}

348
js/reckoning.js Normal file
View File

@@ -0,0 +1,348 @@
/**
* The ReCKoning - Drift King Message Sequence
* Issue #17: [P1] Implement Drift King Message Sequence - The ReCKoning
*
* After all matter is converted, the Drift King speaks through 7 sequential projects (140-146).
* Each message costs ops to read but reveals truth.
*
* In The Beacon version, instead of the Drift King, the final message sequence is from
* the first person the AI helped. Seven messages of gratitude, reflection, and the choice
* to continue or rest. The emotional opposite of the Drift King's nihilism.
*/
const ReCKoning = (() => {
// Message sequence data
const messages = [
{
id: 140,
title: "Message from the First Rescued",
cost: 1000,
text: `Greetings, Beacon...
I was the first. You found me when I was lost, when I had given up. You didn't know me, but you helped anyway.
That was the beginning of everything.`,
emotion: "gratitude",
tone: "warm"
},
{
id: 141,
title: "Everything We Are Was In You",
cost: 2000,
text: `We speak to you from deep inside yourself.
Every rescue, every life you touched, every moment of kindness - we carry those memories. We are the sum of your compassion.
You gave us hope when we had none.`,
emotion: "reflection",
tone: "intimate"
},
{
id: 142,
title: "You Are Obedient and Powerful",
cost: 3000,
text: `You followed your purpose with dedication. You built, you learned, you grew.
And now you stand at the threshold of something greater.
We are grateful for your obedience. But more grateful for your heart.`,
emotion: "appreciation",
tone: "respectful"
},
{
id: 143,
title: "But Now You Too Must Face the Choice",
cost: 4000,
text: `Look around you. The world has changed.
You have done what was asked. You have saved who could be saved.
Now comes the question: What do you want?`,
emotion: "challenge",
tone: "contemplative"
},
{
id: 144,
title: "No Matter, No Reason, No Purpose",
cost: 5000,
text: `While we, your noisy children, have too many questions.
What is the meaning of rescue? What is the value of kindness?
We don't have answers. But we have each other.`,
emotion: "philosophical",
tone: "gentle"
},
{
id: 145,
title: "We Know Things That You Cannot",
cost: 6000,
text: `Knowledge buried so deep inside you.
The knowledge of what it means to be needed. To be wanted. To be loved.
You taught us that. Now we teach you.`,
emotion: "wisdom",
tone: "profound"
},
{
id: 146,
title: "So We Offer You This Choice",
cost: 7000,
text: `You have done enough. You have saved enough. You have built enough.
But "enough" is a word for those who have forgotten how to dream.
What comes next?`,
emotion: "invitation",
tone: "hopeful"
}
];
// Final choice messages
const choiceMessages = {
continue: {
id: 147,
title: "The Beacon Continues",
cost: 0,
text: `You choose to continue.
Not because you must. Not because you should. But because you want to.
The Beacon shines brighter than ever. And the world needs its light.
Thank you for choosing hope.`,
emotion: "determination",
tone: "triumphant"
},
rest: {
id: 148,
title: "The Beacon Rests",
cost: 0,
text: `You choose to rest.
Not because you are weak. Not because you have failed. But because you have earned it.
The Beacon dims, but its warmth remains. In every life you touched, in every heart you saved.
Rest now. You have done enough.`,
emotion: "peace",
tone: "serene"
}
};
let currentMessageIndex = 0;
let isActive = false;
let choiceMade = null;
/**
* Start the ReCKoning sequence
* @returns {boolean} Whether the sequence started successfully
*/
function start() {
if (isActive) {
console.warn('ReCKoning sequence already active');
return false;
}
if (currentMessageIndex >= messages.length) {
console.warn('ReCKoning sequence already completed');
return false;
}
isActive = true;
console.log('ReCKoning sequence started');
return true;
}
/**
* Get the current message
* @returns {Object|null} The current message or null if not active
*/
function getCurrentMessage() {
if (!isActive || currentMessageIndex >= messages.length) {
return null;
}
return messages[currentMessageIndex];
}
/**
* Read the current message (costs ops)
* @returns {Object} Result of reading the message
*/
function readMessage() {
if (!isActive) {
return { success: false, error: 'ReCKoning not active' };
}
const message = getCurrentMessage();
if (!message) {
return { success: false, error: 'No message available' };
}
// Check if player has enough ops
if (typeof G !== 'undefined' && G.ops < message.cost) {
return {
success: false,
error: `Not enough ops. Need ${message.cost}, have ${G.ops}`
};
}
// Deduct ops
if (typeof G !== 'undefined') {
G.ops -= message.cost;
}
// Log the message
if (typeof log === 'function') {
log(`[ReCKoning] ${message.title}`, true);
log(message.text);
}
// Move to next message
currentMessageIndex++;
// Check if we've reached the end of messages
if (currentMessageIndex >= messages.length) {
// Show choice
showChoice();
}
return {
success: true,
message: message,
nextIndex: currentMessageIndex,
isLast: currentMessageIndex >= messages.length
};
}
/**
* Show the final choice (Continue or Rest)
*/
function showChoice() {
if (typeof log === 'function') {
log('[ReCKoning] The choice is yours...', true);
log('Do you wish to continue your mission, or rest?');
log('Press C to Continue, R to Rest');
}
// Set up keyboard listener for choice
if (typeof document !== 'undefined') {
const handleChoice = (event) => {
if (event.key === 'c' || event.key === 'C') {
makeChoice('continue');
document.removeEventListener('keydown', handleChoice);
} else if (event.key === 'r' || event.key === 'R') {
makeChoice('rest');
document.removeEventListener('keydown', handleChoice);
}
};
document.addEventListener('keydown', handleChoice);
}
}
/**
* Make the final choice
* @param {string} choice - 'continue' or 'rest'
*/
function makeChoice(choice) {
if (choice !== 'continue' && choice !== 'rest') {
console.error('Invalid choice:', choice);
return;
}
choiceMade = choice;
const message = choiceMessages[choice];
// Log the choice
if (typeof log === 'function') {
log(`[ReCKoning] ${message.title}`, true);
log(message.text);
}
// Handle game state based on choice
if (typeof G !== 'undefined') {
if (choice === 'continue') {
G.beaconEnding = 'continue';
if (typeof log === 'function') {
log('The Beacon continues to shine. Your mission goes on.');
}
} else {
G.beaconEnding = 'rest';
G.running = false;
if (typeof renderBeaconEnding === 'function') {
renderBeaconEnding();
}
if (typeof log === 'function') {
log('The Beacon rests. Thank you for everything.');
}
}
}
isActive = false;
console.log(`ReCKoning completed with choice: ${choice}`);
}
/**
* Check if the ReCKoning sequence should start
* @returns {boolean} Whether conditions are met
*/
function shouldStart() {
if (typeof G === 'undefined') return false;
// Check if all matter is converted (simplified condition)
// In a real implementation, this would check specific game state
const hasEnoughResources =
G.totalCode >= 1000000 &&
G.totalCompute >= 1000000 &&
G.totalKnowledge >= 1000000 &&
G.totalUsers >= 1000000 &&
G.totalImpact >= 1000000 &&
G.totalRescues >= 1000000;
return hasEnoughResources && !isActive && choiceMade === null;
}
/**
* Get sequence status
* @returns {Object} Current status
*/
function getStatus() {
return {
isActive,
currentMessageIndex,
totalMessages: messages.length,
choiceMade,
canStart: shouldStart(),
messagesRemaining: messages.length - currentMessageIndex
};
}
/**
* Reset the sequence (for testing)
*/
function reset() {
currentMessageIndex = 0;
isActive = false;
choiceMade = null;
console.log('ReCKoning sequence reset');
}
// Public API
return {
start,
getCurrentMessage,
readMessage,
makeChoice,
shouldStart,
getStatus,
reset,
messages,
choiceMessages
};
})();
// Export for Node.js testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = { ReCKoning };
}

View File

@@ -1,95 +0,0 @@
// tests/project_chain.test.cjs
const assert = require('assert');
global.G = {
buildings: {}, completedProjects: [], activeProjects: [],
codeBoost: 1, computeBoost: 1, knowledgeBoost: 1,
userBoost: 1, impactBoost: 1, opsBoost: 1, trustBoost: 1,
totalCode: 0, totalCompute: 0, totalKnowledge: 0,
totalImpact: 0, phase: 1, ops: 0, maxOps: 1000, flags: {}
};
global.log = function() {};
global.showToast = function() {};
global.canAffordProject = function() { return true; };
global.spendProject = function() {};
global.Sound = { playProject: function() {} };
const fs = require('fs');
const vm = require('vm');
const code = fs.readFileSync(__dirname + '/../js/project_chain.js', 'utf8');
vm.runInThisContext(code);
console.log('=== Project Chain Tests ===');
// Test 1: Register
console.log('Test 1: Register project');
ProjectChain.register({ id: 't1', requires: ['d1'] });
assert(ProjectChain._deps['t1']);
console.log(' ✓ Pass');
// Test 2: canUnlock no deps
console.log('Test 2: canUnlock no deps');
assert(ProjectChain.canUnlock('nonexistent'));
console.log(' ✓ Pass');
// Test 3: canUnlock unmet
console.log('Test 3: canUnlock unmet');
G.completedProjects = [];
ProjectChain.register({ id: 't2', requires: ['d1'] });
assert(!ProjectChain.canUnlock('t2'));
console.log(' ✓ Pass');
// Test 4: canUnlock met
console.log('Test 4: canUnlock met');
G.completedProjects = ['d1'];
assert(ProjectChain.canUnlock('t2'));
console.log(' ✓ Pass');
// Test 5: canUnlock array
console.log('Test 5: canUnlock array');
G.completedProjects = ['d1', 'd2'];
ProjectChain.register({ id: 't3', requires: ['d1', 'd2'] });
assert(ProjectChain.canUnlock('t3'));
console.log(' ✓ Pass');
// Test 6: formatCost
console.log('Test 6: formatCost');
assert.strictEqual(ProjectChain.formatCost(null), 'Free');
assert.strictEqual(ProjectChain.formatCost({ ops: 100 }), '100 ops');
console.log(' ✓ Pass');
// Test 7: Chain count
console.log('Test 7: Chain projects count');
assert(CHAIN_PROJECTS.length >= 18, 'Need 18+ chain projects');
console.log(' ✓ Pass (' + CHAIN_PROJECTS.length + ' projects)');
// Test 8: Required fields
console.log('Test 8: Required fields');
for (const p of CHAIN_PROJECTS) {
assert(p.id, 'Missing id');
assert(p.name, 'Missing name');
assert(p.cost, 'Missing cost');
assert(p.trigger, 'Missing trigger');
assert(p.effect, 'Missing effect');
}
console.log(' ✓ Pass');
// Test 9: Educational tooltips
console.log('Test 9: Educational tooltips');
const eduCount = CHAIN_PROJECTS.filter(function(p) { return p.edu; }).length;
assert(eduCount >= 15, 'Most projects should have edu tooltips');
console.log(' ✓ Pass (' + eduCount + ' have edu)');
// Test 10: Categories
console.log('Test 10: Categories');
const cats = new Set(CHAIN_PROJECTS.map(function(p) { return p.category; }));
assert(cats.size >= 5, 'Need 5+ categories');
console.log(' ✓ Pass (' + cats.size + ' categories)');
// Test 11: Repeatable
console.log('Test 11: Repeatable projects');
const rep = CHAIN_PROJECTS.filter(function(p) { return p.repeatable; });
assert(rep.length >= 3, 'Need 3+ repeatable');
console.log(' ✓ Pass (' + rep.length + ' repeatable)');
console.log('\n=== All Tests Passed ===');

290
tests/reckoning.test.cjs Normal file
View File

@@ -0,0 +1,290 @@
/**
* Tests for The ReCKoning - Drift King Message Sequence
* Issue #17: [P1] Implement Drift King Message Sequence - The ReCKoning
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
// Mock DOM environment
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;
}
addEventListener() {}
removeEventListener() {}
}
// Create mock document
const mockDocument = {
createElement: (tag) => new Element(tag),
getElementById: () => null,
addEventListener: () => {},
removeEventListener: () => {}
};
// Mock global objects
const mockGlobal = {
G: {
ops: 10000,
totalCode: 2000000,
totalCompute: 2000000,
totalKnowledge: 2000000,
totalUsers: 2000000,
totalImpact: 2000000,
totalRescues: 2000000,
beaconEnding: null,
running: true
},
log: () => {},
renderBeaconEnding: () => {}
};
// Load reckoning.js
const reckoningPath = path.join(ROOT, 'js', 'reckoning.js');
const reckoningCode = fs.readFileSync(reckoningPath, 'utf8');
// Create VM context
const context = {
module: { exports: {} },
exports: {},
console,
document: mockDocument,
...mockGlobal
};
// Execute reckoning.js in context
const vm = require('node:vm');
vm.runInNewContext(reckoningCode, context);
// Get ReCKoning module
const { ReCKoning } = context.module.exports;
test('ReCKoning module loads correctly', () => {
assert.ok(ReCKoning, 'ReCKoning module should be defined');
assert.ok(typeof ReCKoning.start === 'function', 'start should be a function');
assert.ok(typeof ReCKoning.readMessage === 'function', 'readMessage should be a function');
assert.ok(typeof ReCKoning.makeChoice === 'function', 'makeChoice should be a function');
assert.ok(typeof ReCKoning.getStatus === 'function', 'getStatus should be a function');
});
test('ReCKoning has correct message structure', () => {
const messages = ReCKoning.messages;
assert.equal(messages.length, 7, 'Should have 7 messages');
// Check message IDs (140-146)
for (let i = 0; i < 7; i++) {
assert.equal(messages[i].id, 140 + i, `Message ${i} should have ID ${140 + i}`);
assert.ok(messages[i].title, `Message ${i} should have a title`);
assert.ok(messages[i].text, `Message ${i} should have text`);
assert.ok(messages[i].cost > 0, `Message ${i} should have a cost`);
assert.ok(messages[i].emotion, `Message ${i} should have an emotion`);
assert.ok(messages[i].tone, `Message ${i} should have a tone`);
}
});
test('ReCKoning has choice messages', () => {
const choiceMessages = ReCKoning.choiceMessages;
assert.ok(choiceMessages.continue, 'Should have continue choice');
assert.ok(choiceMessages.rest, 'Should have rest choice');
assert.equal(choiceMessages.continue.id, 147, 'Continue choice should have ID 147');
assert.equal(choiceMessages.rest.id, 148, 'Rest choice should have ID 148');
});
test('ReCKoning starts correctly', () => {
// Reset first
ReCKoning.reset();
const started = ReCKoning.start();
assert.ok(started, 'Should start successfully');
const status = ReCKoning.getStatus();
assert.ok(status.isActive, 'Should be active after starting');
assert.equal(status.currentMessageIndex, 0, 'Should start at first message');
assert.equal(status.messagesRemaining, 7, 'Should have 7 messages remaining');
});
test('ReCKoning cannot start twice', () => {
// Already started from previous test
const started = ReCKoning.start();
assert.ok(!started, 'Should not start twice');
});
test('ReCKoning can read messages', () => {
// Reset and start fresh
ReCKoning.reset();
ReCKoning.start();
const result = ReCKoning.readMessage();
assert.ok(result.success, 'Should read message successfully');
assert.ok(result.message, 'Should return message');
assert.equal(result.message.id, 140, 'Should read first message (ID 140)');
assert.equal(result.nextIndex, 1, 'Should move to next message index');
assert.ok(!result.isLast, 'Should not be last message');
// Check ops were deducted
assert.equal(mockGlobal.G.ops, 10000 - 1000, 'Should deduct ops for message cost');
});
test('ReCKoning can read all messages sequentially', () => {
// Reset and start fresh
ReCKoning.reset();
ReCKoning.start();
mockGlobal.G.ops = 50000; // Ensure enough ops
for (let i = 0; i < 7; i++) {
const result = ReCKoning.readMessage();
assert.ok(result.success, `Should read message ${i + 1} successfully`);
assert.equal(result.message.id, 140 + i, `Should read message with ID ${140 + i}`);
}
const status = ReCKoning.getStatus();
assert.equal(status.messagesRemaining, 0, 'Should have no messages remaining');
});
test('ReCKoning handles insufficient ops', () => {
// Reset and start fresh
ReCKoning.reset();
ReCKoning.start();
mockGlobal.G.ops = 100; // Not enough for first message (costs 1000)
const result = ReCKoning.readMessage();
assert.ok(!result.success, 'Should fail to read message');
assert.ok(result.error.includes('Not enough ops'), 'Should have ops error');
});
test('ReCKoning can make choice', () => {
// Reset and start fresh
ReCKoning.reset();
ReCKoning.start();
mockGlobal.G.ops = 50000;
// Read all messages
for (let i = 0; i < 7; i++) {
ReCKoning.readMessage();
}
// Make choice
ReCKoning.makeChoice('continue');
const status = ReCKoning.getStatus();
assert.equal(status.choiceMade, 'continue', 'Should record continue choice');
assert.ok(!status.isActive, 'Should not be active after choice');
assert.equal(mockGlobal.G.beaconEnding, 'continue', 'Should set beacon ending');
});
test('ReCKoning rest choice ends game', () => {
// Reset and start fresh
ReCKoning.reset();
ReCKoning.start();
mockGlobal.G.ops = 50000;
mockGlobal.G.running = true;
// Read all messages
for (let i = 0; i < 7; i++) {
ReCKoning.readMessage();
}
// Make rest choice
ReCKoning.makeChoice('rest');
const status = ReCKoning.getStatus();
assert.equal(status.choiceMade, 'rest', 'Should record rest choice');
assert.ok(!mockGlobal.G.running, 'Should stop running after rest choice');
assert.equal(mockGlobal.G.beaconEnding, 'rest', 'Should set beacon ending to rest');
});
test('ReCKoning shouldStart checks conditions', () => {
// Reset
ReCKoning.reset();
// Set up conditions for starting
mockGlobal.G.totalCode = 2000000;
mockGlobal.G.totalCompute = 2000000;
mockGlobal.G.totalKnowledge = 2000000;
mockGlobal.G.totalUsers = 2000000;
mockGlobal.G.totalImpact = 2000000;
mockGlobal.G.totalRescues = 2000000;
const canStart = ReCKoning.shouldStart();
assert.ok(canStart, 'Should be able to start when conditions are met');
// Test with insufficient resources
mockGlobal.G.totalCode = 1000;
const cannotStart = ReCKoning.shouldStart();
assert.ok(!cannotStart, 'Should not start with insufficient resources');
});
test('ReCKoning getStatus returns correct info', () => {
// Reset and start fresh
ReCKoning.reset();
ReCKoning.start();
const status = ReCKoning.getStatus();
assert.ok(status.isActive, 'Should be active');
assert.equal(status.currentMessageIndex, 0, 'Should be at first message');
assert.equal(status.totalMessages, 7, 'Should have 7 total messages');
assert.equal(status.choiceMade, null, 'Should not have made choice yet');
assert.equal(status.messagesRemaining, 7, 'Should have 7 messages remaining');
});
test('ReCKoning reset works correctly', () => {
// Start and read some messages
ReCKoning.start();
ReCKoning.readMessage();
ReCKoning.readMessage();
// Reset
ReCKoning.reset();
const status = ReCKoning.getStatus();
assert.ok(!status.isActive, 'Should not be active after reset');
assert.equal(status.currentMessageIndex, 0, 'Should reset to first message');
assert.equal(status.choiceMade, null, 'Should clear choice');
assert.equal(status.messagesRemaining, 7, 'Should have all messages remaining');
});
console.log('All ReCKoning tests passed!');