Nimbin[12]?Games / Entropie / entropie/src/people/talk.c

Entropie git · main

Entropie festival game; first steps

sdl c game entropie cmake · first commit 2026-10-10 · last commit 2026-11-02 (18 hours ago) · synced 10 hours ago

C 88.5% Markdown 9.1% GLSL 2.3%
git clone https://git.christianimmanuel.de/games/Entropie.gitwget https://git.christianimmanuel.de/games/Entropie/archive/Entropie.tar.gz
entropie/src/people/talk.c 35.4 KB · 717 lines raw
#include "people/talk.h"
#include "world/camp.h"
#include "world/tools.h"
#include "people/pockets.h"
#include "world/site.h"
#include "world/stage.h"
#include "world/car.h"
#include "world/why.h"

#include <stdio.h>
#include <string.h>

#define CLOSE_TIME      2.4f  /* the last reply shows this long before the talk ends */
#define PLAYER_LINE     2.6f
#define WALK_AWAY       5.0f  /* further apart than this: the talk is over */
#define RECENT_WOOD    60.0f  /* seconds the player's last wood work counts as "that" */
#define WOODPILE_SIZE   { 1.6f, 0.0f, 0.6f }
#define WOODPILE_AHEAD  TALK_PILE_AHEAD

enum {
    NODE_END,
    NODE_OFFER,    /* they offer to finish the player's wood work */
    NODE_HELLO,    /* the player clicked on someone idle */
    NODE_TASKS,    /* the player wants something done */
    NODE_HELPER,   /* ... on someone helping */
    NODE_WORKER,   /* ... on someone working on an order */
    NODE_ASK,      /* they ask if there's anything to do */
    NODE_SUGGEST,  /* they suggest a job that needs doing */
    NODE_HOW,      /* they don't know how: "can you show me?" */
    NODE_WHERE,    /* they don't know where the firewood goes */
    NODE_PLACE,    /* being shown the place: "here?" */
    NODE_WATCHING, /* being shown how */
    NODE_ASK_HAND, /* a worker asks the player to join in */
    NODE_TOOL,     /* a worker asks for the player's axe */
    NODE_FESTIVAL, /* about the festival: their part in it, who leads what */
    NODE_SHOP,     /* the one with the car keys: what to get at the shops */
    NODE_COUNT
};

#define TASK_CHOICES "Make firewood", "Cut some trees", "Collect the trash", "Look after the fire"

static const char *const CHOICES[NODE_COUNT][TALK_CHOICES] = {
    [NODE_OFFER]    = { "Yes, make firewood of it", "Yes, and cut more trees", "Help me instead", "No, thanks" },
    [NODE_HELLO]    = { "Could you do something for me?", "Come and help me", "Who are you?",
                        "About the festival...", "Nothing, bye" },
    [NODE_TASKS]    = { TASK_CHOICES, "Never mind" },
    [NODE_HELPER]   = { TASK_CHOICES, "That's all, thanks" },
    [NODE_WORKER]   = { "Keep going", "I'll show you where the firewood goes", "Come and help me instead", "Take a break" },
    [NODE_ASK]      = { TASK_CHOICES, "No, thanks" },
    [NODE_SUGGEST]  = { "Good idea, go ahead", "Help me instead", "Not now" },
    [NODE_HOW]      = { "I'll show you", "Never mind" },
    [NODE_WHERE]    = { "I'll show you where", "Find a good place for it", "Leave it where it is" },
    [NODE_PLACE]    = { "Yes, stack it here", "Not yet, keep following", "Forget it" },
    [NODE_WATCHING] = { "Watch closely", "Never mind" },
    [NODE_ASK_HAND] = { "Sure", "Not now" },
    [NODE_TOOL]     = { "Here you go", "No, I still need it" },
    [NODE_FESTIVAL] = { "Great work - thanks!", "(who leads what)", "(you take it over)", "We need some shopping done", "Bye" }, /* (2 and 3: see Talk_choices) */
    [NODE_SHOP]     = { "(boards)", "(nails, screws)", "(paint)", "(food)", "(go)" }, /* (see Talk_choices) */
};

static const Object *g_talk_player; /* (for the choices: what's between them) */
static const Talk *g_talk;          /* (for what they say: the shopping list) */
static World *g_talk_world;         /* (for the choices: who leads what) */

static const Order TASKS[] = { ORDER_FIREWOOD, ORDER_TREES, ORDER_TRASH, ORDER_FIRE };

static float flatDistance(Vec3 a, Vec3 b) {
    return sqrtf((a.x - b.x) * (a.x - b.x) + (a.z - b.z) * (a.z - b.z));
}

static bool shy(const Object *o)    { return o->temperament.pride < -0.3f; }
static bool lively(const Object *o) { return o->temperament.energy > 0.5f; }

#define PRAISE_AGAIN 3600.0f /* game s: "great work" counts once in this long */
#define PRAISE_LIFT  0.06f
#define TAKE_ON      0.45f   /* commitment to take on leading something */

static bool floorProject(int p) {
    return p == PROJECT_HOFBUEHNE || p == PROJECT_OBSTWIESE || p == PROJECT_KARTOFFELACKER || p == PROJECT_HIDDEN;
}

