ugly-game 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export * from './mat4';
3
3
  export * from './noise';
4
4
  export * from './moat';
5
5
  export * from './stateIR';
6
+ export * from './playtestIR';
7
+ export * from './visualIR';
6
8
  export * from './gpuShadow';
7
9
  export * from './input/resolve';
8
10
  export * from './input/browserBackend';
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export * from './mat4';
5
5
  export * from './noise';
6
6
  export * from './moat';
7
7
  export * from './stateIR';
8
+ export * from './playtestIR';
9
+ export * from './visualIR';
8
10
  export * from './gpuShadow';
9
11
  export * from './input/resolve';
10
12
  export * from './input/browserBackend';
@@ -0,0 +1,60 @@
1
+ export interface PlayEvent {
2
+ tick: number;
3
+ kind: string;
4
+ note: string;
5
+ }
6
+ export interface PlaytestDigest {
7
+ wallSeconds: number;
8
+ ticks: number;
9
+ rewards: PlayEvent[];
10
+ rewardCadenceSec: number | null;
11
+ longestDrySpellSec: number;
12
+ frictions: PlayEvent[];
13
+ actions: number;
14
+ failedActions: number;
15
+ undos: number;
16
+ varietyScore: number;
17
+ featuresUsed: string[];
18
+ progress: Record<string, number>;
19
+ affordances: string[];
20
+ summary: string;
21
+ }
22
+ /** Options for `digest()` — the game supplies the bits only it knows. */
23
+ export interface DigestInput {
24
+ wallSeconds: number;
25
+ nowTick: number;
26
+ progress?: Record<string, number>;
27
+ affordances?: string[];
28
+ /** Extra domain notes appended to the summary (e.g. "⚠ stuck for 8s"). */
29
+ extraNotes?: string[];
30
+ /** Distinct things the player TOUCHED beyond `feature()` (e.g. building types placed) — added to varietyScore. */
31
+ extraVariety?: number;
32
+ }
33
+ /** Records a play session and distils it into fun/ease evidence. FRAMEWORK: the game detects what counts as a
34
+ * reward/friction and calls these; the recorder does the cadence/variety/summary math. Ticks are the game's own. */
35
+ export declare class SessionRecorder {
36
+ private readonly startTick;
37
+ private lastRewardTick;
38
+ private longestDry;
39
+ private readonly rewards;
40
+ private readonly frictions;
41
+ private readonly features;
42
+ private actionN;
43
+ private failedN;
44
+ private undoN;
45
+ constructor(startTick?: number);
46
+ /** A rewarding moment happened (first-delivery, milestone, unlock…). Updates the reward cadence + dry-spell. */
47
+ reward(tick: number, kind: string, note: string): void;
48
+ /** A friction moment (failed action, being stuck, an unresolved warning). */
49
+ friction(tick: number, kind: string, note: string): void;
50
+ /** A player action — `failed`/`undo` feed the ease signals. */
51
+ action(opts?: {
52
+ failed?: boolean;
53
+ undo?: boolean;
54
+ }): void;
55
+ /** A mechanic the player actually engaged (for variety/depth). */
56
+ feature(name: string): void;
57
+ get rewardCount(): number;
58
+ /** Distil the session into the digest a grader reads. `fps` scales tick-gaps to seconds (default 60). */
59
+ digest(input: DigestInput, fps?: number): PlaytestDigest;
60
+ }
@@ -0,0 +1,64 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // ugly-game PLAYTEST IR — the SUBJECTIVE layer of the IR platform.
3
+ //
4
+ // stateIR answers "is it BROKEN?"; this answers "is it FUN and LEARNABLE?" by recording a play SESSION and
5
+ // distilling it into evidence a simulated player (or an AI grader) reads to rate the experience:
6
+ // • REWARD events + their CADENCE + longest DRY SPELL — the core "is it fun?" signal (long gaps = boring)
7
+ // • FRICTION events + failed actions / undos — the core "is it easy?" signal
8
+ // • VARIETY (features engaged) + game PROGRESS counters + AFFORDANCES (what's discoverable next)
9
+ //
10
+ // This module is GENERIC: a game feeds it (`reward`/`friction`/`action`/`feature`) from its own delta-detection
11
+ // (which items were delivered, which tech unlocked — that part is game-specific), then `digest()` computes the
12
+ // cadence/dry-spell/variety and formats the summary. Deterministic given the same feed. Graduated from Cogsworth.
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ /** Records a play session and distils it into fun/ease evidence. FRAMEWORK: the game detects what counts as a
15
+ * reward/friction and calls these; the recorder does the cadence/variety/summary math. Ticks are the game's own. */
16
+ export class SessionRecorder {
17
+ startTick;
18
+ lastRewardTick;
19
+ longestDry = 0;
20
+ rewards = [];
21
+ frictions = [];
22
+ features = new Set();
23
+ actionN = 0;
24
+ failedN = 0;
25
+ undoN = 0;
26
+ constructor(startTick = 0) { this.startTick = startTick; this.lastRewardTick = startTick; }
27
+ /** A rewarding moment happened (first-delivery, milestone, unlock…). Updates the reward cadence + dry-spell. */
28
+ reward(tick, kind, note) {
29
+ this.rewards.push({ tick, kind, note });
30
+ this.longestDry = Math.max(this.longestDry, (tick - this.lastRewardTick) / 60);
31
+ this.lastRewardTick = tick;
32
+ }
33
+ /** A friction moment (failed action, being stuck, an unresolved warning). */
34
+ friction(tick, kind, note) { this.frictions.push({ tick, kind, note }); }
35
+ /** A player action — `failed`/`undo` feed the ease signals. */
36
+ action(opts = {}) { this.actionN++; if (opts.failed)
37
+ this.failedN++; if (opts.undo)
38
+ this.undoN++; }
39
+ /** A mechanic the player actually engaged (for variety/depth). */
40
+ feature(name) { this.features.add(name); }
41
+ get rewardCount() { return this.rewards.length; }
42
+ /** Distil the session into the digest a grader reads. `fps` scales tick-gaps to seconds (default 60). */
43
+ digest(input, fps = 60) {
44
+ const ticks = input.nowTick - this.startTick;
45
+ const cadence = this.rewards.length > 0 ? +(input.wallSeconds / this.rewards.length).toFixed(1) : null;
46
+ const feats = [...this.features].sort();
47
+ const varietyScore = feats.length + (input.extraVariety ?? 0);
48
+ const progress = input.progress ?? {};
49
+ const affordances = input.affordances ?? [];
50
+ const summary = [
51
+ `Over ${input.wallSeconds.toFixed(0)}s (${ticks} ticks): ${this.actionN} actions (${this.failedN} failed, ${this.undoN} undone).`,
52
+ Object.keys(progress).length ? `Progress: ${Object.entries(progress).map(([k, v]) => `${k} ${v}`).join(', ')}.` : '',
53
+ `${this.rewards.length} reward moments${cadence !== null ? ` (one about every ${cadence}s; longest dry spell ${this.longestDry.toFixed(0)}s)` : ' (none — nothing rewarding happened)'}.`,
54
+ `Engaged: ${feats.length ? feats.join(', ') : 'only the basics'}.`,
55
+ ...(input.extraNotes ?? []),
56
+ ].filter(Boolean).join(' ');
57
+ return {
58
+ wallSeconds: +input.wallSeconds.toFixed(1), ticks,
59
+ rewards: this.rewards, rewardCadenceSec: cadence, longestDrySpellSec: +this.longestDry.toFixed(1),
60
+ frictions: this.frictions, actions: this.actionN, failedActions: this.failedN, undos: this.undoN,
61
+ varietyScore, featuresUsed: feats, progress, affordances, summary,
62
+ };
63
+ }
64
+ }
@@ -0,0 +1,28 @@
1
+ /** The minimal per-box info the summary reads (RGB in 0..1, emissive strength, world height). */
2
+ export interface SceneBox {
3
+ y: number;
4
+ emissive: number;
5
+ r: number;
6
+ g: number;
7
+ b: number;
8
+ }
9
+ export interface SceneSummary {
10
+ instances: number;
11
+ distinctColours: number;
12
+ palette: string[];
13
+ emissiveCoverage: number;
14
+ emissiveMean: number;
15
+ heightSpread: number;
16
+ motion: number;
17
+ notes: string[];
18
+ }
19
+ export interface SummaryThresholds {
20
+ monochromeAtOrBelow?: number;
21
+ underlitBelow?: number;
22
+ livelyAtOrAbove?: number;
23
+ flatBelow?: number;
24
+ }
25
+ /** Summarise a scene's boxes + a motion count into actionable attractiveness signals. Pure. The game filters
26
+ * which boxes count as "content" (e.g. skip the ground plane) before calling. Notes fire off simple thresholds
27
+ * (overridable) so the output maps straight to a fix. */
28
+ export declare function summarizeScene(boxes: Iterable<SceneBox>, motion: number, th?: SummaryThresholds): SceneSummary;
@@ -0,0 +1,59 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // ugly-game VISUAL IR — attractiveness as STRUCTURED, ACTIONABLE data instead of a screenshot.
3
+ //
4
+ // The renderer already emits a structured scene (typed boxes: position + colour + emissive), so we can read the
5
+ // composition directly and report precise, fixable signals — palette diversity, how much of the scene glows,
6
+ // vertical layering — plus a caller-supplied MOTION count (how many parts are moving: the "living scene"). A
7
+ // screenshot says "looks flat"; this says "3 colours, 2% self-lit, nothing moving → monochrome + under-lit +
8
+ // static", which is a fix list.
9
+ //
10
+ // This module is GENERIC: the game hands it the scene's boxes (whatever subset counts as "content") + a motion
11
+ // count from its own state; `summarizeScene` computes the palette/glow/layering + actionable notes. It's the
12
+ // visual arm of the IR platform (fun/ease = playtestIR, "is it broken?" = stateIR, "does it look good?" = here).
13
+ // Graduated from Cogsworth. Screenshots stay a secondary spot-check for taste/composition structure can't see.
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ const approxHex = (r, g, b) => '#' + [r, g, b].map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, '0')).join('');
16
+ /** Summarise a scene's boxes + a motion count into actionable attractiveness signals. Pure. The game filters
17
+ * which boxes count as "content" (e.g. skip the ground plane) before calling. Notes fire off simple thresholds
18
+ * (overridable) so the output maps straight to a fix. */
19
+ export function summarizeScene(boxes, motion, th = {}) {
20
+ const monoAt = th.monochromeAtOrBelow ?? 3, underlit = th.underlitBelow ?? 0.06, lively = th.livelyAtOrAbove ?? 20, flat = th.flatBelow ?? 1.2;
21
+ const colours = new Map();
22
+ let emisSum = 0, lit = 0, minY = Infinity, maxY = -Infinity, n = 0;
23
+ for (const bx of boxes) {
24
+ n++;
25
+ const key = `${Math.round(bx.r * 16)},${Math.round(bx.g * 16)},${Math.round(bx.b * 16)}`;
26
+ const c = colours.get(key);
27
+ if (c)
28
+ c.n++;
29
+ else
30
+ colours.set(key, { n: 1, r: bx.r, g: bx.g, b: bx.b });
31
+ emisSum += bx.emissive;
32
+ if (bx.emissive > 0.3)
33
+ lit++;
34
+ if (bx.y < minY)
35
+ minY = bx.y;
36
+ if (bx.y > maxY)
37
+ maxY = bx.y;
38
+ }
39
+ const palette = [...colours.values()].sort((a, b) => b.n - a.n).slice(0, 6).map((c) => approxHex(c.r, c.g, c.b));
40
+ const emissiveCoverage = n ? lit / n : 0, emissiveMean = n ? emisSum / n : 0, heightSpread = maxY > minY ? maxY - minY : 0;
41
+ const notes = [];
42
+ if (palette.length <= monoAt)
43
+ notes.push(`only ${palette.length} dominant colours — reads monochrome`);
44
+ if (emissiveCoverage < underlit)
45
+ notes.push('little glow — under-lit / flat');
46
+ if (motion === 0)
47
+ notes.push('nothing is moving — reads static/dead');
48
+ else if (motion >= lively)
49
+ notes.push(`lively — ${motion} moving parts on screen`);
50
+ if (heightSpread < flat)
51
+ notes.push('low vertical variety — reads flat (little layering)');
52
+ if (!notes.length)
53
+ notes.push('cohesive palette, good glow, moving parts — reads rich + alive');
54
+ return {
55
+ instances: n, distinctColours: colours.size, palette,
56
+ emissiveCoverage: +emissiveCoverage.toFixed(3), emissiveMean: +emissiveMean.toFixed(3), heightSpread: +heightSpread.toFixed(2),
57
+ motion, notes,
58
+ };
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {