SLOPSHOPPER

GAME MODE · Map

A status line with the branch and the play time: MAP <branch> · 플레이 h:mm, with ⚠ on a protected branch such as main

newcommandstatusprocesstimer
★ 2v0.2.0MITupdated 2026-10-06Reasonofmoon/bitgame-mods/plugins/game-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-map
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /map ⎿ game-map: MAP feat/auth-refresh ⎿ game-map: 플레이 0:30 ⎿ game-map: 변경 파일 2 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ game-map: MAP feat/auth-refresh · 플레이 0:30
README

GAME MODE · Map

A status line with where you are and how long you have played:

MAP feat/login · 플레이 0:42
MAP main ⚠ 보호 · 플레이 1:05

The branch comes from git (@abc1234 when detached; 저장소 밖 outside a repository), read when the session starts, after each turn and once a minute. A protected branch is marked ⚠ 보호: work there goes straight to it. /map prints the branch, the play time and the number of changed files.

Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-map@bitgame-mods

Settings

intensity (casual)off · casual · hardcore (adds the changed files)
protectedBranches (main,master,production,release/*)names or patterns, * within one path part

한국어

상태 줄에 지금 브랜치와 플레이 시간을 보여줍니다. main 같은 보호 브랜치에는 ⚠ 보호를 붙입니다.

Source 2 files
hooks/register.tsx 110 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { intensityOf } from './palette'
4
5// GAME MODE · MAP
6//
7// A status line under the prompt with where you are and how long you have played:
8//   MAP feat/login · 플레이 0:42
9//   MAP main ⚠ 보호 · 플레이 1:05     (a protected branch: work there goes straight to main)
10// Read from git when the session starts, after each turn and once a minute.
11
12const EVERY_MS = 60_000
13
14type Setup = { isHardcore: boolean; protectedBranches: readonly RegExp[] }
15
16export const register: Register = (on, options) => {
17  const intensity = intensityOf(options.intensity)
18  const setup: Setup = {
19    isHardcore: intensity === 'hardcore',
20    protectedBranches: patternsOf(typeof options.protectedBranches === 'string' ? options.protectedBranches : 'main,master,production,release/*'),
21  }
22
23  on('session.start', async ($, e, next) => {
24    const result = await next(e)
25    if (intensity === 'off') return result
26    await $.command.register({ name: 'map', description: 'GAME MODE map: the branch, the play time and the changed files' })
27    await refresh($, setup)
28    $.clock.every(EVERY_MS, () => {
29      void refresh($, setup).catch(() => undefined)
30    })
31    return result
32  })
33
34  on('turn.complete', async ($, e, next) => {
35    const result = await next(e)
36    if (intensity !== 'off' && e.agentId === undefined) await refresh($, setup).catch(() => undefined)
37    return result
38  }).catch(($, e, next) => next(e))
39
40  on('command.run', { command: 'map' }, async ($) => {
41    const place = await placeOf($)
42    const usage = await $.session.usage()
43    const played = playTime((await $.clock.now()) - usage.startedAt)
44    if (place.branch === null) return { text: `MAP · 저장소 밖 · 플레이 ${played}` }
45    const isProtected = matches(place.branch, setup.protectedBranches)
46    return {
47      text: [
48        `MAP ${place.branch}${isProtected ? ' ⚠ 보호 브랜치' : ''}`,
49        `플레이 ${played}`,
50        `변경 파일 ${place.changed}`,
51        ...(isProtected ? ['보호 브랜치에서 일하는 중: 새 브랜치에서 작업하려면 git switch -c <이름>'] : []),
52      ].join('\n'),
53    }
54  })
55}
56
57type Place = { branch: string | null; changed: number }
58
59/** The branch (`@abc1234` when detached) and how many files differ from it; branch null outside a repository. */
60async function placeOf($: EngineInterface): Promise<Place> {
61  const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 3000 })
62  if (head.exitCode !== 0) return { branch: null, changed: 0 }
63  let branch = head.stdout.trim()
64  if (branch === 'HEAD') {
65    const sha = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { timeoutMs: 3000 })
66    branch = `@${sha.stdout.trim()}`
67  }
68  const status = await $.process.run(['git', 'status', '--porcelain'], { timeoutMs: 5000 })
69  const changed = status.exitCode === 0 ? status.stdout.split('\n').filter(l => l.trim() !== '').length : 0
70  return { branch, changed }
71}
72
73async function refresh($: EngineInterface, setup: Setup): Promise<void> {
74  const usage = await $.session.usage()
75  const played = playTime((await $.clock.now()) - usage.startedAt)
76  let place: Place = { branch: null, changed: 0 }
77  try {
78    place = await placeOf($)
79  } catch {
80    // No git, or no process runner on this surface: the play time alone.
81  }
82  $.ui.status(mapLine(place, played, setup))
83}
84
85export function mapLine(place: Place, played: string, setup: Setup): string {
86  if (place.branch === null) return `MAP 저장소 밖 · 플레이 ${played}`
87  const mark = matches(place.branch, setup.protectedBranches) ? ' ⚠ 보호' : ''
88  const extra = setup.isHardcore ? ` · 변경 ${place.changed}` : ''
89  return `MAP ${place.branch}${mark} · 플레이 ${played}${extra}`
90}
91
92/** `h:mm` */
93export function playTime(ms: number): string {
94  const minutes = Math.max(0, Math.floor(ms / 60_000))
95  return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`
96}
97
98/** `main,release/*` → patterns; `*` matches within one path part. */
99export function patternsOf(list: string): RegExp[] {
100  return list
101    .split(',')
102    .map(p => p.trim())
103    .filter(p => p !== '')
104    .map(p => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')}$`))
105}
106
107function matches(branch: string, patterns: readonly RegExp[]): boolean {
108  return patterns.some(p => p.test(branch))
109}
110
hooks/palette.ts 124 lines
1// GAME MODE shared look (copied into each GAME MODE plugin: a hooks module
2// may only import files of its own plugin). Change every copy together;
3// scripts/check.sh fails when the copies differ.
4//
5// nes / gameboy / amber are raw colors made for dark terminals, taken from
6// the design boards. `theme` uses Claude Code's theme keys, so it follows the
7// person's theme: pick it on a light background. Its `win` is empty: windows
8// draw with no fill there.
9
10export type PaletteName = 'nes' | 'gameboy' | 'amber' | 'theme'
11
12export type Palette = {
13  /** Headings, gold, the level. */
14  title: string
15  ok: string
16  warn: string
17  bad: string
18  info: string
19  dim: string
20  text: string
21  /** A window's border. */
22  frame: string
23  /** A window's fill; '' for none. */
24  win: string
25  /** The bright inner edge of a window, and a highlighted badge's text. */
26  edge: string
27  /** A bar's empty cells. */
28  off: string
29}
30
31const PALETTES: Record<PaletteName, Palette> = {
32  nes: {
33    title: '#f8d830',
34    ok: '#58d854',
35    warn: '#ff8a6a',
36    bad: '#f83800',
37    info: '#3cbcfc',
38    dim: '#b8b8f0',
39    text: '#fcfcfc',
40    frame: '#5060d8',
41    win: '#1c2a8a',
42    edge: '#fcfcfc',
43    off: '#2c3a98',
44  },
45  gameboy: {
46    title: '#e0f8d0',
47    ok: '#9bbc0f',
48    warn: '#e0f8d0',
49    bad: '#f8f8a0',
50    info: '#c4e890',
51    dim: '#8bac0f',
52    text: '#e0f8d0',
53    frame: '#9bbc0f',
54    win: '#1e4a1e',
55    edge: '#e0f8d0',
56    off: '#306230',
57  },
58  amber: {
59    title: '#ffd060',
60    ok: '#ffb000',
61    warn: '#ff7040',
62    bad: '#ff5a2a',
63    info: '#ffe0a0',
64    dim: '#c89040',
65    text: '#ffd9a0',
66    frame: '#ffb000',
67    win: '#2a1700',
68    edge: '#ffe0a0',
69    off: '#4a2a00',
70  },
71  theme: {
72    title: 'claude',
73    ok: 'success',
74    warn: 'warning',
75    bad: 'error',
76    info: 'suggestion',
77    dim: 'subtle',
78    text: 'text',
79    frame: 'promptBorder',
80    win: '',
81    edge: 'text',
82    off: 'subtle',
83  },
84}
85
86export function paletteOf(name: unknown): Palette {
87  return name === 'gameboy' || name === 'amber' || name === 'theme' ? PALETTES[name] : PALETTES.nes
88}
89
90export type Intensity = 'off' | 'casual' | 'hardcore'
91
92export function intensityOf(value: unknown): Intensity {
93  return value === 'off' || value === 'hardcore' ? value : 'casual'
94}
95
96/** A bar of `cells` blocks, `percent` of them full. */
97export function bar(percent: number, cells: number): string {
98  const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
99  return '█'.repeat(full) + '░'.repeat(cells - full)
100}
101
102/** Box props for a window in this palette: a double border and, where the palette has one, a fill. */
103export function windowProps(pal: Palette): { borderStyle: 'double'; borderColor: string; backgroundColor?: string; paddingX: number } {
104  return pal.win === ''
105    ? { borderStyle: 'double', borderColor: pal.frame, paddingX: 1 }
106    : { borderStyle: 'double', borderColor: pal.frame, backgroundColor: pal.win, paddingX: 1 }
107}
108
109/** `m:ss`, or `h:mm:ss` past an hour. */
110export function clock(ms: number): string {
111  const s = Math.max(0, Math.round(ms / 1000))
112  const h = Math.floor(s / 3600)
113  const m = Math.floor((s % 3600) / 60)
114  const sec = s % 60
115  const two = (n: number) => String(n).padStart(2, '0')
116  return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`
117}
118
119/** A cost in the chosen currency: `$1.23`, or `₩1,722` at `krwPerUsd`. */
120export function money(usd: number, currency: 'usd' | 'krw', krwPerUsd: number): string {
121  if (currency === 'krw') return '₩' + String(Math.round(usd * krwPerUsd)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122  return '$' + usd.toFixed(2)
123}
124