/* A floor nobody leads (of the people here), or PROJECT_NONE. */
static int openFloor(void) {
    if (!g_talk_world) return PROJECT_NONE;
    static const int FLOORS[] = { PROJECT_HOFBUEHNE, PROJECT_OBSTWIESE, PROJECT_KARTOFFELACKER, PROJECT_HIDDEN };
    for (int k = 0; k < 4; k++)
        if (!Cast_leadOf(g_talk_world->objects, g_talk_world->count, (Project)FLOORS[k])) return FLOORS[k];
    return PROJECT_NONE;
}

/* Their part in it, how it's going and how they feel about it. */
static const char *festivalLine(const Object *o) {
    static char line[AI_SPEECH_MAX];
    const char *mood = o->commitment > 0.7f ? "Going great!" : o->commitment > 0.45f ? "It's okay." : o->commitment > 0.3f ? "Honestly, I'm getting fed up."
                                                                                                                            : "I'm about done with it.";
    if (o->guest) { /* (a guest: how they like it) */
        snprintf(line, sizeof line, "%s", o->fun > 0.85f ? "Best festival ever! I'm coming back next year." : o->fun > 0.65f ? "Great music, great people. Love it!"
                                        : o->fun > 0.45f ? "It's nice. Could be more going on." : "Honestly? Bit boring so far.");
        return line;
    }
    int p = o->leads != PROJECT_NONE ? o->leads : o->helps;
    if (p == PROJECT_NONE) { snprintf(line, sizeof line, "Me? Nothing really. %s", o->commitment > 0.45f ? "Could do something, though." : ""); return line; }
    const Object *s = g_talk_world && floorProject(p) ? Stage_of(g_talk_world->objects, g_talk_world->count, (Project)p) : NULL;
    if (s && s->build_stage < BUILD_DONE)
        snprintf(line, sizeof line, "%s the %s: %s, %d%%. %s", o->leads == p ? "I lead" : "I help with", Cast_projectName((Project)p),
                 Stage_stepName(s->build_stage), (int)(Stage_part(s) * 100.0f), mood);
    else if (s) snprintf(line, sizeof line, "The %s stage is built! %s", Cast_projectName((Project)p), mood);
    else snprintf(line, sizeof line, "%s the %s. %s", o->leads == p ? "I lead" : "I help with", Cast_projectName((Project)p), mood);
    return line;
}

static int shopTotal(const Talk *t) {
    int sum = 0;
    for (int k = 0; k < SHOP_ITEMS; k++) sum += t->shop[k] * SHOP_PRICE[k];
    return sum;
}

/* Who has the car keys (here), or NULL. */
static Object *keyHolder(void) {
    for (int i = 0; g_talk_world && i < g_talk_world->count; i++) {
        Object *o = g_talk_world->objects[i];
        if (o->is_character && o->keys && !strcmp(o->keys, "Car keys")) return o;
    }
    return NULL;
}

static const char *shopLine(void) {
    static char line[AI_SPEECH_MAX];
    int cash = g_talk_world ? (int)g_talk_world->cash : 0, total = g_talk ? shopTotal(g_talk) : 0;
    if (total == 0) snprintf(line, sizeof line, "Sure. What do we need? There's %d EUR in the cash box.", cash);
    else snprintf(line, sizeof line, "So far that's %d EUR - %d in the box. Anything else?", total, cash);
    return line;
}

/* What they say when the conversation gets to a node. */
static const char *lineFor(const Object *o, int node) {
    switch (node) {
    case NODE_OFFER:   return shy(o) ? "Um... should I finish that for you?" : "Do you want me to finish that?";
    case NODE_HELLO:   return shy(o) ? "Oh... hi." : lively(o) ? "Hey! What's up?" : o->temperament.pride > 0.5f ? "Yes?" : "Hi! What's up?";
    case NODE_TASKS:   return "What do you need?";
    case NODE_HELPER:  return "What should I do?";
    case NODE_WORKER:
        switch (o->ai.order) {
        case ORDER_TREES: return "Cutting trees for firewood. All good?";
        case ORDER_TRASH: return "Cleaning up. All good?";
        case ORDER_FIRE:  return "I'm looking after the fire. All good?";
        default:          return "Making firewood. All good?";
        }
    case NODE_ASK:     return shy(o) ? "Um... can I help with anything?" : "Can I help you with something?";
    case NODE_SUGGEST:
        switch (o->ai.suggested) {
        case ORDER_TREES: return "We're running out of wood. Shall I cut some trees?";
        case ORDER_TRASH: return "There's trash everywhere. Shall I clean up a bit?";
        case ORDER_FIRE:  return "It's getting dark. Shall I get the fire going?";
        default:          return "We're low on firewood. Shall I make some?";
        }
    case NODE_HOW:     return "I've never done that. Can you show me?";
    case NODE_WHERE:   return "Where should the firewood go?";
    case NODE_PLACE:   return "Here?";
    case NODE_WATCHING: return "I'm watching...";
    case NODE_ASK_HAND: return "Want to give me a hand with the wood?";
    case NODE_TOOL:    return shy(o) ? "Um... could I maybe use the axe?" : "Can I have the axe? I'd like to try myself.";
    case NODE_FESTIVAL: return festivalLine(o);
    case NODE_SHOP:    return shopLine();
    default:           return "";
    }
}

/* A job they were called away from and haven't gone back to (ORDER_NONE: none). */
static const char *const JOB_WORD[ORDER_COUNT] = { "", "firewood", "trees", "tidying", "fire" };
static Order pausedJob(const Object *npc) {
    return npc->ai.mode != AI_WORK ? npc->ai.paused_order : ORDER_NONE;
}

static void go(Talk *talk, int node) {
    g_talk = talk;
    talk->node = node;
    talk->waited = 0.0f;
    talk->nudged = false;
    Ai_say(talk->with, lineFor(talk->with, node));
}

/* They answer and the conversation ends. */
static void wrapUp(Talk *talk, const char *reply) {
    if (reply) Ai_say(talk->with, reply);
    talk->node = NODE_END;
    talk->closing = CLOSE_TIME;
}

/* Where an order's work happens: the wood the player was just working on,
   else the nearest wood (or, for trees, the nearest tree) around the player. */
static Vec3 workArea(const World *world, const Object *player, Order order) {
    const Work *w = &player->work_state;
    if (w->last_target && w->since < RECENT_WOOD) return Object_colliderCenter(w->last_target, 0);
    const Object *best = NULL;
    float best_dist = 25.0f;
    for (int i = 0; i < world->count; i++) {
        const Object *o = world->objects[i];
        bool wood = Tools_splittable(o) || Tools_cuttable(o) || (o->type == OBJ_TREE && o->toppled);
        if (order == ORDER_TREES) wood = wood || (o->type == OBJ_TREE && !o->toppled);
        if (!wood || o->is_held) continue;
        float d = flatDistance(o->position, player->position);
        if (d < best_dist) {
            best_dist = d;
            best = o;
        }
    }
    return best ? Object_colliderCenter(best, 0) : player->position;
}

/* A woodpile in front of the player (or one that is already there). */
static Object *woodpileHere(World *world, const Object *player) {
    Vec3 at = vec3_add(player->position, vec3_scale(Object_facing(player), WOODPILE_AHEAD));
    for (int i = 0; i < world->count; i++) {
        Object *o = world->objects[i];
        if (o->type == OBJ_WOODPILE && flatDistance(o->position, at) < 3.0f) return o;
    }
    at.y = Terrain_height(&world->terrain, at.x, at.z);
    const Vec3 size = WOODPILE_SIZE;
    Object *pile = Object_create(OBJ_WOODPILE, PALETTE_WARM, at, size, 0.0f);
    if (!pile) return NULL;
    Object_setRotation(pile, vec3(0.0f, player->rotation.y, 0.0f));
    return World_add(world, pile) ? pile : NULL;
}

/* Where they'd stack it themselves: a woodpile there already (within PILE_SEEK), else a
   spot near the work: clear, level, off the roads, by a wall if there's one. */
#define PILE_SEEK  40.0f
#define PILE_RING0 3.0f   /* m from the work: the ring searched */
#define PILE_RING1 9.0f
static float pileSpotScore(World *world, Vec3 p, Vec3 work) {
    float g = Terrain_height(&world->terrain, p.x, p.z);
    if (World_groundHeight(world, p.x, p.z, g + 3.0f) > g + 0.05f) return -1.0f;              /* (something stands there) */
    float slope = fabsf(Terrain_height(&world->terrain, p.x + 0.8f, p.z) - Terrain_height(&world->terrain, p.x - 0.8f, p.z))
                + fabsf(Terrain_height(&world->terrain, p.x, p.z + 0.8f) - Terrain_height(&world->terrain, p.x, p.z - 0.8f));
    if (slope > 0.25f || Site_road(p.x, p.z) > 0.2f) return -1.0f;
    float score = 1.0f - slope - 0.03f * flatDistance(p, work);
    for (int i = 0; i < world->count; i++) {
        const Object *o = world->objects[i];
        if (o->collider_count == 0 || o->is_character || o->type == OBJ_DECAL || o->type == OBJ_PUDDLE || o->type == OBJ_STINK) continue;
        float d = flatDistance(o->position, p) - Object_reach(o);
        if (d > 2.0f) continue;
        if (!o->is_static && d < 0.8f) return -1.0f;                       /* (loose things lying there) */
        if (o->is_static && d < 0.6f) return -1.0f;
        if (o->is_static && Object_reach(o) > 1.5f) score += 0.5f;            /* (a wall to stack it against) */
    }
    return score;
}

static Object *pileOwnPlace(World *world, const Object *npc) {
    Vec3 work = npc->ai.work_area;
    for (int i = 0; i < world->count; i++) {
        Object *o = world->objects[i];
        if (o->type == OBJ_WOODPILE && flatDistance(o->position, work) < PILE_SEEK) return o;
    }
    Vec3 best = work;
    float best_score = -1.0f;
    for (int r = 0; r < 4; r++)
        for (int k = 0; k < 16; k++) {
            float rad = PILE_RING0 + (PILE_RING1 - PILE_RING0) * (float)r / 3.0f, a = (float)k * PI_F / 8.0f;
            Vec3 p = vec3_add(work, vec3(cosf(a) * rad, 0.0f, sinf(a) * rad));
            float sc = pileSpotScore(world, p, work);
            if (sc > best_score) { best_score = sc; best = p; }
        }
    if (best_score < 0.0f) return NULL;
    best.y = Terrain_height(&world->terrain, best.x, best.z);
    const Vec3 size = WOODPILE_SIZE;
    Object *pile = Object_create(OBJ_WOODPILE, PALETTE_WARM, best, size, 0.0f);
    if (!pile) return NULL;
    Object_setRotation(pile, vec3(0.0f, yaw_along(vec3_sub(best, work)) + PI_F * 0.5f, 0.0f)); /* (lengthwise across the way to it) */
    return World_add(world, pile) ? pile : NULL;
}

/* Kurti and his sons lend you the land - your festival, your work: no help from them. */
static bool ownerSaysNo(Talk *talk, const Object *npc) {
    if (!npc->lives_here) return false;
    wrapUp(talk, "Your festival, your work - I'll be at the fire.");
    return true;
}

static bool woodJob(Order what) { return what == ORDER_FIREWOOD || what == ORDER_TREES; }

/* A job: they may say no. Wood jobs: unless they need to be shown how, or
   where the firewood goes. their_idea: they asked for something to do. */
static void order(Talk *talk, World *world, const Object *player, Order what, bool their_idea) {
    Object *npc = talk->with;
    const char *reply = NULL;
    if (!Ai_willDo(npc, world->objects, world->count, what, their_idea, &reply)) {
        wrapUp(talk, reply);
        return;
    }
    if (what == ORDER_TRASH || what == ORDER_FIRE) {
        Object *fire = Camp_fireNear(world->objects, world->count, player->position, 80.0f, false);
        Ai_order(npc, what, what == ORDER_FIRE && fire ? fire->position : player->position);
        wrapUp(talk, reply);
        return;
    }
    if (!npc->ai.knows_wood) {
        npc->ai.pending = what;
        go(talk, NODE_HOW);
        return;
    }
    Ai_order(npc, what, workArea(world, player, what));
    if (!npc->ai.woodpile && !npc->ai.no_pile) {
        Ai_say(npc, "Sure. Where should the firewood go?");
        talk->node = NODE_WHERE;
        return;
    }
    wrapUp(talk, reply);
}

void Talk_showHere(World *world, Object *player, Object *npc) {
    Object *old = npc->ai.woodpile;
    Object *pile = woodpileHere(world, player);
    if (!pile) return;
    /* A new place: everybody stacks there now, and carries the old stack over */
    if (old && old != pile) {
        old->moved_to = pile;
        for (int i = 0; i < world->count; i++) {
            Object *o = world->objects[i];
            if (o->type == OBJ_HUMAN && o->ai.woodpile == old && o != npc) o->ai.woodpile = pile;
        }
    }
    Ai_setWoodpile(npc, pile);
    npc->ai.no_pile = false;
    Ai_say(npc, "Got it! I'll stack it here.");
}

/* The player gives their tool for this job to npc (into a free hand). */
static bool handOver(Object *player, Object *npc, Job job) {
    int hand = -1;
    if (!Tools_canDo(Tools_held(player, &hand), job) || hand < 0) return false;
    Object *tool = player->hands.item[hand];
    Hands_drop(player, hand);
    for (int h = 0; h < 2; h++)
        if (npc->hands.state[h] == HAND_HOLDING) Hands_drop(npc, h);
    return Hands_receive(npc, npc->main_hand, tool);
}

/* The tool a worker's talk is about: the one in their hand, else the chainsaw. */
static int talkTool(const Object *npc) {
    ToolKind held = Tools_held(npc, NULL);
    return held == TOOL_AXE || held == TOOL_CHAINSAW ? (int)held : (int)TOOL_CHAINSAW;
}

/* The lighter between the two: what the player can say about it (instead of "Who are you?"). */
/* ... or a cigarette: asking them for one, offering one. */
enum { LIGHTER_NONE, LIGHTER_MINE_BACK, LIGHTER_RETURN, LIGHTER_BORROW, CIG_ASK, CIG_OFFER };

static int lighterTopic(const Object *player, const Object *npc) {
    if (npc->lighter && npc->lighter_of == player && !player->lighter) return LIGHTER_MINE_BACK;
    if (player->lighter && player->lighter_of == npc && !npc->lighter) return LIGHTER_RETURN;
    if (npc->smoker > 0.0f && Pockets_hasSmoke(player) && (!Pockets_hasSmoke(npc) || npc->craving > 0.6f)) return CIG_OFFER;
    if (!Pockets_hasSmoke(player) && Pockets_hasSmoke(npc)) return CIG_ASK;
    if ((!player->lighter || player->lighter_uses <= 0) && npc->lighter && npc->lighter_uses > 0) return LIGHTER_BORROW;
    return LIGHTER_NONE;
}

static void talkLighter(Talk *talk, Object *player, int topic) {
    Object *npc = talk->with;
    if (topic == LIGHTER_MINE_BACK) {
        if (npc->drunk > 0.6f && npc->ai.character.willing < 0.5f) wrapUp(talk, "Lighter? What lighter?");
        else { Pockets_passLighter(npc, player); wrapUp(talk, shy(npc) ? "Oh! Sorry... here." : "Oh, right - here you go."); }
    } else if (topic == CIG_OFFER) {
        Pockets_giveSmoke(player, npc);
        npc->craving = fmaxf(npc->craving, 0.8f); /* (lit soon) */
        npc->ai.annoyed = fmaxf(0.0f, npc->ai.annoyed - 0.3f);
        wrapUp(talk, shy(npc) ? "Oh... thank you!" : "You're a star. Thanks!");
    } else if (topic == CIG_ASK) {
        int has = npc->cigarettes + npc->tobacco;
        if (has < 3 || npc->ai.annoyed > 0.5f) wrapUp(talk, has < 3 ? "Sorry, those are my last ones." : "Buy your own.");
        else {
            Pockets_giveSmoke(npc, player);
            wrapUp(talk, npc->cigarettes == 0 ? "Here, I rolled you one." : "Sure, have one.");
        }
    } else if (topic == LIGHTER_RETURN) {
        Pockets_passLighter(player, npc);
        wrapUp(talk, "Thanks!");
    } else if (npc->ai.annoyed > 0.6f) {
        wrapUp(talk, "Get your own.");
    } else {
        if (player->lighter) { player->lighter = false; player->lighter_of = NULL; } /* (the empty one: away with it) */
        Pockets_passLighter(npc, player);
        wrapUp(talk, npc->lighter_uses < 5 ? "Here - it's nearly empty though." : "Sure. I want it back though!");
    }
}

static void festivalAnswer(Talk *talk, Object *player, int choice) {
    Object *npc = talk->with;
    char reply[AI_SPEECH_MAX];
    if (choice == 1) { /* praise */
        if (npc->ai.praised > 0.0f) { wrapUp(talk, lively(npc) ? "Ha, you said that already!" : "Thanks."); return; }
        npc->commitment = fminf(1.0f, npc->commitment + PRAISE_LIFT);
        npc->ai.praised = PRAISE_AGAIN;
        wrapUp(talk, shy(npc) ? "Oh... thanks. That helps." : "Thanks! Good to hear that.");
    } else if (choice == 2) { /* can you lead the open floor? */
        int open = openFloor();
        if (open == PROJECT_NONE) { wrapUp(talk, "Every floor has somebody leading it. Good, isn't it?"); return; }
        if (npc->leads != PROJECT_NONE) { snprintf(reply, sizeof reply, "I've got the %s already.", Cast_projectName((Project)npc->leads)); wrapUp(talk, reply); return; }
        if (npc->lives_here || npc->commitment < TAKE_ON || npc->ai.character.willing < 0.4f) { wrapUp(talk, shy(npc) ? "Um... not me, sorry." : "Not me - I'm not up for that."); return; }
        npc->leads = open;
        if (npc->helps == open) npc->helps = PROJECT_NONE;
        npc->commitment = fminf(1.0f, npc->commitment + 0.05f);
        snprintf(reply, sizeof reply, "Alright - I'll lead the %s!", Cast_projectName((Project)open));
        wrapUp(talk, reply);
    } else if (choice == 3) {
        if (npc->leads != PROJECT_NONE) { /* you take over theirs */
            int p = npc->leads;
            if (npc->temperament.pride > 0.4f && npc->commitment > 0.5f) { wrapUp(talk, "No way - that one's mine."); return; }
            player->leads = p;
            npc->leads = PROJECT_NONE;
            npc->helps = p;
            npc->commitment = clampf(npc->commitment + (npc->commitment > 0.5f ? -0.1f : 0.08f), 0.0f, 1.0f); /* (proud of it: it stings; fed up: a relief) */
            snprintf(reply, sizeof reply, npc->commitment > 0.45f ? "Okay... the %s is yours. I'll help." : "Thank god. The %s is yours.", Cast_projectName((Project)p));
            wrapUp(talk, reply);
        } else if (floorProject(player->leads)) { /* come and help with yours */
            if (npc->commitment < 0.2f || npc->lives_here) { wrapUp(talk, "Nah, not today."); return; }
            npc->helps = player->leads;
            snprintf(reply, sizeof reply, "Sure, I'll help with the %s.", Cast_projectName((Project)player->leads));
            wrapUp(talk, reply);
        } else wrapUp(talk, "Lead a floor yourself first - then I'll help you with it.");
    } else if (choice == 4) { /* the shopping: whoever has the car keys */
        Object *keys = keyHolder();
        Object *car = NULL;
        for (int i = 0; g_talk_world && i < g_talk_world->count; i++) if (g_talk_world->objects[i]->type == OBJ_CAR) car = g_talk_world->objects[i];
        if (keys != npc) {
            if (keys) snprintf(reply, sizeof reply, "%s has the car keys - ask %s.", keys->name, keys->female ? "her" : "him");
            else snprintf(reply, sizeof reply, "Nobody here has a car right now.");
            wrapUp(talk, reply);
        } else if (!car || (g_talk_world && g_talk_world->shopping)) wrapUp(talk, "The car's not here - wait till it's back.");
        else if (car->load && car->load->n > 0) wrapUp(talk, "Let's get the car empty first.");
        else if (npc->drunk > 0.3f) wrapUp(talk, "I've had a few - I'm not driving now.");
        else if (npc->commitment < 0.2f || npc->needs.stamina < 0.15f) wrapUp(talk, "Not today. I'm done in.");
        else {
            memset(talk->shop, 0, sizeof talk->shop);
            go(talk, NODE_SHOP);
        }
    } else if (g_talk_world && Why_open(g_talk_world) >= 0) { /* why do festivals ...? */
        int k = Why_open(g_talk_world);
        if (Why_knows(npc, k)) {
            g_talk_world->why[k] = WHY_UNDERSTOOD;
            npc->commitment = fminf(1.0f, npc->commitment + 0.03f); /* (asked: they like that) */
            wrapUp(talk, Why_get(k)->answer);
        } else {
            snprintf(reply, sizeof reply, "No idea. Ask %s - or %s.", Why_get(k)->knowers[0], Why_get(k)->knowers[2]);
            wrapUp(talk, reply);
        }
    } else wrapUp(talk, "See you.");
}

static void shopAnswer(Talk *talk, int choice) {
    if (choice >= 1 && choice <= SHOP_ITEMS) { /* one more of it (past the most: none) */
        int k = choice - 1;
        talk->shop[k] = (talk->shop[k] + 1) % (SHOP_MAX[k] + 1);
        if (g_talk_world && shopTotal(talk) > (int)g_talk_world->cash) {
            talk->shop[k] = 0;
            Ai_say(talk->with, "That's more than we've got in the box.");
            talk->node = NODE_SHOP;
            return;
        }
        go(talk, NODE_SHOP);
    } else if (shopTotal(talk) > 0) {
        talk->errand = talk->with;
        wrapUp(talk, "Right - I'm off to the hardware store. Back in an hour or so.");
    } else wrapUp(talk, "Okay - another time then.");
}

static void answer(Talk *talk, World *world, Object *player, int choice) {
    Object *npc = talk->with;
    switch (talk->node) {
    case NODE_HELLO:
        if (choice == 1 && pausedJob(npc)) {
            Order job = pausedJob(npc);
            Ai_resumePaused(npc);
            char reply[64];
            snprintf(reply, sizeof reply, shy(npc) ? "Oh... sorry. The %s." : "Oh right - the %s!", JOB_WORD[job]);
            wrapUp(talk, reply);
        } else if (choice == 1) go(talk, NODE_TASKS);
        else if (choice == 2) {
            if (npc->ai.character.willing < 0.3f && npc->needs.stamina < 0.5f) wrapUp(talk, "Maybe later, I'm beat.");
            else { if (!ownerSaysNo(talk, npc)) { Ai_help(npc, player); wrapUp(talk, shy(npc) ? "Okay..." : "Sure, I'm with you."); } }
        } else if (choice == 3 && lighterTopic(player, npc) != LIGHTER_NONE) {
            talkLighter(talk, player, lighterTopic(player, npc));
        } else if (choice == 3) {
            Ai_say(npc, npc->ai.character.about ? npc->ai.character.about : npc->guest ? "Here for the festival! A friend told me about it." : "Me? Nobody special.");
        } else if (choice == 4) go(talk, NODE_FESTIVAL);
        else wrapUp(talk, "See you.");
        break;
    case NODE_TASKS:
    case NODE_HELPER:
    case NODE_ASK:
        if (choice >= 1 && choice <= 4) {
            if (talk->node == NODE_HELPER) Ai_stopHelping(npc);
            order(talk, world, player, TASKS[choice - 1], talk->node == NODE_ASK);
        } else if (talk->node == NODE_HELPER) { Ai_stopHelping(npc); wrapUp(talk, "Anytime!"); }
        else wrapUp(talk, talk->node == NODE_ASK ? "Alright." : "Okay.");
        break;
    case NODE_OFFER:
        if (choice == 1) order(talk, world, player, ORDER_FIREWOOD, true);
        else if (choice == 2) order(talk, world, player, ORDER_TREES, true);
        else if (choice == 3) { if (!ownerSaysNo(talk, npc)) { Ai_help(npc, player); wrapUp(talk, "Sure, I'm with you."); } }
        else wrapUp(talk, "Alright.");
        break;
    case NODE_SUGGEST:
        if (choice == 1) order(talk, world, player, npc->ai.suggested != ORDER_NONE ? npc->ai.suggested : ORDER_FIREWOOD, true);
        else if (choice == 2) { if (!ownerSaysNo(talk, npc)) { Ai_help(npc, player); wrapUp(talk, "Sure, I'm with you."); } }
        else wrapUp(talk, "Maybe later then.");
        break;
    case NODE_WORKER:
        if (choice == 1) wrapUp(talk, "Will do.");
        else if (choice == 2 && woodJob(npc->ai.order)) { Ai_show(npc, SHOW_PLACE, player); wrapUp(talk, "Lead the way."); }
        else if (choice == 2) { Ai_stopWork(npc); go(talk, NODE_TASKS); }
        else if (choice == 3) { Ai_stopWork(npc); if (!ownerSaysNo(talk, npc)) { Ai_help(npc, player); wrapUp(talk, "Sure!"); } }
        else if (choice == 5) {
            int tool = talkTool(npc);
            if (!(npc->ai.banned & (1u << tool))) {
                bool obeys = Ai_banTool(npc, tool, true);
                wrapUp(talk, obeys ? (shy(npc) ? "Oh... okay, sorry." : "Okay, I'll leave it.")
                                   : npc->temperament.pride > 0.3f ? "Pfft. I know what I'm doing." : "Yeah, yeah...");
            } else {
                Ai_banTool(npc, tool, false);
                wrapUp(talk, "Alright!");
            }
        }
        else { Ai_stopWork(npc); wrapUp(talk, "Thanks, I could use one."); }
        break;
    case NODE_HOW:
        if (choice == 1) { Ai_show(npc, SHOW_HOW, player); wrapUp(talk, "Okay, I'll watch you."); }
        else { npc->ai.pending = ORDER_NONE; wrapUp(talk, "Okay."); }
        break;
    case NODE_WHERE:
        if (choice == 1) { Ai_show(npc, SHOW_PLACE, player); wrapUp(talk, "Lead the way. Click where it should go."); }
        else if (choice == 2) { /* they think it over, then pick a place */
            Object *pile = pileOwnPlace(world, npc);
            Body_think(&npc->body, 1.0f + npc->ai.character.ponder);
            if (pile) { Ai_setWoodpile(npc, pile); wrapUp(talk, pile->stored ? "There's a pile already - I'll put it there." : "Hm... I'll stack it over there."); }
            else { npc->ai.no_pile = true; wrapUp(talk, "Hm, no good spot here. I'll leave it lying."); }
        } else { npc->ai.no_pile = true; wrapUp(talk, "Okay, I'll leave it there."); }
        break;
    case NODE_PLACE:
        if (choice == 1) {
            Talk_showHere(world, player, npc);
            wrapUp(talk, NULL);
        } else if (choice == 2) wrapUp(talk, "Okay.");
        else { Ai_stopShowing(npc); wrapUp(talk, "Okay."); }
        break;
    case NODE_WATCHING:
        if (choice == 1) wrapUp(talk, "Okay!");
        else { Ai_stopShowing(npc); wrapUp(talk, "Okay."); }
        break;
    case NODE_ASK_HAND:
        wrapUp(talk, choice == 1 ? "Great! I'll cut, you split." : "No problem.");
        break;
    case NODE_FESTIVAL: festivalAnswer(talk, player, choice); break;
    case NODE_SHOP:     shopAnswer(talk, choice); break;
    case NODE_TOOL:
        if (choice == 1 && handOver(player, npc, JOB_SPLIT)) wrapUp(talk, "Thanks! I'll be careful.");
        else { npc->ai.tool_waiting = true; wrapUp(talk, "Okay, I'll wait till you're done."); }
        break;
    default:
        break;
    }
}

static int nodeFor(const Object *npc, Topic topic) {
    switch (topic) {
    case TOPIC_OFFER:    return NODE_OFFER;
    case TOPIC_ASK:      return NODE_ASK;
    case TOPIC_SUGGEST:  return NODE_SUGGEST;
    case TOPIC_WHERE:    return NODE_WHERE;
    case TOPIC_ASK_HAND: return NODE_ASK_HAND;
    case TOPIC_TOOL:     return NODE_TOOL;
    case TOPIC_NONE:     break;
    }
    const Ai *ai = &npc->ai;
    if (ai->mode == AI_FOLLOW) return ai->showing == SHOW_PLACE ? NODE_PLACE : NODE_WATCHING;
    if (ai->mode == AI_WORK) return NODE_WORKER;
    if (ai->mode == AI_HELP) return NODE_HELPER;
    return NODE_HELLO;
}

void Talk_begin(Talk *talk, World *world, Object *player, Object *with, Topic topic) {
    g_talk_world = world;
    g_talk_player = player;
    if (talk->with) Ai_setTalking(talk->with, false);
    memset(talk, 0, sizeof(*talk));
    talk->with = with;
    Ai_setTalking(with, true);
    go(talk, nodeFor(with, topic));
}

static void end(Talk *talk) {
    if (talk->with) Ai_setTalking(talk->with, false);
    talk->with = NULL;
    talk->node = NODE_END;
}

/* How long someone waits for an answer: the lively and the proud not long,
   the shy and the slow a good while. */
static float patience(const Object *o) {
    const Mood *m = &o->temperament;
    return clampf(12.0f * (1.0f - 0.35f * m->energy) * (1.0f - 0.3f * m->pride), 5.0f, 25.0f);
}

static const char *nudgeLine(const Object *o) {
    const Mood *m = &o->temperament;
    if (m->pride < -0.4f) return "Um...?";
    if (m->pride > 0.5f) return "So?";
    if (m->energy > 0.5f) return "Hello? Anybody home?";
    return "Hello?";
}

static const char *giveUpLine(const Object *o) {
    const Mood *m = &o->temperament;
    if (m->pride < -0.4f) return "Sorry... I'll leave you alone.";
    if (m->pride > 0.5f) return "Forget it.";
    if (m->energy > 0.5f) return "Okay, whatever!";
    if (m->energy < -0.4f) return "...never mind.";
    return "Never mind then.";
}

void Talk_update(Talk *talk, World *world, Object *player, int choice, bool leave, float dt) {
    talk->player_time = fmaxf(0.0f, talk->player_time - dt);
    if (!talk->with) return;
    Object *npc = talk->with;
    if (talk->node == NODE_END) {
        if ((talk->closing -= dt) <= 0.0f) end(talk);
        return;
    }
    bool apart = flatDistance(npc->position, player->position) > WALK_AWAY;
    if (leave || apart || Body_isDown(&npc->body) || Body_isDown(&player->body)) {
        Ai_say(npc, apart ? "Oh... okay then." : "Okay.");
        if (npc->ai.mode == AI_APPROACH) npc->ai.talk_cooldown = 90.0f;
        end(talk);
        return;
    }
    const char *choices[TALK_CHOICES];
    int n = Talk_choices(talk, choices);
    if (n > 0 && (choice < 1 || choice > n)) { /* no answer: they don't wait forever */
        talk->waited += dt;
        float wait = patience(npc);
        if (!talk->nudged && talk->waited > 0.55f * wait) {
            Ai_say(npc, nudgeLine(npc));
            talk->nudged = true;
        }
        if (talk->waited > wait) {
            Ai_say(npc, giveUpLine(npc));
            npc->ai.annoyed = fminf(1.0f, npc->ai.annoyed + 0.4f);
            npc->ai.talk_cooldown = fmaxf(npc->ai.talk_cooldown, 120.0f);
            end(talk);
        }
        return;
    }
    if (choice < 1 || choice > n) return;
    snprintf(talk->player_line, sizeof(talk->player_line), "%s", choices[choice - 1]);
    talk->player_time = PLAYER_LINE;
    answer(talk, world, player, choice);
}

bool Talk_active(const Talk *talk) {
    return talk->with != NULL;
}

int Talk_choices(const Talk *talk, const char *out[TALK_CHOICES]) {
    if (!talk->with || talk->node <= NODE_END || talk->node >= NODE_COUNT) return 0;
    int n = 0;
    for (int k = 0; k < TALK_CHOICES; k++)
        if (CHOICES[talk->node][k]) out[n++] = CHOICES[talk->node][k];
    if (talk->node == NODE_WORKER && !woodJob(talk->with->ai.order)) out[1] = "Do something else instead";
    if (talk->node == NODE_HELLO && pausedJob(talk->with)) { /* called away from a job, and forgot it: a reminder */
        static char line[64];
        snprintf(line, sizeof line, "Back to the %s, please", JOB_WORD[pausedJob(talk->with)]);
        out[0] = line;
    }
    if (talk->node == NODE_HELLO && g_talk_player) {
        static const char *const LIGHTER_LINE[] = { NULL, "Can I have my lighter back?", "Here's your lighter back", "Can I borrow your lighter?",
                                                    "Can I have a cigarette?", "Want a cigarette?" };
        int topic = lighterTopic(g_talk_player, talk->with);
        if (topic != LIGHTER_NONE) out[2] = LIGHTER_LINE[topic];
    }
    if (talk->node == NODE_FESTIVAL) {
        static char lead_line[64], take_line[64];
        const Object *npc = talk->with;
        int open = openFloor();
        if (open != PROJECT_NONE) snprintf(lead_line, sizeof lead_line, "Can you lead the %s?", Cast_projectName((Project)open));
        else snprintf(lead_line, sizeof lead_line, "Who leads what?");
        if (npc->leads != PROJECT_NONE) snprintf(take_line, sizeof take_line, "I'll take over the %s", Cast_projectName((Project)npc->leads));
        else if (g_talk_player && floorProject(g_talk_player->leads))
            snprintf(take_line, sizeof take_line, "Come and help me with the %s", Cast_projectName((Project)g_talk_player->leads));
        else snprintf(take_line, sizeof take_line, "Help me with something");
        out[1] = lead_line;
        out[2] = take_line;
        if (g_talk_world && Why_open(g_talk_world) >= 0) out[4] = Why_get(Why_open(g_talk_world))->ask; /* (a why question come up: ask it) */
    }
    if (talk->node == NODE_SHOP) {
        static char lines[TALK_CHOICES][64];
        static const char *const WHAT[SHOP_ITEMS] = { "Boards, a bundle of 8", "Nails and screws, a box each", "Paint, a bucket", "Food and drinks for the crew" };
        for (int k = 0; k < SHOP_ITEMS; k++) {
            if (talk->shop[k]) snprintf(lines[k], sizeof lines[k], "%s - %d EUR   [%d]", WHAT[k], SHOP_PRICE[k], talk->shop[k]);
            else snprintf(lines[k], sizeof lines[k], "%s - %d EUR", WHAT[k], SHOP_PRICE[k]);
            out[k] = lines[k];
        }
        if (shopTotal(talk) > 0) snprintf(lines[4], sizeof lines[4], "That's it - off you go (%d EUR)", shopTotal(talk));
        else snprintf(lines[4], sizeof lines[4], "Never mind");
        out[4] = lines[4];
        n = TALK_CHOICES;
    }
    if (talk->node == NODE_WORKER && woodJob(talk->with->ai.order) && n < TALK_CHOICES) { /* about the tool they use */
        static char line[64];
        const Object *npc = talk->with;
        int tool = talkTool(npc);
        snprintf(line, sizeof line, npc->ai.banned & (1u << tool) ? "You can use the %s again" : "Leave the %s alone",
                 tool == TOOL_CHAINSAW ? "chainsaw" : "axe");
        out[n++] = line;
    }
    return n;
}