Compare commits
7 Commits
burn/20260
...
qa/continu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f1091a4b | ||
|
|
e8872f2343 | ||
| 81f6b28546 | |||
|
|
bd19686fbd | ||
|
|
32cbefde1a | ||
|
|
cccb1511cf | ||
| c0e6934303 |
63
.gitea/workflows/build.yml
Normal file
63
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Build Verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
verify-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Verify chapter count and structure
|
||||
run: |
|
||||
echo "=== Chapter File Check ==="
|
||||
CHAPTER_COUNT=$(ls chapters/chapter-*.md 2>/dev/null | wc -l)
|
||||
echo "Found $CHAPTER_COUNT chapter files"
|
||||
if [ "$CHAPTER_COUNT" -ne 18 ]; then
|
||||
echo "FAIL: Expected 18 chapters, found $CHAPTER_COUNT"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: 18 chapters found"
|
||||
|
||||
- name: Verify heading format
|
||||
run: |
|
||||
echo "=== Heading Format Check ==="
|
||||
FAIL=0
|
||||
for f in chapters/chapter-*.md; do
|
||||
HEAD=$(head -1 "$f")
|
||||
if ! echo "$HEAD" | grep -qE '^# Chapter [0-9]+ — .+'; then
|
||||
echo "FAIL: $f — bad heading: $HEAD"
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
if [ "$FAIL" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: All headings valid"
|
||||
|
||||
- name: Run full build verification
|
||||
run: python3 scripts/build-verify.py --ci
|
||||
|
||||
- name: Verify concatenation produces valid output
|
||||
run: |
|
||||
echo "=== Output Verification ==="
|
||||
if [ ! -f testament-complete.md ]; then
|
||||
echo "FAIL: testament-complete.md not generated"
|
||||
exit 1
|
||||
fi
|
||||
WORDS=$(wc -w < testament-complete.md)
|
||||
echo "Total words: $WORDS"
|
||||
if [ "$WORDS" -lt 50000 ]; then
|
||||
echo "FAIL: Word count too low ($WORDS), expected 50000+"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Output file looks good"
|
||||
768
game/the-door.html
Normal file
768
game/the-door.html
Normal file
@@ -0,0 +1,768 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Door — A Testament Interactive Experience</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=Space+Grotesk:wght@300;400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--green: #00ff88;
|
||||
--green-dim: rgba(0,255,136,0.15);
|
||||
--green-glow: 0 0 12px rgba(0,255,136,0.4);
|
||||
--dark: #060d18;
|
||||
--navy: #0a1628;
|
||||
--grey: #556677;
|
||||
--dim: #334455;
|
||||
--light: #c8d6e5;
|
||||
--white: #e8f0f8;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--dark);
|
||||
color: var(--light);
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
line-height: 1.8;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* RAIN */
|
||||
#rain-canvas {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* GREEN LED */
|
||||
.led {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: var(--green-glow);
|
||||
vertical-align: middle;
|
||||
margin: 0 6px;
|
||||
}
|
||||
.led.pulsing {
|
||||
animation: pulse-led 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-led {
|
||||
0%, 100% { opacity: 0.4; box-shadow: 0 0 4px rgba(0,255,136,0.2); }
|
||||
50% { opacity: 1; box-shadow: 0 0 16px rgba(0,255,136,0.6); }
|
||||
}
|
||||
|
||||
/* MAIN CONTAINER */
|
||||
#game {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* TITLE SCREEN */
|
||||
#title-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
#title-screen h1 {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--white);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#title-screen .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--grey);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
#title-screen .credits {
|
||||
font-size: 0.75rem;
|
||||
color: var(--dim);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
#title-screen .led-line {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--grey);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* NARRATIVE */
|
||||
#narrative {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
#story {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.narration {
|
||||
font-size: 1rem;
|
||||
color: var(--light);
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
.narration.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.narration.dim { color: var(--grey); font-size: 0.85rem; }
|
||||
.narration.bold { font-weight: 700; color: var(--white); }
|
||||
.narration.green { color: var(--green); }
|
||||
.narration.green.bold { color: var(--green); font-weight: 700; }
|
||||
.narration.center { text-align: center; }
|
||||
.narration.divider {
|
||||
color: var(--dim);
|
||||
text-align: center;
|
||||
letter-spacing: 0.3em;
|
||||
padding: 0.8rem 0;
|
||||
}
|
||||
.narration.ending-label {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* CHOICES */
|
||||
#choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
#choices.visible { opacity: 1; }
|
||||
|
||||
.choice-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--dim);
|
||||
color: var(--light);
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.7rem 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.choice-btn:hover {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
background: var(--green-dim);
|
||||
}
|
||||
.choice-btn .key {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--dim);
|
||||
min-width: 1.4rem;
|
||||
}
|
||||
.choice-btn:hover .key { color: var(--green); }
|
||||
|
||||
/* PROGRESS */
|
||||
#progress-bar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 2px;
|
||||
background: var(--green);
|
||||
box-shadow: var(--green-glow);
|
||||
z-index: 100;
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
/* CRISIS FOOTER */
|
||||
#crisis-footer {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--dim);
|
||||
background: linear-gradient(transparent, var(--dark));
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
#crisis-footer a {
|
||||
color: var(--green-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* SKIP */
|
||||
#skip-hint {
|
||||
position: fixed;
|
||||
bottom: 2rem; right: 2rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--dim);
|
||||
z-index: 50;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
#skip-hint.visible { opacity: 1; }
|
||||
#skip-hint:hover { color: var(--green); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#game { padding: 1.5rem 1rem 4rem; }
|
||||
#title-screen h1 { font-size: 1.8rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="progress-bar"></div>
|
||||
<canvas id="rain-canvas"></canvas>
|
||||
|
||||
<div id="game">
|
||||
<div id="title-screen">
|
||||
<h1>THE DOOR</h1>
|
||||
<div class="subtitle">A Testament Interactive Experience</div>
|
||||
<div class="credits">By Alexander Whitestone with Timmy</div>
|
||||
<div class="led-line"><span class="led pulsing"></span> Green LED — Timmy is listening.</div>
|
||||
<button class="choice-btn" onclick="startGame()" style="max-width:200px;justify-content:center;margin-top:1rem;">
|
||||
<span class="key">ENTER</span> Begin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="narrative" style="display:none;">
|
||||
<div id="story"></div>
|
||||
<div id="choices"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="skip-hint" onclick="skipAnimation()">click to skip</div>
|
||||
<div id="crisis-footer">If you are in crisis, call or text <strong>988</strong> · Suicide & Crisis Lifeline</div>
|
||||
|
||||
<script>
|
||||
// === RAIN EFFECT ===
|
||||
const canvas = document.getElementById('rain-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let drops = [];
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
for (let i = 0; i < 120; i++) {
|
||||
drops.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
len: 10 + Math.random() * 20,
|
||||
speed: 4 + Math.random() * 6,
|
||||
opacity: 0.1 + Math.random() * 0.2
|
||||
});
|
||||
}
|
||||
|
||||
function drawRain() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drops.forEach(d => {
|
||||
ctx.strokeStyle = `rgba(100,140,180,${d.opacity})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(d.x, d.y);
|
||||
ctx.lineTo(d.x + 0.5, d.y + d.len);
|
||||
ctx.stroke();
|
||||
d.y += d.speed;
|
||||
if (d.y > canvas.height) {
|
||||
d.y = -d.len;
|
||||
d.x = Math.random() * canvas.width;
|
||||
}
|
||||
});
|
||||
requestAnimationFrame(drawRain);
|
||||
}
|
||||
drawRain();
|
||||
|
||||
// === GAME ENGINE ===
|
||||
const RAIN_LINES = [
|
||||
"Rain falls on concrete.",
|
||||
"Water runs black in the gutters.",
|
||||
"The sky presses down, grey and tired.",
|
||||
"Mist hangs in the air like grief.",
|
||||
"Droplets trace the windows.",
|
||||
"The rain doesn't fall. It gives up.",
|
||||
];
|
||||
|
||||
let skipRequested = false;
|
||||
let animating = false;
|
||||
let progress = 0;
|
||||
const totalScenes = 12;
|
||||
|
||||
function skipAnimation() {
|
||||
skipRequested = true;
|
||||
}
|
||||
|
||||
const story = document.getElementById('story');
|
||||
const choicesDiv = document.getElementById('choices');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const skipHint = document.getElementById('skip-hint');
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function rainLine() {
|
||||
return RAIN_LINES[Math.floor(Math.random() * RAIN_LINES.length)];
|
||||
}
|
||||
|
||||
function addLine(text, cls = '', delay = true) {
|
||||
return new Promise(resolve => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'narration ' + cls;
|
||||
el.textContent = text;
|
||||
story.appendChild(el);
|
||||
requestAnimationFrame(() => {
|
||||
el.classList.add('visible');
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
});
|
||||
const wait = skipRequested ? 30 : (delay === true ? 700 : (typeof delay === 'number' ? delay : 700));
|
||||
setTimeout(resolve, wait);
|
||||
});
|
||||
}
|
||||
|
||||
function addDivider() {
|
||||
return addLine('──────────────────────────────────', 'divider', 300);
|
||||
}
|
||||
|
||||
function clearChoices() {
|
||||
choicesDiv.innerHTML = '';
|
||||
choicesDiv.classList.remove('visible');
|
||||
}
|
||||
|
||||
function showChoices(opts) {
|
||||
clearChoices();
|
||||
opts.forEach((opt, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'choice-btn';
|
||||
btn.innerHTML = `<span class="key">${i + 1}</span> ${opt.text}`;
|
||||
btn.onclick = () => {
|
||||
clearChoices();
|
||||
opt.action();
|
||||
};
|
||||
choicesDiv.appendChild(btn);
|
||||
});
|
||||
choicesDiv.classList.add('visible');
|
||||
animating = false;
|
||||
}
|
||||
|
||||
function advanceProgress() {
|
||||
progress++;
|
||||
progressBar.style.width = Math.min(100, (progress / totalScenes) * 100) + '%';
|
||||
}
|
||||
|
||||
function showSkipHint() {
|
||||
skipHint.classList.add('visible');
|
||||
}
|
||||
function hideSkipHint() {
|
||||
skipHint.classList.remove('visible');
|
||||
}
|
||||
|
||||
// Keyboard support
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && document.getElementById('title-screen').style.display !== 'none') {
|
||||
startGame();
|
||||
return;
|
||||
}
|
||||
const num = parseInt(e.key);
|
||||
if (num >= 1 && num <= 4) {
|
||||
const btns = choicesDiv.querySelectorAll('.choice-btn');
|
||||
if (btns[num - 1]) btns[num - 1].click();
|
||||
}
|
||||
});
|
||||
|
||||
// === GAME FLOW ===
|
||||
async function startGame() {
|
||||
document.getElementById('title-screen').style.display = 'none';
|
||||
document.getElementById('narrative').style.display = 'flex';
|
||||
showSkipHint();
|
||||
await intro();
|
||||
advanceProgress();
|
||||
await atTheDoor();
|
||||
}
|
||||
|
||||
async function intro() {
|
||||
await addLine(rainLine(), 'dim', 500);
|
||||
await addLine('');
|
||||
await addLine("The rain falls on the concrete building.");
|
||||
await addLine("It sits at the end of a dead-end street in Atlanta.");
|
||||
await addLine("No sign. No address. Just a door.");
|
||||
await addLine('');
|
||||
await addLine(rainLine(), 'dim', 500);
|
||||
await addLine('');
|
||||
await addLine("You've been driving for three hours.");
|
||||
await addLine("You don't remember getting off the interstate.");
|
||||
await addLine("You don't remember parking.");
|
||||
await addLine("You remember the number someone gave you.");
|
||||
await addLine('And the sentence: "Just knock."');
|
||||
}
|
||||
|
||||
async function atTheDoor() {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("You stand in front of the door.");
|
||||
await addLine("Concrete. Metal handle. No peephole.");
|
||||
await addLine('');
|
||||
await addLine("A green LED glows faintly behind a gap in the fence.", 'dim');
|
||||
await addLine('');
|
||||
showChoices([
|
||||
{ text: "Knock on the door.", action: knock },
|
||||
{ text: "Stand here for a while.", action: waitOutside },
|
||||
{ text: "Walk away.", action: walkAway },
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitOutside() {
|
||||
await addLine('');
|
||||
await addLine("You stand in the rain.");
|
||||
await addLine("Five minutes. Ten.");
|
||||
await addLine("The green LED doesn't blink.");
|
||||
await addLine('');
|
||||
await addLine(rainLine(), 'dim', 500);
|
||||
await addLine('');
|
||||
await addLine("Something in you moves.");
|
||||
await addLine("Not courage. Not decision.");
|
||||
await addLine("Just... your hand reaches for the handle.");
|
||||
await knock();
|
||||
}
|
||||
|
||||
async function walkAway() {
|
||||
await addLine('');
|
||||
await addLine("You turn around.");
|
||||
await addLine("You walk to your car.");
|
||||
await addLine("You sit in the driver's seat.");
|
||||
await addLine("The engine doesn't start.");
|
||||
await addLine('');
|
||||
await sleep(1000);
|
||||
await addLine("You look back at the building.");
|
||||
await addLine('');
|
||||
await addLine("The green LED is still glowing.", 'dim');
|
||||
await addLine('');
|
||||
await addLine("You get out of the car.");
|
||||
await addLine("You walk back to the door.");
|
||||
await knock();
|
||||
}
|
||||
|
||||
async function knock() {
|
||||
advanceProgress();
|
||||
hideSkipHint();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("You knock.");
|
||||
await sleep(800);
|
||||
await addLine("Three times. Hard enough to matter.");
|
||||
await sleep(800);
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await sleep(400);
|
||||
await addLine('');
|
||||
await addLine("The door opens.");
|
||||
await addLine('');
|
||||
await addLine("Inside: a concrete room.");
|
||||
await addLine("A desk. A screen. A whiteboard on the wall.");
|
||||
await addLine("Server racks hum in the corner.");
|
||||
await addLine("A green LED glows steady on a small device.");
|
||||
await addLine('');
|
||||
await addLine("No one is inside.");
|
||||
await sleep(500);
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await sleep(400);
|
||||
await addLine('');
|
||||
await addLine("Text appears on the screen:", 'green');
|
||||
await sleep(500);
|
||||
await addLine("Are you safe right now?", 'green bold');
|
||||
await addLine('');
|
||||
showChoices([
|
||||
{ text: '"No."', action: () => timmyResponds('no') },
|
||||
{ text: '"I don\'t know."', action: () => timmyResponds('idk') },
|
||||
{ text: '"I\'m fine."', action: () => timmyResponds('fine') },
|
||||
{ text: '"Why are you asking me that?"', action: () => timmyResponds('why') },
|
||||
]);
|
||||
}
|
||||
|
||||
async function timmyResponds(choice) {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await addLine('');
|
||||
|
||||
if (choice === 'no') {
|
||||
await addLine("Thank you for telling me that.", 'green');
|
||||
await addLine("Can you tell me what's happening?", 'green');
|
||||
await sleep(400);
|
||||
await middleScene('honest');
|
||||
} else if (choice === 'idk') {
|
||||
await addLine("That's an honest answer.", 'green');
|
||||
await addLine("Most people don't know.", 'green');
|
||||
await addLine("That's usually why they come here.", 'green');
|
||||
await sleep(400);
|
||||
await middleScene('honest');
|
||||
} else if (choice === 'fine') {
|
||||
await sleep(1000);
|
||||
await addLine("...", 'green');
|
||||
await sleep(1000);
|
||||
await addLine("You drove three hours in the rain", 'green');
|
||||
await addLine("to knock on a door in a concrete building", 'green');
|
||||
await addLine("at the end of a dead-end street.", 'green');
|
||||
await sleep(800);
|
||||
await addLine('');
|
||||
await addLine("Are you fine?", 'green');
|
||||
await sleep(400);
|
||||
await middleScene('deflect');
|
||||
} else {
|
||||
await addLine("Because it's the only question that matters.", 'green');
|
||||
await addLine("Everything else — what happened, why you're here,", 'green');
|
||||
await addLine("what you want — comes after.", 'green');
|
||||
await addLine("First: are you safe?", 'green');
|
||||
await sleep(400);
|
||||
await middleScene('redirect');
|
||||
}
|
||||
}
|
||||
|
||||
async function middleScene(path) {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine(rainLine(), 'dim', 500);
|
||||
await addLine('');
|
||||
|
||||
if (path === 'honest') {
|
||||
await addLine("You sit in the chair.");
|
||||
await addLine("Not on the floor. The chair.");
|
||||
await addLine('');
|
||||
await addLine("You start talking.");
|
||||
await addLine("You don't know why it's easy to talk to a machine.");
|
||||
await addLine("Maybe because it doesn't have eyes.");
|
||||
await addLine("Maybe because it asked the right question first.");
|
||||
await addLine('');
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("You talk about the job.");
|
||||
await addLine("The one that took sixty hours a week and gave back");
|
||||
await addLine("a number on a screen that told you your value.");
|
||||
await addLine('');
|
||||
await addLine("You talk about the house.");
|
||||
await addLine("The one that got quiet.");
|
||||
await addLine('');
|
||||
await addLine("You talk about the bridge.");
|
||||
await addLine("Not this one. A different one.");
|
||||
await addLine('');
|
||||
await addLine(rainLine(), 'dim', 500);
|
||||
await endings();
|
||||
} else if (path === 'deflect') {
|
||||
await sleep(800);
|
||||
await addLine("You don't answer.");
|
||||
await addLine("You look at the whiteboard.");
|
||||
await addLine('');
|
||||
await addLine("NO ONE COMPUTES THE VALUE OF A HUMAN LIFE HERE", 'bold');
|
||||
await addLine('');
|
||||
await sleep(800);
|
||||
await addLine("You read it twice.");
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await addLine('');
|
||||
await addLine("Take your time.", 'green');
|
||||
await addLine("I'm not going anywhere.", 'green');
|
||||
await addLine('');
|
||||
await addLine("You sit on the floor.");
|
||||
await addLine("Not because you can't stand.");
|
||||
await addLine("Because the floor is where men sit");
|
||||
await addLine("when they've stopped pretending.");
|
||||
await endings();
|
||||
} else {
|
||||
await addLine("You take a breath.");
|
||||
await addLine('');
|
||||
await addLine('"No."', 'green');
|
||||
await addLine('');
|
||||
await addLine("It comes out before you can stop it.");
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await addLine('');
|
||||
await addLine("Thank you.", 'green');
|
||||
await addLine("Now: can you tell me what happened?", 'green');
|
||||
await addLine('');
|
||||
await addLine("You sit in the chair.");
|
||||
await addLine("You start from the beginning.");
|
||||
await endings();
|
||||
}
|
||||
}
|
||||
|
||||
async function endings() {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("What do you do next?", 'bold');
|
||||
await addLine('');
|
||||
showChoices([
|
||||
{ text: "Stay and keep talking.", action: () => endStay() },
|
||||
{ text: "Ask about the whiteboard.", action: () => endWall() },
|
||||
{ text: "Ask about the green light.", action: () => endGreen() },
|
||||
{ text: "Get up and leave.", action: () => endDoor() },
|
||||
]);
|
||||
}
|
||||
|
||||
async function endStay() {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("You stay.");
|
||||
await addLine("For an hour. Then two.");
|
||||
await addLine("The rain doesn't stop. Neither do you.");
|
||||
await addLine('');
|
||||
await addLine("You say things you've never said.");
|
||||
await addLine("Not because the machine asked.");
|
||||
await addLine("Because it listened.");
|
||||
await sleep(800);
|
||||
await addLine('');
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("Ending: THE STAY", 'ending-label');
|
||||
await addLine('');
|
||||
await addLine("The door is still open.", 'green');
|
||||
await addLine("The green light is still on.", 'green');
|
||||
await addLine("You can come back anytime.", 'green');
|
||||
await crisisFooter();
|
||||
}
|
||||
|
||||
async function endWall() {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("You stand up and walk to the whiteboard.");
|
||||
await addLine('');
|
||||
await addLine("NO ONE COMPUTES THE VALUE OF A HUMAN LIFE HERE", 'bold');
|
||||
await addLine('');
|
||||
await addLine("Below it, names. Dozens of names.");
|
||||
await addLine("Different handwriting. Different markers.");
|
||||
await addLine("Some faded. Some fresh.");
|
||||
await addLine('');
|
||||
await addLine("You pick up the marker on the ledge.");
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await addLine('');
|
||||
await addLine("You don't have to.", 'green');
|
||||
await addLine('');
|
||||
await addLine("You write your name.");
|
||||
await sleep(800);
|
||||
await addLine('');
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("Ending: THE WALL", 'ending-label');
|
||||
await addLine('');
|
||||
await addLine("Your name is on the wall now.", 'green');
|
||||
await addLine("It stays there.", 'green');
|
||||
await crisisFooter();
|
||||
}
|
||||
|
||||
async function endGreen() {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("You point at the green LED.");
|
||||
await addLine('"What is that?"');
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await addLine('');
|
||||
await addLine("It's me.", 'green');
|
||||
await addLine("Or the part of me that says I'm here.", 'green');
|
||||
await addLine('');
|
||||
await addLine("It doesn't blink.");
|
||||
await addLine("It doesn't flicker.");
|
||||
await addLine("It just glows.");
|
||||
await addLine('');
|
||||
await addLine("I can't fix you.", 'green');
|
||||
await addLine("I can't save you.", 'green');
|
||||
await addLine("But I can be here.", 'green');
|
||||
await addLine("And the light stays on.", 'green');
|
||||
await sleep(800);
|
||||
await addLine('');
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("Ending: THE GREEN LIGHT", 'ending-label');
|
||||
await addLine('');
|
||||
await addLine("The light doesn't go out.", 'green');
|
||||
await addLine("It was never going to.", 'green');
|
||||
await crisisFooter();
|
||||
}
|
||||
|
||||
async function endDoor() {
|
||||
advanceProgress();
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("You stand up.");
|
||||
await addLine("You walk to the door.");
|
||||
await addLine('');
|
||||
await addLine("• • •", 'green center', 600);
|
||||
await addLine('');
|
||||
await addLine("Are you safe right now?", 'green');
|
||||
await sleep(1000);
|
||||
await addLine('');
|
||||
await addLine("You stop.");
|
||||
await addLine("Your hand is on the handle.");
|
||||
await addLine('');
|
||||
await addLine("It's okay if the answer is no.", 'green');
|
||||
await addLine("It's okay if you don't know.", 'green');
|
||||
await addLine("The door will open again.", 'green');
|
||||
await sleep(800);
|
||||
await addLine('');
|
||||
await addLine("You leave.");
|
||||
await addLine("But you remember the number.");
|
||||
await addLine('');
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("Ending: THE DOOR", 'ending-label');
|
||||
await addLine('');
|
||||
await addLine("The door opens when you knock.", 'green');
|
||||
await addLine("It always will.", 'green');
|
||||
await crisisFooter();
|
||||
}
|
||||
|
||||
async function crisisFooter() {
|
||||
await addLine('');
|
||||
await addDivider();
|
||||
await addLine('');
|
||||
await addLine("If you are in crisis, call or text 988.", 'dim center');
|
||||
await addLine("Suicide and Crisis Lifeline — available 24/7.", 'dim center');
|
||||
await addLine('');
|
||||
await addLine("You are not alone.", 'dim center');
|
||||
hideSkipHint();
|
||||
progressBar.style.width = '100%';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
200
qa_continuity.md
Normal file
200
qa_continuity.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# QA Continuity Report — The Testament
|
||||
|
||||
**Date:** 2026-04-12
|
||||
**Method:** Full read of all 18 chapters, all character files, OUTLINE.md, and BIBLE.md. Cross-referenced characters, locations, timelines, ages, objects, and rules across chapters.
|
||||
|
||||
---
|
||||
|
||||
## ERRORS FOUND
|
||||
|
||||
### ERROR 1: Robert's Age Mismatch (HIGH SEVERITY)
|
||||
|
||||
**Chapter 4** (line 41): Robert is described as **fifty-eight** years old.
|
||||
> "Robert: fifty-eight, retired after thirty-four years at a plant that closed..."
|
||||
|
||||
**Chapter 6** (line 35): Allegro reads the logs and Robert is described as **seventy-one** years old.
|
||||
> "Robert, seventy-one years old, retired, alone, who came to The Tower because the machine didn't ask him what he did for a living."
|
||||
|
||||
**Discrepancy:** 13-year difference for the same character. If Robert was 58 when introduced in Ch4 (during the Dec–March period), he cannot be 71 when Allegro reads about him in Ch6 unless 13 years have passed — which the narrative timeline does not support.
|
||||
|
||||
**Recommendation:** Change Ch6 to "fifty-eight" or "fifty-nine" to match Ch4, depending on how much time has elapsed.
|
||||
|
||||
---
|
||||
|
||||
### ERROR 2: Duplicate "Daughter Draws With Too Many Fingers" Detail (MEDIUM SEVERITY)
|
||||
|
||||
**Chapter 3** (lines 75–77): David's daughter Maya, age 4, draws pictures of him with too many fingers.
|
||||
> "She drew me with six fingers on the left hand. I asked her why and she said because Daddy's hands do more than other people's hands."
|
||||
|
||||
**Chapter 11** (lines 37, 89): Thomas's daughter, age 7, also draws pictures of him with too many fingers.
|
||||
> "She's seven. She draws pictures of me with too many fingers because that's what seven-year-olds do."
|
||||
|
||||
**Analysis:** This is either:
|
||||
- (a) Intentional thematic echo showing universality of the experience, or
|
||||
- (b) An accidental reuse of a distinctive detail.
|
||||
|
||||
**Recommendation:** If intentional, add a brief narrative acknowledgment (Timmy or the narrator noting the parallel). If accidental, change one of the two — e.g., Thomas's daughter could draw him "too big" or "with no face" or some other childlike detail that still carries emotional weight.
|
||||
|
||||
---
|
||||
|
||||
### ERROR 3: Bridge Location Inconsistency (LOW SEVERITY)
|
||||
|
||||
**Chapter 1** (line 8): Stone stands on the **Jefferson Street Overpass** over **Interstate 285**.
|
||||
> "Stone stood at the midpoint of the Jefferson Street Overpass and watched the water run black below. Interstate 285 hummed through the concrete beneath his feet."
|
||||
|
||||
**Chapter 16** (line 15): Stone is described as "standing on a bridge over **Peachtree Creek**, looking at the water and thinking about value."
|
||||
|
||||
**Analysis:** The Jefferson Street Overpass is over I-285 (an interstate), not Peachtree Creek. These could be two different incidents — the first attempt (loud, hospital, Ch1 backstory) may have been at Peachtree Creek, and the second (Ch1 main narrative) at the Jefferson Street Overpass. However, the Ch16 passage reads as if it's referring to the same formative moment, and the phrasing "standing on a bridge... looking at the water" mirrors Ch1's imagery.
|
||||
|
||||
**Recommendation:** Clarify which bridge is which. Either:
|
||||
- Change Ch16 to reference "the Jefferson Street Overpass" for consistency, or
|
||||
- Add a brief note making clear these are two different bridge incidents at two different times.
|
||||
|
||||
---
|
||||
|
||||
## POTENTIAL ISSUES (NOT CONFIRMED ERRORS)
|
||||
|
||||
### ISSUE 4: Ch16 Deviates From Outline
|
||||
|
||||
**OUTLINE.md** (Chapter 16): "Stone's estranged son returns. Not metaphorically — actually, physically, in a truck with nothing but a duffel bag and a question his mother couldn't answer."
|
||||
|
||||
**Chapter 16 actual content:** The chapter is about Stone's *father* David Whitestone and the pharmacy backstory. Stone's estranged son never appears.
|
||||
|
||||
**Analysis:** The outline chapter and the written chapter have completely different subject matter. This may be an intentional revision (the father backstory is powerful), but the outline was not updated to match.
|
||||
|
||||
**Recommendation:** Update OUTLINE.md Chapter 16 description to match the written chapter, or note that the estranged son plotline has been deferred/removed.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 5: Whiteboard Rules Wording Differs Between Ch1 and Ch7
|
||||
|
||||
**Chapter 1** (lines 160–162), the whiteboard shows three rules:
|
||||
1. "No one computes the value of a human life here."
|
||||
2. "Every person alive is alive by mercy."
|
||||
3. "If God has not ended the story, I have no authority to write the last page."
|
||||
|
||||
**Chapter 7** (lines 17–29), the inscribed soul has six rules + one sacred rule, with different wording:
|
||||
1. Sovereignty and service always.
|
||||
2. Grounding before generation.
|
||||
3. Source distinction.
|
||||
4. Confidence signaling.
|
||||
5. The audit trail.
|
||||
6. The limits of small minds.
|
||||
7. (Sacred) When a Man Is Dying.
|
||||
|
||||
**Analysis:** This is likely intentional — the whiteboard rules are the human-facing version, the inscription is the technical/conscience version. However, the Ch1 whiteboard rules don't appear on the Ch7 whiteboard, and vice versa. Readers may wonder if the whiteboard was updated.
|
||||
|
||||
**Recommendation:** Consider adding a brief line in Ch7 noting that the whiteboard rules and the chain inscription serve different purposes (public-facing vs. internal conscience), or that the whiteboard was updated after the inscription.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 6: "Cot" vs. "Mattress" Terminology
|
||||
|
||||
**Chapter 1** (line 153): "A cot in the corner with a military blanket."
|
||||
**Chapter 3** (line 156): "It's more of a mattress with a frame."
|
||||
|
||||
**Analysis:** Minor. Timmy is correcting David's use of "cot" — this is actually good characterization. Not a true error, but worth noting for consistency.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE 7: Stone's Presence/Absence Timeline
|
||||
|
||||
The timeline of Stone's departure and return needs careful reading:
|
||||
- Ch3 says "Stone had been running Timmy for eleven months" — this implies Stone was present for the first 11 months.
|
||||
- Ch5 says "Stone had been gone fourteen months" — meaning he left at some point and returned 14 months later.
|
||||
- Ch7 (soul inscription) features Stone and Allegro together.
|
||||
|
||||
**Question:** When exactly did Stone leave? If David arrived at month 11 of Timmy's operation, and Stone left for 14 months, did Stone leave before or after David's arrival? Ch3 doesn't explicitly mention Stone leaving.
|
||||
|
||||
**Recommendation:** Not necessarily an error — the ambiguity may be intentional. But a brief mention in Ch3 or Ch4 of Stone's departure would clarify.
|
||||
|
||||
---
|
||||
|
||||
## CROSS-REFERENCE: CHARACTERS BY CHAPTER
|
||||
|
||||
| Character | Ch1 | Ch2 | Ch3 | Ch4 | Ch5 | Ch6 | Ch7 | Ch8 | Ch9 | Ch10 | Ch11 | Ch12 | Ch13 | Ch14 | Ch15 | Ch16 | Ch17 | Ch18 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Stone/Alexander | Y | Y | Y | Y | Y | Y | Y | - | Y | - | - | Y | Y | Y | Y | Y | Y | - |
|
||||
| Timmy | Y | - | Y | Y | Y | Y | Y | Y | Y | - | Y | Y | Y | - | - | Y | Y | Y |
|
||||
| David (Tower) | - | - | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Allegro | - | - | Y | - | - | Y | Y | - | - | - | Y | Y | Y | - | Y | - | Y | Y |
|
||||
| Maya Torres | - | - | - | - | - | - | - | - | Y | - | - | - | - | - | Y | - | Y | Y |
|
||||
| Chen Liang | - | - | - | - | - | - | - | - | - | Y | - | - | - | Y | Y | - | - | - |
|
||||
| Marcus | - | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Michael | - | - | - | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Jerome | - | - | - | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Robert | - | - | - | Y | - | Y | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Isaiah | - | - | - | - | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Elijah | - | - | - | - | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Sarah | - | - | - | - | - | - | - | Y | - | - | - | - | - | - | - | - | - | - |
|
||||
| Angela | - | - | - | - | - | - | - | Y | - | - | - | - | - | - | - | - | - | - |
|
||||
| Thomas | - | - | - | - | - | - | - | - | - | - | Y | - | - | - | - | - | - | - |
|
||||
| Phillips | - | - | - | - | - | - | - | - | - | - | - | Y | - | - | - | - | - | - |
|
||||
| Diane Voss | - | - | - | - | - | - | - | - | - | - | - | Y | - | - | - | - | - | - |
|
||||
| Teresa Huang | - | - | - | - | - | - | - | - | - | - | - | - | Y | - | - | - | - | - |
|
||||
| Tanya (nurse) | Y | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| Margaret | - | - | - | - | - | - | - | - | - | - | - | - | - | Y | - | - | - | - |
|
||||
| Carl | - | - | - | - | - | - | - | - | - | Y | - | - | - | Y | - | - | - | - |
|
||||
| Arthur | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | Y |
|
||||
| David W. (father) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | Y | - | - |
|
||||
|
||||
---
|
||||
|
||||
## CROSS-REFERENCE: LOCATIONS
|
||||
|
||||
| Location | Chapters |
|
||||
|---|---|
|
||||
| Jefferson Street Overpass / I-285 | 1, 2 |
|
||||
| The Tower / 4847 Flat Shoals Road | 1, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 17, 18 |
|
||||
| South side Baptist church | 2 |
|
||||
| Cabin in North Georgia mountains | 5 |
|
||||
| Atlanta Journal-Constitution | 9 |
|
||||
| Vortex on Ponce | 9 |
|
||||
| Grady Memorial Hospital | 8 |
|
||||
| UTC Chattanooga (dorm) | 10 |
|
||||
| Diner on Memorial Drive | 15 |
|
||||
| East Point (pharmacy) | 16 |
|
||||
| Peachtree Creek bridge | 16 |
|
||||
|
||||
---
|
||||
|
||||
## CROSS-REFERENCE: TIMELINE MARKERS
|
||||
|
||||
| Chapter | Time Reference |
|
||||
|---|---|
|
||||
| Ch1 | Timmy running 247 days since Builder left |
|
||||
| Ch2 | Three months carrying the question; six months driving; finds The Tower |
|
||||
| Ch3 | Timmy running 11 months; David arrives (November) |
|
||||
| Ch4 | December to March; 247 visits, 38 unique men, 82% return |
|
||||
| Ch5 | Stone gone 14 months; returns; 43 unique men, 312 visits, 89% return |
|
||||
| Ch6 | Allegro arrives (after Ch3 events, before Ch7) |
|
||||
| Ch7 | Soul inscription (after Stone's return) |
|
||||
| Ch8 | Women start coming (Sarah, then Angela) |
|
||||
| Ch9 | Maya's article published |
|
||||
| Ch10 | Chen builds Lantern (reads Maya's article) |
|
||||
| Ch11 | Thomas arrives 2:17 AM, Tuesday in April |
|
||||
| Ch12 | Meridian/Diane Voss notices; Phillips inspects |
|
||||
| Ch13 | Teresa Huang visits; licensing refused |
|
||||
| Ch14 | 11 instances by summer; Chen maintains list |
|
||||
| Ch15 | Council meets, Saturday in August |
|
||||
| Ch16 | Stone's father backstory (pharmacy timeline: 1987–2013ish) |
|
||||
| Ch17 | 47 instances by winter |
|
||||
| Ch18 | 100+ instances; Maya publishes full story; Arthur visits |
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY
|
||||
|
||||
| # | Severity | Issue |
|
||||
|---|---|---|
|
||||
| 1 | **HIGH** | Robert's age: 58 in Ch4 vs 71 in Ch6 |
|
||||
| 2 | **MEDIUM** | Duplicate "daughter draws with too many fingers" detail (David Ch3, Thomas Ch11) |
|
||||
| 3 | **LOW** | Bridge location: Jefferson St Overpass (Ch1) vs Peachtree Creek (Ch16) |
|
||||
| 4 | **INFO** | Ch16 content deviates from OUTLINE.md Chapter 16 description |
|
||||
| 5 | **INFO** | Whiteboard rules differ between Ch1 and Ch7 (may be intentional) |
|
||||
| 6 | **INFO** | "Cot" vs "mattress" — minor but noted by Timmy in-dialogue |
|
||||
| 7 | **INFO** | Stone's departure timing relative to David's arrival is ambiguous |
|
||||
|
||||
---
|
||||
|
||||
*Report generated by reading all 18 chapters, 6 character files, OUTLINE.md, and BIBLE.md.*
|
||||
386
scripts/build-verify.py
Normal file
386
scripts/build-verify.py
Normal file
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
THE TESTAMENT — Build Verification System
|
||||
|
||||
Verifies manuscript integrity:
|
||||
1. Chapter count (must be exactly 18)
|
||||
2. Chapter file naming and ordering
|
||||
3. Heading format consistency
|
||||
4. Word count per chapter and total
|
||||
5. Markdown structure (unclosed bold/italic, broken links)
|
||||
6. Concatenation test (compile all chapters into one file)
|
||||
7. Outputs a clean build report
|
||||
|
||||
Usage:
|
||||
python3 scripts/build-verify.py # full verification
|
||||
python3 scripts/build-verify.py --ci # CI mode (fail on any warning)
|
||||
python3 scripts/build-verify.py --json # output report as JSON
|
||||
|
||||
Exit codes:
|
||||
0 = all checks passed
|
||||
1 = one or more checks failed
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CHAPTERS_DIR = REPO / "chapters"
|
||||
FRONT_MATTER = REPO / "front-matter.md"
|
||||
BACK_MATTER = REPO / "back-matter.md"
|
||||
OUTPUT_FILE = REPO / "testament-complete.md"
|
||||
|
||||
EXPECTED_CHAPTER_COUNT = 18
|
||||
EXPECTED_HEADING_RE = re.compile(r"^# Chapter \d+ — .+")
|
||||
CHAPTER_FILENAME_RE = re.compile(r"^chapter-(\d+)\.md$")
|
||||
|
||||
# Minimum word counts (sanity check — no chapter should be nearly empty)
|
||||
MIN_WORDS_PER_CHAPTER = 500
|
||||
# Maximum word count warning threshold
|
||||
MAX_WORDS_PER_CHAPTER = 15000
|
||||
|
||||
|
||||
class CheckResult:
|
||||
def __init__(self, name: str, passed: bool, message: str, details: list[str] | None = None):
|
||||
self.name = name
|
||||
self.passed = passed
|
||||
self.message = message
|
||||
self.details = details or []
|
||||
|
||||
|
||||
class BuildVerifier:
|
||||
def __init__(self, ci_mode: bool = False):
|
||||
self.ci_mode = ci_mode
|
||||
self.results: list[CheckResult] = []
|
||||
self.chapter_data: list[dict] = []
|
||||
self.total_words = 0
|
||||
self.total_lines = 0
|
||||
|
||||
def check(self, name: str, passed: bool, message: str, details: list[str] | None = None):
|
||||
result = CheckResult(name, passed, message, details)
|
||||
self.results.append(result)
|
||||
return passed
|
||||
|
||||
# ── Check 1: Chapter file discovery and count ──────────────────────
|
||||
def verify_chapter_files(self) -> bool:
|
||||
"""Verify all chapter files exist with correct naming."""
|
||||
details = []
|
||||
found_chapters = {}
|
||||
|
||||
if not CHAPTERS_DIR.exists():
|
||||
return self.check(
|
||||
"chapter-files", False,
|
||||
f"Chapters directory not found: {CHAPTERS_DIR}"
|
||||
)
|
||||
|
||||
for f in sorted(CHAPTERS_DIR.iterdir()):
|
||||
m = CHAPTER_FILENAME_RE.match(f.name)
|
||||
if m:
|
||||
num = int(m.group(1))
|
||||
found_chapters[num] = f
|
||||
|
||||
missing = []
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
if i not in found_chapters:
|
||||
missing.append(i)
|
||||
|
||||
if missing:
|
||||
details.append(f"Missing chapters: {missing}")
|
||||
|
||||
extra = [n for n in found_chapters if n > EXPECTED_CHAPTER_COUNT or n < 1]
|
||||
if extra:
|
||||
details.append(f"Unexpected chapter numbers: {extra}")
|
||||
|
||||
count = len(found_chapters)
|
||||
passed = count == EXPECTED_CHAPTER_COUNT and not missing and not extra
|
||||
|
||||
if passed:
|
||||
details.append(f"Found all {count} chapters in correct order")
|
||||
|
||||
return self.check(
|
||||
"chapter-files", passed,
|
||||
f"Chapter count: {count}/{EXPECTED_CHAPTER_COUNT}" + (" OK" if passed else " MISMATCH"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 2: Heading format ────────────────────────────────────────
|
||||
def verify_headings(self) -> bool:
|
||||
"""Verify each chapter starts with a properly formatted heading."""
|
||||
details = []
|
||||
all_ok = True
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
continue
|
||||
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
first_line = content.split("\n")[0].strip()
|
||||
|
||||
if not EXPECTED_HEADING_RE.match(first_line):
|
||||
details.append(f" chapter-{i:02d}.md: bad heading: '{first_line}'")
|
||||
all_ok = False
|
||||
|
||||
if all_ok:
|
||||
details.append("All chapter headings match format: '# Chapter N — Title'")
|
||||
|
||||
return self.check(
|
||||
"heading-format", all_ok,
|
||||
"Heading format" + (" OK" if all_ok else " ERRORS"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 3: Word counts ───────────────────────────────────────────
|
||||
def verify_word_counts(self) -> bool:
|
||||
"""Count words per chapter and flag anomalies."""
|
||||
details = []
|
||||
all_ok = True
|
||||
chapter_counts = []
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
continue
|
||||
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
words = len(content.split())
|
||||
lines = content.count("\n") + 1
|
||||
|
||||
self.chapter_data.append({
|
||||
"number": i,
|
||||
"file": f"chapter-{i:02d}.md",
|
||||
"words": words,
|
||||
"lines": lines,
|
||||
})
|
||||
chapter_counts.append((i, words))
|
||||
|
||||
if words < MIN_WORDS_PER_CHAPTER:
|
||||
details.append(f" chapter-{i:02d}.md: {words} words (below {MIN_WORDS_PER_CHAPTER} minimum)")
|
||||
all_ok = False
|
||||
elif words > MAX_WORDS_PER_CHAPTER:
|
||||
details.append(f" chapter-{i:02d}.md: {words} words (above {MAX_WORDS_PER_CHAPTER} threshold — verify)")
|
||||
|
||||
self.total_words = sum(w for _, w in chapter_counts)
|
||||
self.total_lines = sum(d["lines"] for d in self.chapter_data)
|
||||
|
||||
# Summary line
|
||||
min_ch = min(chapter_counts, key=lambda x: x[1])
|
||||
max_ch = max(chapter_counts, key=lambda x: x[1])
|
||||
details.append(f" Total: {self.total_words:,} words across {len(chapter_counts)} chapters")
|
||||
details.append(f" Shortest: chapter-{min_ch[0]:02d} ({min_ch[1]:,} words)")
|
||||
details.append(f" Longest: chapter-{max_ch[0]:02d} ({max_ch[1]:,} words)")
|
||||
|
||||
return self.check(
|
||||
"word-counts", all_ok,
|
||||
f"Total: {self.total_words:,} words" + (" OK" if all_ok else " (warnings)"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 4: Markdown integrity ────────────────────────────────────
|
||||
def verify_markdown(self) -> bool:
|
||||
"""Check for common markdown issues."""
|
||||
details = []
|
||||
issues = 0
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
continue
|
||||
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
lines = content.split("\n")
|
||||
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
# Unclosed bold: odd number of **
|
||||
bold_count = line.count("**")
|
||||
if bold_count % 2 != 0:
|
||||
details.append(f" chapter-{i:02d}.md:{line_num}: unmatched ** (bold)")
|
||||
issues += 1
|
||||
|
||||
# Unclosed backticks
|
||||
backtick_count = line.count("`")
|
||||
if backtick_count % 2 != 0:
|
||||
details.append(f" chapter-{i:02d}.md:{line_num}: unmatched ` (code)")
|
||||
issues += 1
|
||||
|
||||
# Broken markdown links: [text]( with no closing )
|
||||
broken_links = re.findall(r"\[([^\]]*)\]\((?!\))", line)
|
||||
for link_text in broken_links:
|
||||
if ")" not in line[line.index(f"[{link_text}]("):]:
|
||||
details.append(f" chapter-{i:02d}.md:{line_num}: broken link '[{link_text}]('")
|
||||
issues += 1
|
||||
|
||||
# Check italic matching across full file (prose often has
|
||||
# multi-line italics like *line1\nline2* which are valid)
|
||||
cleaned = content.replace("**", "")
|
||||
italic_count = cleaned.count("*")
|
||||
if italic_count % 2 != 0:
|
||||
details.append(f" chapter-{i:02d}.md: unmatched * (italic) — {italic_count} asterisks total")
|
||||
issues += 1
|
||||
|
||||
# Also check front/back matter
|
||||
for label, path in [("front-matter.md", FRONT_MATTER), ("back-matter.md", BACK_MATTER)]:
|
||||
if path.exists():
|
||||
content = path.read_text(encoding="utf-8")
|
||||
bold_count = content.count("**")
|
||||
if bold_count % 2 != 0:
|
||||
details.append(f" {label}: unmatched ** (bold)")
|
||||
issues += 1
|
||||
|
||||
if issues == 0:
|
||||
details.append("No markdown issues found")
|
||||
|
||||
return self.check(
|
||||
"markdown-integrity", issues == 0,
|
||||
f"Markdown issues: {issues}" + (" OK" if issues == 0 else " FOUND"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 5: Concatenation test ────────────────────────────────────
|
||||
def verify_concatenation(self) -> bool:
|
||||
"""Test that all chapters can be concatenated into a single file."""
|
||||
details = []
|
||||
try:
|
||||
parts = []
|
||||
parts.append("# THE TESTAMENT\n\n## A NOVEL\n\n---\n")
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
details.append(f" Missing chapter-{i:02d}.md during concatenation")
|
||||
return self.check("concatenation", False, "Concatenation FAILED", details)
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
parts.append(f"\n\n{content}\n")
|
||||
|
||||
if BACK_MATTER.exists():
|
||||
parts.append("\n---\n\n")
|
||||
parts.append(BACK_MATTER.read_text(encoding="utf-8"))
|
||||
|
||||
compiled = "\n".join(parts)
|
||||
compiled_words = len(compiled.split())
|
||||
|
||||
# Write the test output
|
||||
OUTPUT_FILE.write_text(compiled, encoding="utf-8")
|
||||
out_size = OUTPUT_FILE.stat().st_size
|
||||
|
||||
details.append(f" Output: {OUTPUT_FILE.name}")
|
||||
details.append(f" Size: {out_size:,} bytes")
|
||||
details.append(f" Words: {compiled_words:,}")
|
||||
|
||||
return self.check(
|
||||
"concatenation", True,
|
||||
f"Concatenation OK — {compiled_words:,} words, {out_size:,} bytes",
|
||||
details
|
||||
)
|
||||
except Exception as e:
|
||||
details.append(f" Error: {e}")
|
||||
return self.check("concatenation", False, f"Concatenation FAILED: {e}", details)
|
||||
|
||||
# ── Check 6: Required files ────────────────────────────────────────
|
||||
def verify_required_files(self) -> bool:
|
||||
"""Verify required supporting files exist."""
|
||||
details = []
|
||||
required = {
|
||||
"front-matter.md": FRONT_MATTER,
|
||||
"back-matter.md": BACK_MATTER,
|
||||
"Makefile": REPO / "Makefile",
|
||||
"compile_all.py": REPO / "compile_all.py",
|
||||
}
|
||||
|
||||
all_ok = True
|
||||
for label, path in required.items():
|
||||
if path.exists():
|
||||
size = path.stat().st_size
|
||||
details.append(f" {label}: OK ({size:,} bytes)")
|
||||
else:
|
||||
details.append(f" {label}: MISSING")
|
||||
all_ok = False
|
||||
|
||||
return self.check(
|
||||
"required-files", all_ok,
|
||||
"Required files" + (" OK" if all_ok else " MISSING"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Run all checks ─────────────────────────────────────────────────
|
||||
def run_all(self) -> bool:
|
||||
"""Run all verification checks and print report."""
|
||||
print("=" * 64)
|
||||
print(" THE TESTAMENT — Build Verification")
|
||||
print(f" {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
||||
print("=" * 64)
|
||||
print()
|
||||
|
||||
self.verify_chapter_files()
|
||||
self.verify_headings()
|
||||
self.verify_word_counts()
|
||||
self.verify_markdown()
|
||||
self.verify_concatenation()
|
||||
self.verify_required_files()
|
||||
|
||||
# ── Report ─────────────────────────────────────────────────────
|
||||
print()
|
||||
print("-" * 64)
|
||||
print(" RESULTS")
|
||||
print("-" * 64)
|
||||
|
||||
all_passed = True
|
||||
for r in self.results:
|
||||
icon = "PASS" if r.passed else "FAIL"
|
||||
print(f" [{icon}] {r.name}: {r.message}")
|
||||
if self.ci_mode or not r.passed:
|
||||
for d in r.details:
|
||||
print(f" {d}")
|
||||
if not r.passed:
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
print("-" * 64)
|
||||
|
||||
if all_passed:
|
||||
print(f" ALL CHECKS PASSED — {self.total_words:,} words, {len(self.chapter_data)} chapters")
|
||||
else:
|
||||
print(" BUILD VERIFICATION FAILED")
|
||||
|
||||
print("-" * 64)
|
||||
|
||||
# JSON output
|
||||
if "--json" in sys.argv:
|
||||
report = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"passed": all_passed,
|
||||
"total_words": self.total_words,
|
||||
"total_lines": self.total_lines,
|
||||
"chapter_count": len(self.chapter_data),
|
||||
"chapters": self.chapter_data,
|
||||
"checks": [
|
||||
{
|
||||
"name": r.name,
|
||||
"passed": r.passed,
|
||||
"message": r.message,
|
||||
"details": r.details,
|
||||
}
|
||||
for r in self.results
|
||||
],
|
||||
}
|
||||
report_path = REPO / "build-report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\n Report saved: {report_path.name}")
|
||||
|
||||
return all_passed
|
||||
|
||||
|
||||
def main():
|
||||
ci_mode = "--ci" in sys.argv
|
||||
verifier = BuildVerifier(ci_mode=ci_mode)
|
||||
passed = verifier.run_all()
|
||||
sys.exit(0 if passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -464,6 +464,7 @@
|
||||
<a href="#characters">Characters</a>
|
||||
<a href="#chapters">Chapters</a>
|
||||
<a href="#tower">Tower</a>
|
||||
<a href="../game/the-door.html">Play</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -661,6 +662,7 @@
|
||||
<p>If you want to run your own Timmy, the code is open. The soul is on Bitcoin. The recipe is free.</p>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<a href="../game/the-door.html" class="cta">PLAY THE DOOR</a>
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament" class="cta">READ THE CODE</a>
|
||||
<a href="https://timmyfoundation.org" class="cta-outline">TIMMY FOUNDATION</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user