Repository navigation
Expand file tree
/
Copy pathbuild-play.js
More file actions
executable file
·222 lines (198 loc) · 8.15 KB
/
Copy pathbuild-play.js
File metadata and controls
executable file
·222 lines (198 loc) · 8.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
#!/usr/bin/env node
// bin/build-play.js — build the playable half of the GitHub Pages site.
//
// Produces docs/play/, which is the play/ engine plus a set of pre-generated
// stories and a picker to switch between them:
//
// docs/play/index.html the picker page
// docs/play/game.js copied from play/
// docs/play/phrasebook.js copied from play/
// docs/play/stories/<id>.<seed>.js one `window.GRAPH = {...}` per story
// docs/play/stories/manifest.js `window.STORIES = [...]`
//
// Pages is served statically from master:/docs, so everything here is
// committed. Regenerate with `make site`.
//
// The page styling is not duplicated: this script lifts the <style> block out
// of play/index.html so the local app and the published one cannot drift.
// The one structural difference is script loading — play/index.html has a
// fixed `<script src="graph.js">`, while the picker has to load whichever
// story the query string names, in order, before game.js runs.
const fs = require('fs'),
path = require('path'),
{ execFileSync } = require('child_process'),
examples = require('../examples')
const ROOT = path.resolve(__dirname, '..')
const PLAY = path.join(ROOT, 'play')
const OUT = path.join(ROOT, 'docs', 'play')
const STORIES = path.join(OUT, 'stories')
// Which seeds get published per example. The canonical seed always; the rest
// of defaultSeeds too, so the site can show that the same grammar yields
// visibly different maps. Keep this small — each story is a committed file.
function seedsFor (ex) { return ex.defaultSeeds.slice(0, 3) }
// PROSE=1 generates real narrative for the CANONICAL seed of each example via
// the Anthropic API, leaving the other seeds on themed placeholders.
//
// Only the canonical seed, for two reasons. It is the one the docs link to and
// the one anybody actually plays, so it is where prose earns its cost; and the
// placeholder seeds stay useful precisely *because* they are placeholders —
// `[gothic_horror:describe_room#room_4]` shows which slot each string fills,
// which is what you want while a grammar is still moving. Shipping both side by
// side lets the site show the same map in both modes.
//
// Every call is cached on disk by sha256(model + system + prompt), so a rebuild
// at an unchanged seed makes zero API requests. See sonnet-runner.js.
const PROSE = process.env.PROSE === '1'
function prosePassFor (ex, seed) {
return PROSE && seed === ex.defaultSeeds[0]
}
fs.mkdirSync(STORIES, { recursive: true })
// --- 1. the stories --------------------------------------------------------
const manifest = []
examples.list().forEach(function (ex) {
seedsFor(ex).forEach(function (seed) {
const base = ex.id + '.' + seed + '.js'
const dest = path.join(STORIES, base)
const args = [
path.join(ROOT, 'bin', 'story.js'),
'--example', ex.id,
'--seed', String(seed),
'--format', 'play',
'--out', dest,
'--quiet'
]
if (prosePassFor(ex, seed)) args.push('--sonnet')
execFileSync(process.execPath, args,
{ cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'] })
const graph = JSON.parse(fs.readFileSync(dest, 'utf-8')
.replace(/^window\.GRAPH = /, '').replace(/;\n?$/, ''))
manifest.push({
file: base,
id: ex.id,
seed: seed,
title: ex.title,
topology: ex.topology,
puzzles: ex.puzzles,
nodes: graph.nodes.length,
edges: graph.edges.length,
prose: prosePassFor(ex, seed) ? 'generated' : 'placeholder'
})
console.log(' ' + ex.id + ' @ ' + seed + ' -> docs/play/stories/' + base +
' (' + graph.nodes.length + ' nodes, ' + graph.edges.length + ' edges' +
(prosePassFor(ex, seed) ? ', prose' : '') + ')')
})
})
fs.writeFileSync(path.join(STORIES, 'manifest.js'),
'// Generated by bin/build-play.js. Do not edit.\n' +
'window.STORIES = ' + JSON.stringify(manifest, null, 2) + ';\n')
// --- 2. the engine ---------------------------------------------------------
;['game.js', 'phrasebook.js'].forEach(function (f) {
fs.copyFileSync(path.join(PLAY, f), path.join(OUT, f))
})
// --- 3. the picker page ----------------------------------------------------
// Lift the shared styling out of the local app rather than maintaining a
// second copy of it.
const localHtml = fs.readFileSync(path.join(PLAY, 'index.html'), 'utf-8')
const styleMatch = localHtml.match(/<style>[\s\S]*?<\/style>/)
if (!styleMatch) throw new Error('could not find the <style> block in play/index.html')
const PICKER_STYLE = `<style>
/* Picker chrome, on top of the styles lifted from play/index.html. */
#picker {
flex: 0 0 auto;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 6px 12px;
background: #efe8da;
border-bottom: 1px solid #c0b8a8;
font-family: "Menlo", "Consolas", monospace;
font-size: 12px;
color: #453f34;
}
#picker a { color: #7a3d1f; }
#picker select {
font-family: inherit; font-size: 12px;
padding: 2px 4px; border: 1px solid #b0a890; border-radius: 3px; background: #fff;
}
#picker .about { margin-left: auto; }
</style>`
const PICKER_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>graphgram — play</title>
${styleMatch[0]}
${PICKER_STYLE}
</head>
<body>
<div id="picker">
<label for="story-select">story</label>
<select id="story-select"></select>
<span id="story-meta"></span>
<span class="about"><a href="../examples.html">about these examples</a> · <a href="../index.html">docs</a></span>
</div>
<div id="status"><span>Loading…</span></div>
<div id="panes">
<div id="text-pane"></div>
<div id="right-pane">
<div id="graph-pane"><p style="color:#999">Loading…</p></div>
</div>
</div>
<div id="error"></div>
<script src="https://cdn.jsdelivr.net/npm/@dagrejs/dagre@1.1.4/dist/dagre.min.js"></script>
<script src="stories/manifest.js"></script>
<script src="phrasebook.js"></script>
<script>
// Ordered, dynamic script loading. play/game.js runs on load and expects
// window.GRAPH to be set already, so the story must finish loading before
// the engine script is even appended. A plain pair of <script> tags cannot
// express "whichever story the query string names", hence this shim.
(function () {
var stories = window.STORIES || []
if (!stories.length) {
document.getElementById('error').style.display = 'block'
document.getElementById('error').textContent =
'No stories built. Run: make site'
return
}
var params = new URLSearchParams(location.search)
var want = params.get('story')
var chosen = stories.filter(function (s) {
return s.file === want || (s.id + '.' + s.seed) === want || s.id === want
})[0] || stories[0]
var sel = document.getElementById('story-select')
stories.forEach(function (s) {
var opt = document.createElement('option')
opt.value = s.id + '.' + s.seed
opt.textContent = s.id + ' · seed ' + s.seed + (s.prose === 'generated' ? ' ✍' : '')
if (s === chosen) opt.selected = true
sel.appendChild(opt)
})
sel.addEventListener('change', function () {
location.search = '?story=' + encodeURIComponent(sel.value)
})
document.getElementById('story-meta').textContent =
chosen.title + ' — ' + chosen.topology +
(chosen.puzzles ? ', puzzles' : ', no puzzles') +
' — ' + chosen.nodes + ' nodes, ' + chosen.edges + ' edges' +
(chosen.prose === 'generated' ? ' — written' : ' — placeholder text')
function load (src, next) {
var el = document.createElement('script')
el.src = src
el.onload = next
el.onerror = function () {
document.getElementById('error').style.display = 'block'
document.getElementById('error').textContent = 'Failed to load ' + src
}
document.body.appendChild(el)
}
load('stories/' + chosen.file, function () { load('game.js', function () {}) })
})()
</script>
</body>
</html>
`
fs.writeFileSync(path.join(OUT, 'index.html'), PICKER_HTML)
console.log(manifest.length + ' stories written to docs/play/')