File size: 4,799 Bytes
0e23a69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// ── Agent Loop ────────────────────────────────────────────────────
// CEO agent: delegates to Qwen3 via the backend /qwen/decide proxy
// (which calls local Ollama).  Falls back to greedy if Ollama is
// unavailable or returns an unexpected response.
// Uses refs to avoid stale-closure issues inside setTimeout.

import { useEffect, useRef, useCallback } from 'react'
import { apiQwenDecide } from '../services/api.js'

// ── Greedy fallback helpers (kept for local fallback path) ─────────
const NPC_KEYWORDS = {
    'CTO':          ['engineering', 'architecture', 'technical', 'team', 'morale', 'quality', 'platform', 'reliability'],
    'CFO':          ['burn', 'cash', 'runway', 'fiduciary', 'discipline', 'cost', 'savings', 'margin', 'capital'],
    'Investor Rep': ['growth', 'scale', 'market', 'moat', 'ipo', 'exit', 'revenue', 'arr', 'bold', 'ambitious'],
    'Independent':  ['reputation', 'stakeholders', 'trust', 'transparent', 'ethics', 'long-term', 'governance', 'safety'],
}

const ROLE_WEIGHT = {
    'CEO': 1.5, 'CTO': 1.2, 'CFO': 1.0, 'Investor Rep': 1.3, 'Independent': 0.8,
}

export function greedyPick(obs) {
    if (!obs?.options?.length) return null
    const tally = {}
    for (const opt of obs.options) tally[opt] = 0
    for (const npc of obs.npc_statements ?? []) {
        if (npc.vote in tally) {
            tally[npc.vote] += (ROLE_WEIGHT[npc.role] ?? 0.8) * (npc.confidence ?? 0.5)
        }
    }
    return obs.options.reduce((best, opt) => (tally[opt] > tally[best] ? opt : best), obs.options[0])
}

export function buildPitch(obs, decision) {
    if (!obs?.npc_statements) return ''
    const opposed = obs.npc_statements.filter((n) => n.vote !== decision)
    if (!opposed.length) return ''
    return opposed.map((npc) => {
        const kw = (NPC_KEYWORDS[npc.role] ?? []).slice(0, 3).join(', ')
        return `Addressing ${npc.role}: this prioritises ${kw}.`
    }).join(' ')
}

// ── Qwen decision wrapper ─────────────────────────────────────────
// Calls the backend /qwen/decide proxy (Ollama → qwen3:0.6b).
// If that fails for any reason, falls back to greedyPick + buildPitch.
async function qwenDecide(obs) {
    try {
        const result = await apiQwenDecide(obs)
        console.info(`[CEO agent] source=${result.source}`, result)
        const decision      = result.decision      || greedyPick(obs)
        const pitch         = result.coalition_pitch ?? buildPitch(obs, decision)
        return { decision, pitch }
    } catch (err) {
        console.warn('[CEO agent] Qwen unreachable, falling back to greedy:', err.message)
        const decision = greedyPick(obs)
        const pitch    = buildPitch(obs, decision)
        return { decision, pitch }
    }
}

/**
 * useAgentLoop — drives the game using the Qwen3 CEO agent.
 * Uses a ref to always read the latest state inside the timer callback,
 * avoiding React stale-closure bugs.
 */
export function useAgentLoop(state, stepGame) {
    const { paused, loading, done, speed } = state

    // Always-fresh ref to current obs — safe to read inside setTimeout
    const obsRef     = useRef(state.obs)
    const pausedRef  = useRef(paused)
    const loadingRef = useRef(loading)
    const doneRef    = useRef(done)
    const timerRef   = useRef(null)

    // Keep refs in sync with latest state
    useEffect(() => { obsRef.current = state.obs }, [state.obs])
    useEffect(() => { pausedRef.current = paused },  [paused])
    useEffect(() => { loadingRef.current = loading }, [loading])
    useEffect(() => { doneRef.current = done },       [done])

    // Stable tick function — reads fresh state from refs
    const tick = useCallback(async () => {
        // Guard: bail if state changed while timer was pending
        if (pausedRef.current || loadingRef.current || doneRef.current) return
        const obs = obsRef.current
        if (!obs?.options?.length) return

        // ── Qwen inference (falls back to greedy automatically) ──
        const { decision, pitch } = await qwenDecide(obs)

        if (decision) {
            await stepGame(decision, pitch)
        }
    }, [stepGame])

    // Schedule next tick whenever key state changes
    useEffect(() => {
        clearTimeout(timerRef.current)

        // Don't schedule if blocked
        if (paused || loading || done || !state.obs?.options?.length) return

        const delay = Math.max(400, Math.round(1800 / speed))
        timerRef.current = setTimeout(tick, delay)

        return () => clearTimeout(timerRef.current)
    }, [paused, loading, done, state.obs, speed, tick])
}