From 458916fb78dcef3ff77cb29b7ba98d9f646819fc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 19 Jun 2026 02:08:55 -0400 Subject: feat: rough pharmacy skill added --- skill_plans/assassin.md | 1895 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1895 insertions(+) create mode 100644 skill_plans/assassin.md (limited to 'skill_plans/assassin.md') diff --git a/skill_plans/assassin.md b/skill_plans/assassin.md new file mode 100644 index 0000000..31ea301 --- /dev/null +++ b/skill_plans/assassin.md @@ -0,0 +1,1895 @@ +# Assassin Skill Implementation Plan + +## 1. Overview + +Assassin is the Slayer analog for The House of Icarus. A special NPC called **The Client** assigns the player a task to kill a certain number of a specific mob type. Killing mobs "on task" awards Assassin XP in addition to normal combat XP. Some mobs require a minimum Assassin level to attack. Some mobs deal extra damage unless the player wears specific protective equipment. Some mobs cannot be killed below 1 HP without using a finishing blow item on them. + +Players earn **Reputation** (analogous to Slayer Points) on task completion, with streak bonuses at milestones. Reputation is spent in the Client's Reputation Shop to skip/extend tasks, unlock permanent perks, and buy auto-finishing blow unlocks. + +The Assassin skill already exists as a stub in `internal/player/player.go` (constant `Assassin SkillName = "assassin"`, abbreviation `"asm"`). XP is tracked but no behaviors, items, mobs, or commands exist yet. + +--- + +## 2. Task System Architecture + +### Player Flags for Task State + +All task state is stored in player flags (`p.Flags` map, persisted to character YAML). Using player flags means tasks are per-character, saved automatically, and require no schema changes to `Player`. + +| Flag Key | Type | Description | +|---|---|---| +| `assassin_task_mob` | `string` | Mob definition ID of current task (e.g. `"slug"`) | +| `assassin_task_total` | `int` | Total kills assigned for this task | +| `assassin_task_remaining` | `int` | Kills remaining on current task | +| `assassin_tasks_completed` | `int` | Lifetime tasks completed (never resets) | +| `assassin_streak` | `int` | Consecutive tasks completed without skipping | +| `assassin_reputation` | `int` | Current unspent reputation points | +| `assassin_unlocked_` | `bool` | Purchased permanent unlocks (e.g. `assassin_unlocked_auto_salt`) | + +### Task Lifecycle + +1. Player talks to The Client and selects "I need a job." +2. Client calls `assignAssassinTask(p)` which picks a mob from the task table based on `p.Level(player.Assassin)`. +3. Player flags are set: `assassin_task_mob`, `assassin_task_total`, `assassin_task_remaining`. +4. Player kills assigned mobs. Each on-task kill: + - Decrements `assassin_task_remaining` + - Awards Assassin XP = `mob.MaxHP * 2` + - Displays task progress message: `"Assassin task: 12 of 45 slugs remaining."` +5. When `assassin_task_remaining` reaches 0: + - Increments `assassin_tasks_completed` and `assassin_streak` + - Awards base reputation (1) + any streak bonus + - Clears `assassin_task_mob` + - Displays completion message with reputation earned +6. Player returns to Client for a new task. + +### How Flags Are Read/Written + +Player flags use `map[string]any`. Integer flags are stored as `int` but may unmarshal from YAML as `int`, `int64`, or `float64`. A helper function must handle type assertion: + +```go +// in internal/game/assassin.go +func getPlayerFlagInt(p *player.Player, key string) int { + if p.Flags == nil { + return 0 + } + val, ok := p.Flags[key] + if !ok { + return 0 + } + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} + +func getPlayerFlagString(p *player.Player, key string) string { + if p.Flags == nil { + return "" + } + val, ok := p.Flags[key] + if !ok { + return "" + } + s, _ := val.(string) + return s +} + +func setPlayerFlag(p *player.Player, key string, val any) { + if p.Flags == nil { + p.Flags = make(map[string]any) + } + p.Flags[key] = val +} +``` + +--- + +## 3. Commands + +### `task` (Instant Command) + +Displays the player's current Assassin task status. No arguments. + +**Output examples:** + +- Has task: `"Assassin task: Kill slugs. 12 of 45 remaining."` +- No task: `"You don't have an Assassin task. Talk to The Client to get one."` +- Task just completed: `"You have no active task. Talk to The Client for a new assignment."` + +Also shows streak and reputation: +``` +Assassin task: Kill slugs. 12 of 45 remaining. +Streak: 7 | Reputation: 42 +``` + +**Implementation:** Create `internal/game/cmd_task.go`: + +```go +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doTask(sess *net.Session) { + p := sess.Player.(*player.Player) + + mobID := getPlayerFlagString(p, "assassin_task_mob") + remaining := getPlayerFlagInt(p, "assassin_task_remaining") + total := getPlayerFlagInt(p, "assassin_task_total") + streak := getPlayerFlagInt(p, "assassin_streak") + rep := getPlayerFlagInt(p, "assassin_reputation") + + if mobID == "" || remaining <= 0 { + sess.WriteLine("You don't have an Assassin task. Talk to The Client to get one.") + } else { + def, err := g.MobStore.LoadDef(mobID) + name := mobID + if err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("Assassin task: Kill %ss. %d of %d remaining.", name, remaining, total)) + } + sess.WriteLine(fmt.Sprintf("Streak: %d | Reputation: %d", streak, rep)) +} +``` + +### No New Combat Commands + +The existing `attack`/`kill` command is extended in `cmd_attack.go` to check assassin level requirements, apply finishing blow mechanics, and award on-task XP. No new verbs are needed. + +--- + +## 4. New Files to Create + +### Go Files + +| File | Purpose | +|---|---| +| `internal/game/cmd_task.go` | `doTask()` handler — displays current task info | +| `internal/game/assassin.go` | Core assassin logic: task table, `assignAssassinTask()`, `onAssassinKill()`, `assassinTaskTable`, flag helpers | + +### YAML Data Files + +| File | Purpose | +|---|---| +| `data/mobs/slug.yaml` | Slug mob (assassin level 1, finishing blow: salt) | +| `data/mobs/drone.yaml` | Drone mob (assassin level 15, damage_without: insulated_gloves) | +| `data/mobs/crawler.yaml` | Crawler mob (assassin level 30, finishing blow: acid_vial) | +| `data/mobs/phantom.yaml` | Phantom mob (assassin level 45, damage_without: spectral_visor) | +| `data/mobs/client.yaml` | The Client NPC (protected, unique, behavior: client_talk) | +| `data/items/salt.yaml` | Salt (stackable, finishing blow consumable) | +| `data/items/acid_vial.yaml` | Acid vial (stackable, finishing blow consumable) | +| `data/items/insulated_gloves.yaml` | Insulated gloves (hands slot, minimal stats) | +| `data/items/spectral_visor.yaml` | Spectral visor (head slot, minimal stats) | +| `data/behaviors/client_talk.yaml` | Client dialog tree (task assignment, reputation shop) | +| `data/help/task.yaml` | Help topic for `task` command | +| `data/help/assassin.yaml` | Help topic for assassin skill overview | +| `data/rooms/50.yaml` | Assassin Den room (Client's location) — or modify an existing room | + +### Rooms that need mobs added + +| Room | Mob(s) Added | +|---|---| +| New room (e.g. 50) | `client` | +| New room (e.g. 51) | `slug`, `slug` | +| New room (e.g. 52) | `drone`, `drone` | +| New room (e.g. 53) | `crawler` | +| New room (e.g. 54) | `phantom` | + +--- + +## 5. Code Changes to Existing Files + +### `internal/world/mob.go` — MobDef Extensions + +Add three new fields to `MobDef`: + +```go +type MobDef struct { + // ... existing fields ... + AssassinLevel int `yaml:"assassin_level"` + FinishingBlow string `yaml:"finishing_blow"` + DamageWithout string `yaml:"damage_without"` +} +``` + +Also add these fields to `MobInstance` so they're accessible at runtime: + +```go +type MobInstance struct { + // ... existing fields ... + AssassinLevel int + FinishingBlow string + DamageWithout string +} +``` + +In `SeedMobs()`, copy these fields from def to instance: + +```go +inst := &MobInstance{ + // ... existing fields ... + AssassinLevel: dw.def.AssassinLevel, + FinishingBlow: dw.def.FinishingBlow, + DamageWithout: dw.def.DamageWithout, +} +``` + +### `internal/game/cmd_attack.go` — Attack Modifications + +#### a) Assassin Level Check in `doAttack()` + +After the `mob.Protected` check (line 38-41) and before `startCombat()` (line 49), add: + +```go +if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel { + sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true))) + return +} +``` + +#### b) Finishing Blow — HP Floor in `playerAttack()` + +After `mob.HP -= dmg` (line 199), add the finishing blow HP floor: + +```go +if mob.FinishingBlow != "" && mob.HP <= 0 { + mob.HP = 1 +} +``` + +This prevents the mob from dying via normal combat. The mob stays at 1 HP. The player must `use on ` to kill it. + +When the mob is at 1 HP and has a finishing blow requirement, display a hint on every attack: + +```go +if mob.FinishingBlow != "" && mob.HP == 1 { + def, _ := g.ItemStore.Load(mob.FinishingBlow) + itemName := mob.FinishingBlow + if def != nil { + itemName = def.Name + } + sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf(" %s is immune! Use %s on it to finish it off.", mobDisplayName(mob, false), itemName))) +} +``` + +#### c) Damage Increase in `mobAttack()` + +After calculating `dmg` in `mobAttack()` (line 254), before applying to `p.HP`, add: + +```go +if mob.DamageWithout != "" { + hasProtection := false + for _, itemID := range p.Equipment { + if itemID == mob.DamageWithout { + hasProtection = true + break + } + } + if !hasProtection { + dmg = dmg * 3 / 2 // 1.5x damage + } +} +``` + +#### d) On-Task Kill XP in `endCombat()` + +After the victory message (line 312) and before loot drops (line 324), add the assassin task processing: + +```go +if mob != nil && mob.HP <= 0 { + sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) + + // --- NEW: Assassin task processing --- + g.onAssassinKill(sess, p, mob) + // --- END NEW --- + + // ... existing broadcast, loot drops, respawn ... +} +``` + +### `internal/game/game.go` — Register `task` Command + +#### a) In `classifyCommand()` (line 136) + +Add `"task"` to the Instant list: + +```go +case "say", "score", "sc", "inventory", "i", "inv", + "look", "l", "exits", "help", + "map", "option", "options", "alias", "unalias", + "description", "desc", "queued", "color", "colors", + "colortable", "prompt", "style", "task": + return ClassInstant +``` + +#### b) In `executeCommand()` (line 249) + +Add a case after `"sc", "score"`: + +```go +case "task": + g.doTask(sess) +``` + +### `internal/game/game.go` — Finishing Blow via `use` on Mob + +The `doUse()` function in `cmd_use.go` currently handles `use on ` for objects. It needs a new path: if the target is a mob that the player is in combat with and the mob has `finishing_blow` set and is at 1 HP, consume the item and kill the mob. + +In the `doUse()` function, after failing to find an object target, attempt to find a mob target: + +```go +// After object lookup fails or before object lookup +// Check if target is a mob in combat with the player +cs := combat.GetCombat(p.Name) +if cs != nil { + mob := g.MobStore.GetInstance(cs.MobID) + if mob != nil && mob.HP == 1 && mob.FinishingBlow != "" { + if mob.MatchQuality(targetName) != world.MatchNone { + if strings.ToLower(itemName) == mob.FinishingBlow || itemMatchesDef(itemName, mob.FinishingBlow, g.ItemStore) { + if !p.HasItem(mob.FinishingBlow) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", mob.FinishingBlow)) + return + } + // Check for auto-finish unlock + autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow + if getPlayerFlagBool(p, autoKey) { + // Don't consume + } else { + p.RemoveItem(mob.FinishingBlow, 1) + } + def, _ := g.ItemStore.Load(mob.FinishingBlow) + itemDisplayName := mob.FinishingBlow + if def != nil { + itemDisplayName = def.Name + } + sess.WriteLine(fmt.Sprintf("You use the %s on %s!", itemDisplayName, mobDisplayName(mob, true))) + mob.HP = 0 + g.endCombat(sess, p, mob) + return + } + } + } +} +``` + +A helper is needed to match an item input string to a finishing blow item ID: + +```go +func itemMatchesDef(input, itemID string, store *object.ItemStore) bool { + def, err := store.Load(itemID) + if err != nil { + return false + } + return def.MatchesName(input) +} +``` + +### `internal/game/action_state.go` — No Changes Needed + +The existing `ActionCombating` action type covers all combat states including assassin-related combat. No new action types are needed. + +--- + +## 6. The Client NPC + +### Mob Definition: `data/mobs/client.yaml` + +```yaml +id: client +name: The Client +description: "A shadowy figure in a dark coat. They speak in clipped, measured tones and seem to know everything about every creature in the sector." +unique: true +protected: true +behavior: client_talk +attack: 1 +strength: 1 +defense: 1 +hp: 100 +speed: 5 +aggressive: false +respawn_ticks: 30 +idle_descriptions: + - "reviews a holographic dossier" + - "marks a location on a worn star chart" + - "flips a credit chit between their fingers" + - "mutters coordinates under their breath" + - "glances at you appraisingly" + - "taps a data pad with a stylus" +``` + +### Talk Behavior: `data/behaviors/client_talk.yaml` + +The Client's dialog tree is complex. Since the talk system currently doesn't support dynamic logic (task assignment requires Go code), the approach is: + +**Option A (Recommended): Hybrid approach — use `set_player_flags` with a sentinel value, then intercept in `applyNodeAction`.** + +Add a new `NodeAction` field called `assign_task` (boolean) that triggers `assignAssassinTask()` from Go code. + +**Required change to `internal/action/behavior.go`:** + +```go +type NodeAction struct { + // ... existing fields ... + AssignTask bool `yaml:"assign_task"` + SkipTask bool `yaml:"skip_task"` + ExtendTask bool `yaml:"extend_task"` +} +``` + +**Required change to `internal/game/action_talk.go` in `applyNodeAction()`:** + +After existing action processing (line 218), add: + +```go +if na.AssignTask { + g.assignAssassinTask(sess, p) +} +if na.SkipTask { + g.skipAssassinTask(sess, p) +} +if na.ExtendTask { + g.extendAssassinTask(sess, p) +} +``` + +**Full behavior YAML:** + +```yaml +id: client_talk +type: talk +nodes: + start: + message: "The Client looks up from their data pad. \"What do you need?\"" + options: + - text: "\"I need a job.\"" + goto: assign_task + condition: + player_flag: assassin_task_mob + not: true + - text: "\"I've finished my task.\"" + goto: task_complete + condition: + all_of: + - player_flag: assassin_task_mob + not: true + value: "" + - text: "\"What's my current task?\"" + goto: current_task + condition: + player_flag: assassin_task_mob + not: true + - text: "\"I want to skip my task.\"" + goto: skip_confirm + condition: + player_flag: assassin_task_mob + not: true + - text: "\"I want to extend my task.\"" + goto: extend_confirm + condition: + player_flag: assassin_task_mob + not: true + - text: "\"I'd like to browse the Reputation Shop.\"" + goto: rep_shop + - text: "\"I need supplies.\"" + goto: supplies + - text: "\"Goodbye.\"" + end: true + + assign_task: + message: "\"Let me check what's available...\" The Client scrolls through their data pad." + action: + assign_task: true + options: + - text: "\"Understood.\"" + end: true + - text: "\"What else do you have?\"" + goto: start + + current_task: + message: "The Client checks their records." + options: + - text: "\"Okay.\"" + end: true + + skip_confirm: + message: "\"Skipping a task costs 30 Reputation and resets your streak. Are you sure?\"" + options: + - text: "\"Yes, skip it.\"" + goto: skip_done + - text: "\"Never mind.\"" + goto: start + + skip_done: + message: "\"Task cancelled. Your streak has been reset.\"" + action: + skip_task: true + options: + - text: "\"Give me a new one.\"" + goto: assign_task + - text: "\"Goodbye.\"" + end: true + + extend_confirm: + message: "\"Extending your task costs 30 Reputation and adds more kills. Want to proceed?\"" + options: + - text: "\"Yes, extend it.\"" + goto: extend_done + - text: "\"Never mind.\"" + goto: start + + extend_done: + message: "\"Done. I've added more targets to your contract.\"" + action: + extend_task: true + options: + - text: "\"Thanks.\"" + end: true + + rep_shop: + message: "\"Here's what I've got. All purchases are permanent.\"" + options: + - text: "\"Auto-finish: Salt (200 Rep) - Never consume salt on finishing blows.\"" + goto: buy_auto_salt + - text: "\"Auto-finish: Acid Vial (200 Rep) - Never consume acid vials on finishing blows.\"" + goto: buy_auto_acid + - text: "\"Unlock Superior Mobs (300 Rep) - Rare superior variants may spawn.\"" + goto: buy_superiors + - text: "\"Unlock Extended Tasks (100 Rep) - Allows extending tasks.\"" + goto: buy_extend_unlock + - text: "\"Back.\"" + goto: start + + buy_auto_salt: + message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\"" + action: + set_player_flags: + assassin_unlocked_auto_salt: true + options: + - text: "\"Thanks.\"" + goto: rep_shop + + buy_auto_acid: + message: "\"Auto-acid purchased. Acid vials will no longer be consumed.\"" + action: + set_player_flags: + assassin_unlocked_auto_acid_vial: true + options: + - text: "\"Thanks.\"" + goto: rep_shop + + buy_superiors: + message: "\"Superior encounters unlocked. Watch yourself out there.\"" + action: + set_player_flags: + assassin_unlocked_superiors: true + options: + - text: "\"Thanks.\"" + goto: rep_shop + + buy_extend_unlock: + message: "\"You can now extend tasks via our conversation.\"" + action: + set_player_flags: + assassin_unlocked_extend: true + options: + - text: "\"Thanks.\"" + goto: rep_shop + + supplies: + message: "\"I stock everything you need for the job. Cheap, too.\"" + options: + - text: "\"Buy salt (5 credits each).\"" + goto: buy_salt + condition: + min_credits: 5 + - text: "\"Buy acid vial (10 credits each).\"" + goto: buy_acid + condition: + min_credits: 10 + - text: "\"Buy insulated gloves (50 credits).\"" + goto: buy_gloves + condition: + min_credits: 50 + - text: "\"Buy spectral visor (75 credits).\"" + goto: buy_visor + condition: + min_credits: 75 + - text: "\"Back.\"" + goto: start + + buy_salt: + message: "\"Here you go.\" The Client slides a packet of salt across the table." + action: + give_item: salt + cost: 5 + options: + - text: "\"Buy more.\"" + goto: buy_salt + condition: + min_credits: 5 + - text: "\"Thanks.\"" + goto: supplies + + buy_acid: + message: "\"Handle with care.\" The Client passes you a vial of corrosive acid." + action: + give_item: acid_vial + cost: 10 + options: + - text: "\"Buy more.\"" + goto: buy_acid + condition: + min_credits: 10 + - text: "\"Thanks.\"" + goto: supplies + + buy_gloves: + message: "\"These'll keep the current from frying your hands.\" The Client tosses you a pair of thick rubber gloves." + action: + give_item: insulated_gloves + cost: 50 + options: + - text: "\"Thanks.\"" + goto: supplies + + buy_visor: + message: "\"Spectral frequency filter. Makes the invisible visible — and keeps their attacks from scrambling your brain.\"" + action: + give_item: spectral_visor + cost: 75 + options: + - text: "\"Thanks.\"" + goto: supplies +``` + +**Note on dynamic content in dialog:** The talk system currently renders static `message` strings. For the "current task" and "assign task" nodes, the message content depends on player state. Two approaches: + +**Approach 1 (Simple):** The `assign_task` action in `applyNodeAction` writes a message to the session directly. The node's `message` is generic ("Let me check what's available...") and the actual task details are output by the Go function. + +**Approach 2 (Placeholder):** Support a `{assassin_task}` placeholder in talk node messages that `showTalkNode` expands before display. This is more complex but cleaner. + +**Recommended: Approach 1.** The `assignAssassinTask()` function outputs the task details via `sess.WriteLine()` after the node message is shown. + +--- + +## 7. Task Assignment Logic + +### File: `internal/game/assassin.go` + +```go +package game + +import ( + "fmt" + "math/rand" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +type assassinTaskEntry struct { + MobID string + MinLevel int + MaxLevel int + MinCount int + MaxCount int + Weight int +} + +var assassinTaskTable = []assassinTaskEntry{ + // Level 1-10: basic mobs + {"man", 1, 99, 10, 30, 10}, + {"cow", 1, 99, 10, 30, 10}, + + // Assassin-specific mobs + {"slug", 1, 99, 15, 45, 15}, + {"drone", 15, 99, 20, 50, 12}, + {"crawler", 30, 99, 15, 40, 10}, + {"phantom", 45, 99, 10, 30, 8}, +} + +func (g *Game) assignAssassinTask(sess *net.Session, p *player.Player) { + level := p.Level(player.Assassin) + + var eligible []assassinTaskEntry + totalWeight := 0 + for _, entry := range assassinTaskTable { + if level >= entry.MinLevel && level <= entry.MaxLevel { + eligible = append(eligible, entry) + totalWeight += entry.Weight + } + } + + if len(eligible) == 0 { + sess.WriteLine("The Client shakes their head. \"Nothing available for your level.\"") + return + } + + // Weighted random selection + roll := rand.Intn(totalWeight) + var chosen assassinTaskEntry + for _, entry := range eligible { + roll -= entry.Weight + if roll < 0 { + chosen = entry + break + } + } + + // Random count within range + count := chosen.MinCount + rand.Intn(chosen.MaxCount-chosen.MinCount+1) + + setPlayerFlag(p, "assassin_task_mob", chosen.MobID) + setPlayerFlag(p, "assassin_task_total", count) + setPlayerFlag(p, "assassin_task_remaining", count) + g.AccountStore.SaveCharacter(p) + + def, err := g.MobStore.LoadDef(chosen.MobID) + name := chosen.MobID + if err == nil { + name = def.Name + } + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\"Your target: %d %ss. Get to work.\"", count, name))) +} + +func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.MobInstance) { + taskMob := getPlayerFlagString(p, "assassin_task_mob") + if taskMob == "" || taskMob != mob.DefID { + return + } + + remaining := getPlayerFlagInt(p, "assassin_task_remaining") + if remaining <= 0 { + return + } + + // Award Assassin XP + xp := mob.MaxHP * 2 + newLevel := p.AddSkillXP(player.Assassin, xp) + if newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d Assassin! ***", newLevel))) + } + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp))) + } + + remaining-- + setPlayerFlag(p, "assassin_task_remaining", remaining) + + if remaining <= 0 { + // Task complete + completed := getPlayerFlagInt(p, "assassin_tasks_completed") + 1 + streak := getPlayerFlagInt(p, "assassin_streak") + 1 + + setPlayerFlag(p, "assassin_tasks_completed", completed) + setPlayerFlag(p, "assassin_streak", streak) + setPlayerFlag(p, "assassin_task_mob", "") + setPlayerFlag(p, "assassin_task_remaining", 0) + setPlayerFlag(p, "assassin_task_total", 0) + + // Calculate reputation + rep := 1 // base + bonus := streakBonus(streak) + rep += bonus + currentRep := getPlayerFlagInt(p, "assassin_reputation") + setPlayerFlag(p, "assassin_reputation", currentRep+rep) + + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***"))) + if bonus > 0 { + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus))) + } + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Reputation earned: %d (total: %d)", rep, currentRep+rep))) + } else { + total := getPlayerFlagInt(p, "assassin_task_total") + def, _ := g.MobStore.LoadDef(taskMob) + name := taskMob + if def != nil { + name = def.Name + } + sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Assassin task: %d of %d %ss remaining.", remaining, total, name))) + } + + g.AccountStore.SaveCharacter(p) +} + +func (g *Game) skipAssassinTask(sess *net.Session, p *player.Player) { + rep := getPlayerFlagInt(p, "assassin_reputation") + if rep < 30 { + sess.WriteLine("You don't have enough Reputation to skip. (Need 30, have " + fmt.Sprint(rep) + ")") + return + } + setPlayerFlag(p, "assassin_reputation", rep-30) + setPlayerFlag(p, "assassin_task_mob", "") + setPlayerFlag(p, "assassin_task_remaining", 0) + setPlayerFlag(p, "assassin_task_total", 0) + setPlayerFlag(p, "assassin_streak", 0) // Reset streak + g.AccountStore.SaveCharacter(p) +} + +func (g *Game) extendAssassinTask(sess *net.Session, p *player.Player) { + rep := getPlayerFlagInt(p, "assassin_reputation") + if rep < 30 { + sess.WriteLine("You don't have enough Reputation to extend. (Need 30, have " + fmt.Sprint(rep) + ")") + return + } + taskMob := getPlayerFlagString(p, "assassin_task_mob") + if taskMob == "" { + sess.WriteLine("You don't have an active task to extend.") + return + } + setPlayerFlag(p, "assassin_reputation", rep-30) + // Add 50% more kills + total := getPlayerFlagInt(p, "assassin_task_total") + remaining := getPlayerFlagInt(p, "assassin_task_remaining") + extension := total / 2 + if extension < 5 { + extension = 5 + } + setPlayerFlag(p, "assassin_task_total", total+extension) + setPlayerFlag(p, "assassin_task_remaining", remaining+extension) + g.AccountStore.SaveCharacter(p) + + def, _ := g.MobStore.LoadDef(taskMob) + name := taskMob + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("Task extended by %d. Kill %d more %ss (%d total).", extension, remaining+extension, name, total+extension)) +} + +func streakBonus(streak int) int { + bonus := 0 + if streak%1000 == 0 { + bonus += 50 + } else if streak%250 == 0 { + bonus += 35 + } else if streak%100 == 0 { + bonus += 25 + } else if streak%50 == 0 { + bonus += 15 + } else if streak%10 == 0 { + bonus += 5 + } + return bonus +} +``` + +--- + +## 8. Reputation System + +### Earning Reputation + +Reputation is earned on task completion: + +| Event | Reputation | +|---|---| +| Task completed (base) | +1 | +| Every 10th consecutive task | +5 bonus | +| Every 50th consecutive task | +15 bonus | +| Every 100th consecutive task | +25 bonus | +| Every 250th consecutive task | +35 bonus | +| Every 1000th consecutive task | +50 bonus | + +Milestone bonuses are checked via modulo and are **mutually exclusive** — only the highest applicable milestone bonus is awarded per completion. The `streakBonus()` function checks from highest to lowest: + +```go +func streakBonus(streak int) int { + if streak%1000 == 0 { + return 50 + } + if streak%250 == 0 { + return 35 + } + if streak%100 == 0 { + return 25 + } + if streak%50 == 0 { + return 15 + } + if streak%10 == 0 { + return 5 + } + return 0 +} +``` + +### Spending Reputation + +Reputation is spent via The Client's talk dialog. The `cost` field on `NodeAction` already handles credit deduction. For reputation, since it's stored as a player flag rather than `p.Credits`, we need one of: + +**Option A:** Add a `reputation_cost` field to `NodeAction`. In `applyNodeAction`, deduct from the player flag. + +**Option B:** Handle reputation costs inside `skipAssassinTask()` and `extendAssassinTask()` (the Go functions called by `skip_task`/`extend_task` actions). + +**Recommended: Option B** for skip/extend (since the Go function already exists), and **Option A** for shop purchases. + +Add to `NodeAction`: + +```go +type NodeAction struct { + // ... existing fields ... + AssignTask bool `yaml:"assign_task"` + SkipTask bool `yaml:"skip_task"` + ExtendTask bool `yaml:"extend_task"` + ReputationCost int `yaml:"reputation_cost"` +} +``` + +In `applyNodeAction()`: + +```go +if na.ReputationCost > 0 { + rep := getPlayerFlagInt(p, "assassin_reputation") + if rep < na.ReputationCost { + sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) + return // Don't apply remaining actions + } + setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) + g.AccountStore.SaveCharacter(p) +} +``` + +The reputation shop nodes in the behavior YAML then use: + +```yaml + buy_auto_salt: + message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\"" + action: + reputation_cost: 200 + set_player_flags: + assassin_unlocked_auto_salt: true + options: + - text: "\"Thanks.\"" + goto: rep_shop +``` + +Condition to show shop items only if not already purchased: + +```yaml + - text: "\"Auto-finish: Salt (200 Rep)\"" + goto: buy_auto_salt + condition: + all_of: + - player_flag: assassin_unlocked_auto_salt + not: true +``` + +--- + +## 9. Reputation Shop Items + +| Item | Cost | Flag Set | Effect | +|---|---|---|---| +| Skip task | 30 Rep | (handled by `skip_task` action) | Cancels current task, resets streak | +| Extend task | 30 Rep | (handled by `extend_task` action) | Adds 50% more kills to current task | +| Auto-finish: Salt | 200 Rep | `assassin_unlocked_auto_salt` | Salt is not consumed on finishing blows | +| Auto-finish: Acid Vial | 200 Rep | `assassin_unlocked_auto_acid_vial` | Acid vials are not consumed on finishing blows | +| Unlock Superior Mobs | 300 Rep | `assassin_unlocked_superiors` | Rare chance for stronger mob variant to spawn (future feature) | +| Unlock Extended Tasks | 100 Rep | `assassin_unlocked_extend` | Allows the "extend task" dialog option | +| Broader Task List | 150 Rep | `assassin_unlocked_broader` | Unlocks additional mobs in the task table (future feature) | + +### Updated Dialog YAML with Conditions + +The "extend" option should only appear if the player has purchased the unlock: + +```yaml + - text: "\"I want to extend my task.\"" + goto: extend_confirm + condition: + all_of: + - player_flag: assassin_task_mob + not: true + - player_flag: assassin_unlocked_extend + value: true +``` + +--- + +## 10. Slayer-Only Mobs + +### `data/mobs/slug.yaml` + +```yaml +id: slug +name: slug +description: "A bloated, translucent slug the size of a dog. Its skin glistens with toxic mucus. It cannot be killed by conventional means — only salt can destroy it." +assassin_level: 1 +finishing_blow: salt +attack: 3 +strength: 3 +defense: 1 +hp: 15 +speed: 6 +aggressive: false +respawn_ticks: 25 +idle_descriptions: + - "oozes along the floor leaving a slimy trail" + - "contracts and expands rhythmically" + - "extends its eyestalks toward you" + - "secretes a glob of toxic mucus" +combat_descriptions: + - "lunges slimily at %s" + - "sprays mucus toward %s" + - "writhes in combat with %s" +drops: + remains: slug_mucus + loot: + - item_id: credits + weight: 100 + quantity: 15 +``` + +### `data/mobs/drone.yaml` + +```yaml +id: drone +name: drone +description: "A malfunctioning security drone crackling with electrical discharge. Its attacks are especially dangerous to anyone not wearing insulated gloves." +assassin_level: 15 +damage_without: insulated_gloves +attack: 15 +strength: 14 +defense: 12 +hp: 45 +speed: 4 +aggressive: true +respawn_ticks: 35 +idle_descriptions: + - "hovers erratically, sparking" + - "emits a high-pitched whine" + - "scans the area with a flickering red beam" + - "rotates its weapon array with a mechanical click" +combat_descriptions: + - "fires an electrical bolt at %s" + - "charges its capacitors and zaps %s" + - "swoops down on %s with crackling energy" +drops: + remains: circuit_board + loot: + - item_id: credits + weight: 80 + quantity: 75 + - item_id: insulated_gloves + weight: 5 + - item_id: credits + weight: 15 + quantity: 200 +``` + +### `data/mobs/crawler.yaml` + +```yaml +id: crawler +name: crawler +description: "A heavily armored bio-mechanical creature with a chitinous exoskeleton. Its regenerative biology prevents death unless dissolved with acid." +assassin_level: 30 +finishing_blow: acid_vial +attack: 25 +strength: 22 +defense: 30 +hp: 80 +speed: 5 +aggressive: false +respawn_ticks: 45 +idle_descriptions: + - "scrapes its mandibles together menacingly" + - "clicks and chitters in an alien rhythm" + - "tests the air with feathered antennae" + - "coils its segmented body defensively" +combat_descriptions: + - "snaps its mandibles at %s" + - "lashes out with a barbed tail at %s" + - "charges headlong into %s" +drops: + remains: chitin_plate + loot: + - item_id: credits + weight: 70 + quantity: 250 + - item_id: acid_vial + weight: 10 + - item_id: credits + weight: 20 + quantity: 500 +``` + +### `data/mobs/phantom.yaml` + +```yaml +id: phantom +name: phantom +description: "A semi-transparent entity that phases in and out of visible light. Its psychic attacks are devastating to anyone without a spectral visor." +assassin_level: 45 +damage_without: spectral_visor +attack: 35 +strength: 30 +defense: 25 +hp: 100 +speed: 3 +aggressive: true +respawn_ticks: 50 +idle_descriptions: + - "flickers between visible and invisible" + - "emits a low, resonant hum" + - "drifts through a wall and back again" + - "stares at you with hollow, glowing eyes" +combat_descriptions: + - "blasts %s with a psychic wave" + - "phases through %s's defenses" + - "unleashes a spectral shriek at %s" +drops: + remains: ectoplasm + loot: + - item_id: credits + weight: 60 + quantity: 500 + - item_id: spectral_visor + weight: 3 + - item_id: credits + weight: 37 + quantity: 1000 +``` + +### Additional Item Drops (remain items) + +These are guaranteed drops ("remains") from the new mobs. Create simple item YAMLs: + +#### `data/items/slug_mucus.yaml` +```yaml +id: slug_mucus +name: slug mucus +color: "82" +description: "A glob of toxic slug mucus. Unpleasant." +value: 5 +stackable: false +``` + +#### `data/items/circuit_board.yaml` +```yaml +id: circuit_board +name: circuit board +color: "40" +description: "A scorched circuit board salvaged from a destroyed drone." +value: 25 +stackable: false +``` + +#### `data/items/chitin_plate.yaml` +```yaml +id: chitin_plate +name: chitin plate +color: "130" +description: "A thick plate of biological armor from a crawler's exoskeleton." +value: 50 +stackable: false +``` + +#### `data/items/ectoplasm.yaml` +```yaml +id: ectoplasm +name: ectoplasm +color: "159" +description: "A shimmering residue left behind by a destroyed phantom." +value: 75 +stackable: false +``` + +--- + +## 11. Assassin Equipment + +### `data/items/salt.yaml` + +```yaml +id: salt +name: salt +color: "255" +description: "A packet of coarse industrial salt. Used to destroy slugs." +value: 5 +stackable: true +``` + +### `data/items/acid_vial.yaml` + +```yaml +id: acid_vial +name: acid vial +color: "46" +description: "A small vial of concentrated acid. Used to dissolve crawlers." +value: 10 +stackable: true +``` + +### `data/items/insulated_gloves.yaml` + +```yaml +id: insulated_gloves +name: insulated gloves +color: "214" +description: "Heavy rubber gloves that protect against electrical attacks. Essential when fighting drones." +value: 50 +stackable: false +equip_slot: hands +stats: + defense_bonus: 1 +``` + +### `data/items/spectral_visor.yaml` + +```yaml +id: spectral_visor +name: spectral visor +color: "141" +description: "A visor fitted with spectral frequency filters. Dampens psychic attacks from phantoms." +value: 75 +stackable: false +equip_slot: head +stats: + defense_bonus: 2 +``` + +--- + +## 12. Finishing Blow Mechanic + +### Core Logic + +The finishing blow mechanic has two components: + +#### a) HP Floor (in `playerAttack`) + +In `internal/game/cmd_attack.go`, in the `playerAttack()` method, after `mob.HP -= dmg` (line 199): + +```go +mob.HP -= dmg +if mob.HP < 0 { + mob.HP = 0 +} + +// Finishing blow: mob cannot die from normal combat +if mob.FinishingBlow != "" && mob.HP <= 0 { + mob.HP = 1 +} +``` + +When mob is at 1 HP and has a finishing blow, display a message: + +```go +if mob.FinishingBlow != "" && mob.HP == 1 { + fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) + fbName := mob.FinishingBlow + if fbDef != nil { + fbName = fbDef.Name + } + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", + mobDisplayName(mob, false), fbName))) +} +``` + +#### b) `use on ` Kill (in `doUse` or new handler) + +The `doUse()` function in `internal/game/cmd_use.go` parses `use on `. Currently it only finds objects. The modification adds mob lookup: + +After the existing `use` parsing extracts `itemName` and `targetName`, before the object lookup: + +```go +// Check for finishing blow on mob in combat +cs := combat.GetCombat(p.Name) +if cs != nil { + mob := g.MobStore.GetInstance(cs.MobID) + if mob != nil && mob.FinishingBlow != "" && mob.HP == 1 { + // Check if target matches this mob + if mob.MatchQuality(targetName) != world.MatchNone { + g.doFinishingBlow(sess, p, mob, itemName) + return + } + } +} +``` + +The `doFinishingBlow` function: + +```go +func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { + // Verify the item matches the required finishing blow item + fbDef, err := g.ItemStore.Load(mob.FinishingBlow) + if err != nil { + sess.WriteLine("Something went wrong.") + return + } + + if !fbDef.MatchesName(itemInput) { + sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) + return + } + + if !p.HasItem(mob.FinishingBlow) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) + return + } + + // Check for auto-finish unlock + autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow + if p.Flags != nil { + if val, ok := p.Flags[autoKey]; ok { + if b, ok := val.(bool); ok && b { + // Don't consume + } else { + p.RemoveItem(mob.FinishingBlow, 1) + } + } else { + p.RemoveItem(mob.FinishingBlow, 1) + } + } else { + p.RemoveItem(mob.FinishingBlow, 1) + } + + sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) + mob.HP = 0 + g.endCombat(sess, p, mob) +} +``` + +### Combat Continues While Mob is at 1 HP + +The mob still attacks the player while at 1 HP. The player's attacks continue hitting (and show damage) but the mob HP stays at 1. The player must type `use salt on slug` (or similar) during combat to kill it. This is handled because: + +1. The combat tick subscriber keeps running (mob HP > 0 since it's clamped to 1) +2. Player attacks deal damage but HP is floored at 1 +3. `use` command is classified as `ClassActive` — it replaces the current active action. However, this creates a conflict because the player is in combat. + +**Problem:** `StartAction()` checks `combat.GetCombat(p.Name) != nil` and returns "You can't do that during combat!" (line 50-53 of action.go). + +**Solution:** The finishing blow `use` should NOT route through `StartAction()`. Instead, handle it directly in `executeCommand()` under the `"use"` case, BEFORE calling `doUse()`: + +In `executeCommand()`, the `"use"` case (line 367-369): + +```go +case "use": + // Check for finishing blow first + if g.tryFinishingBlow(sess, strings.Join(args, " ")) { + return + } + g.doUse(sess, strings.Join(args, " ")) + return +``` + +The `tryFinishingBlow` function: + +```go +func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { + p := sess.Player.(*player.Player) + cs := combat.GetCombat(p.Name) + if cs == nil { + return false + } + mob := g.MobStore.GetInstance(cs.MobID) + if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { + return false + } + + // Parse "item on target" or "item on/with target" + lower := strings.ToLower(input) + var itemPart, targetPart string + for _, sep := range []string{" on ", " with "} { + if idx := strings.Index(lower, sep); idx > 0 { + itemPart = strings.TrimSpace(input[:idx]) + targetPart = strings.TrimSpace(input[idx+len(sep):]) + break + } + } + if targetPart == "" { + return false + } + + if mob.MatchQuality(targetPart) == world.MatchNone { + return false + } + + g.doFinishingBlow(sess, p, mob, itemPart) + return true +} +``` + +--- + +## 13. Damage Reduction Equipment + +### Mechanic + +Mobs with a `damage_without` field deal 1.5x damage when the player does NOT have the specified item equipped. This simulates the "protect yourself" mechanic from Slayer. + +### Implementation in `mobAttack()` + +In `internal/game/cmd_attack.go`, in the `mobAttack()` function, after computing `dmg` from `combat.RollDamage(maxHit)` (around line 254): + +```go +if combat.HitCheck(attRoll, defRoll) { + maxHit := combat.MaxHit(mob.Strength, 0, 0) + dmg := combat.RollDamage(maxHit) + + // Damage amplification if player lacks protective equipment + if mob.DamageWithout != "" { + hasProtection := false + for _, itemID := range p.Equipment { + if itemID == mob.DamageWithout { + hasProtection = true + break + } + } + if !hasProtection { + dmg = dmg * 3 / 2 + if dmg < 1 { + dmg = 1 + } + } + } + + p.HP -= dmg + // ... rest of existing code +} +``` + +### Player Feedback + +Optionally, the first time in a combat session the player takes amplified damage, display a warning: + +```go +if !hasProtection { + dmg = dmg * 3 / 2 + fbDef, _ := g.ItemStore.Load(mob.DamageWithout) + fbName := mob.DamageWithout + if fbDef != nil { + fbName = fbDef.Name + } + // Only warn once per combat (use a flag on combat state or session) + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", + attacker, fbName))) +} +``` + +To avoid spamming this every hit, track whether the warning has been shown. The simplest approach: add a `DamageWarningShown` bool field to `combat.State`: + +```go +type State struct { + PlayerName string + MobID string + Active bool + DamageWarningShown bool +} +``` + +Then in `mobAttack()`: + +```go +if !hasProtection { + dmg = dmg * 3 / 2 + if !cs.DamageWarningShown { + cs.DamageWarningShown = true + // ... display warning + } +} +``` + +--- + +## 14. XP Calculation + +### Assassin XP Per Kill (On-Task Only) + +Formula: `assassinXP = mob.MaxHP * 2` + +This is awarded ONLY when the killed mob matches the player's current task (`assassin_task_mob` player flag). + +XP is awarded in `onAssassinKill()` which is called from `endCombat()` after the mob death is confirmed. + +### Examples + +| Mob | MaxHP | Assassin XP Per Kill | +|---|---|---| +| slug | 15 | 30 | +| drone | 45 | 90 | +| crawler | 80 | 160 | +| phantom | 100 | 200 | +| man | 7 | 14 | +| cow | 8 | 16 | + +### XP Table Reference + +The game uses the RSC XP table (defined in `internal/player/xp.go` or similar). Level 1 = 0 XP, Level 2 = 83 XP, etc. The same table applies to Assassin. + +### Normal Combat XP Still Awarded + +The existing `awardCombatXP()` function is not modified. Players receive both normal combat XP (Attack/Strength/Defense/Hitpoints based on style) AND Assassin XP when on-task. + +--- + +## 15. Mob YAML Extensions + +### New Fields on `MobDef` (in `internal/world/mob.go`) + +```go +type MobDef struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + BehaviorID string `yaml:"behavior"` + IdleDescriptions []string `yaml:"idle_descriptions"` + CombatDescriptions []string `yaml:"combat_descriptions"` + Attack int `yaml:"attack"` + Strength int `yaml:"strength"` + Defense int `yaml:"defense"` + HP int `yaml:"hp"` + Speed float64 `yaml:"speed"` + Aggressive bool `yaml:"aggressive"` + Protected bool `yaml:"protected"` + Unique bool `yaml:"unique"` + RespawnTicks float64 `yaml:"respawn_ticks"` + Drops DropTable `yaml:"drops"` + // NEW FIELDS: + AssassinLevel int `yaml:"assassin_level"` + FinishingBlow string `yaml:"finishing_blow"` + DamageWithout string `yaml:"damage_without"` +} +``` + +### New Fields on `MobInstance` + +```go +type MobInstance struct { + // ... existing fields ... + AssassinLevel int + FinishingBlow string + DamageWithout string +} +``` + +### Copy in `SeedMobs()` + +In the `SeedMobs` function (line 236-259 of `internal/world/mob.go`), add to the instance creation: + +```go +inst := &MobInstance{ + // ... existing fields ... + AssassinLevel: dw.def.AssassinLevel, + FinishingBlow: dw.def.FinishingBlow, + DamageWithout: dw.def.DamageWithout, +} +``` + +### Mobs Without These Fields + +Mobs that don't have these fields in their YAML will have zero-value defaults: +- `AssassinLevel: 0` — no requirement to attack +- `FinishingBlow: ""` — no finishing blow needed +- `DamageWithout: ""` — no extra damage + +This is fully backward compatible. + +--- + +## 16. Rooms + +### The Client's Location + +Create a new room for The Client. Since room 19 is currently a stub ("Agility Course"), use a different room number. Suggested: Room 50+ area for the "Assassin District." + +#### `data/rooms/50.yaml` — Assassin Den + +```yaml +id: 50 +name: "The Assassin's Den" +description: "A dimly lit basement accessible through a trapdoor. Tactical maps and bounty posters line the walls. The air smells of gun oil and burnt ozone. {141}The Client{/} sits behind a reinforced desk." +exits: + up: 1 +mobs: + - "client" +``` + +Connect from Town Square (room 1) by adding `down: 50` exit (or use a different connection point). Room 1 already has `down: 31`, so either: +- Add the den as a separate entrance from another room +- Create a chain: Room 1 → Room 31 → Room 50 + +Alternative: connect from an existing room that has a free exit direction. + +### Slayer-Only Mob Rooms + +#### `data/rooms/51.yaml` — Sewer Tunnels + +```yaml +id: 51 +name: "Sewer Tunnels" +description: "Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable." +exits: + north: 50 + south: 52 +mobs: + - "slug" + - "slug" + - "slug" +``` + +#### `data/rooms/52.yaml` — Abandoned Sector + +```yaml +id: 52 +name: "Abandoned Sector" +description: "A decommissioned sector of the asteroid's infrastructure. Broken monitors flicker and exposed wiring {214}sparks{/} dangerously." +exits: + north: 51 + south: 53 +mobs: + - "drone" + - "drone" +``` + +#### `data/rooms/53.yaml` — Deep Tunnels + +```yaml +id: 53 +name: "Deep Tunnels" +description: "The tunnels descend deeper into the asteroid's core. Strange chitinous scraping echoes from the darkness. The walls are scarred with {130}claw marks{/}." +exits: + north: 52 + south: 54 +mobs: + - "crawler" + - "crawler" +``` + +#### `data/rooms/54.yaml` — The Void Chamber + +```yaml +id: 54 +name: "The Void Chamber" +description: "A vast cavern where reality seems to thin. The air shimmers with {141}spectral energy{/} and strange whispers fill your mind." +exits: + north: 53 +mobs: + - "phantom" + - "phantom" +``` + +### Connecting to Existing World + +Add an exit from the Assassin Den (room 50) back to an appropriate existing room. The den should connect to somewhere in the 1-30 range. Example: add `down: 50` to room 22 (west of town square) or create a new connection. + +Update the connecting room's YAML to add the exit: + +```yaml +# In the connecting room, add: +exits: + down: 50 # To Assassin Den +``` + +--- + +## 17. Help Files + +### `data/help/task.yaml` + +```yaml +name: "task" +category: "Assassin" +description: | + Check your current Assassin task status. + + Usage: task + + Displays your current task target, kills remaining, streak count, + and unspent Reputation points. If you have no active task, visit + The Client to receive a new assignment. + + The Client can be found in The Assassin's Den, accessible from + the lower levels of the settlement. +``` + +### `data/help/assassin.yaml` + +```yaml +name: "assassin" +category: "Skills" +description: | + The Assassin skill (Slayer equivalent). + + Talk to The Client in The Assassin's Den to receive tasks. Each task + assigns you a number of specific mobs to kill. Killing mobs on-task + awards Assassin XP (mob's max HP x 2) in addition to normal combat XP. + + Some mobs require a minimum Assassin level to attack: + Slug - Level 1 (needs salt to finish off) + Drone - Level 15 (extra damage without insulated gloves) + Crawler - Level 30 (needs acid vial to finish off) + Phantom - Level 45 (extra damage without spectral visor) + + Finishing Blow: Some mobs cannot be killed below 1 HP. Use the + required item on them during combat: "use salt on slug" + + Protection: Some mobs deal 1.5x damage unless you have the + required protective item equipped. + + Reputation is earned on task completion (1 per task + streak bonuses + at 10th, 50th, 100th, 250th, and 1000th consecutive tasks). + Spend Reputation at The Client's Reputation Shop for permanent + unlocks and task management options. + + Related: task, attack, use +``` + +--- + +## 18. Task Table + +The full task assignment table. Each entry specifies: +- `MobID`: The mob definition to assign +- `MinLevel` / `MaxLevel`: Player's Assassin level range for eligibility +- `MinCount` / `MaxCount`: Random kill count range +- `Weight`: Relative probability of being assigned + +### Starter Tasks (Assassin Level 1+) + +| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | +|---|---|---|---|---|---|---| +| `man` | 1 | 15 | 10 | 25 | 8 | Basic melee mob, 7 HP | +| `cow` | 1 | 15 | 10 | 25 | 8 | Basic melee mob, 8 HP | +| `slug` | 1 | 99 | 15 | 45 | 15 | Finishing blow: salt | + +### Mid-Level Tasks (Assassin Level 15+) + +| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | +|---|---|---|---|---|---|---| +| `drone` | 15 | 99 | 20 | 50 | 12 | Damage without: insulated_gloves | + +### High-Level Tasks (Assassin Level 30+) + +| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | +|---|---|---|---|---|---|---| +| `crawler` | 30 | 99 | 15 | 40 | 10 | Finishing blow: acid_vial | + +### Expert Tasks (Assassin Level 45+) + +| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | +|---|---|---|---|---|---|---| +| `phantom` | 45 | 99 | 10 | 30 | 8 | Damage without: spectral_visor | + +### Task Table in Go (Full) + +```go +var assassinTaskTable = []assassinTaskEntry{ + // Starter mobs — available early, phased out at mid levels + {"man", 1, 15, 10, 25, 8}, + {"cow", 1, 15, 10, 25, 8}, + + // Core assassin mobs — available from their assassin_level onward + {"slug", 1, 99, 15, 45, 15}, + {"drone", 15, 99, 20, 50, 12}, + {"crawler", 30, 99, 15, 40, 10}, + {"phantom", 45, 99, 10, 30, 8}, +} +``` + +### Expanding the Table + +As new mobs are added to the game, add entries to `assassinTaskTable`. The weighted random system ensures new mobs can be introduced without modifying existing entries. Future mobs behind the `assassin_unlocked_broader` perk can be filtered in `assignAssassinTask()`: + +```go +for _, entry := range assassinTaskTable { + if level >= entry.MinLevel && level <= entry.MaxLevel { + if entry.RequiresUnlock != "" { + if p.Flags == nil { + continue + } + if val, ok := p.Flags[entry.RequiresUnlock]; !ok || val != true { + continue + } + } + eligible = append(eligible, entry) + totalWeight += entry.Weight + } +} +``` + +Add `RequiresUnlock string` to `assassinTaskEntry` for this. + +--- + +## Summary of All File Changes + +### New Go Files +1. `internal/game/cmd_task.go` — `doTask()` command handler +2. `internal/game/assassin.go` — Task table, assignment, on-kill processing, flag helpers, finishing blow, skip/extend + +### Modified Go Files +1. `internal/world/mob.go` — Add `AssassinLevel`, `FinishingBlow`, `DamageWithout` to `MobDef` and `MobInstance`; copy in `SeedMobs()` +2. `internal/game/cmd_attack.go` — Assassin level check in `doAttack()`, HP floor in `playerAttack()`, damage amplification in `mobAttack()`, `onAssassinKill()` call in `endCombat()` +3. `internal/game/game.go` — Add `"task"` to `classifyCommand()` Instant list; add `"task"` case in `executeCommand()`; add finishing blow intercept before `doUse()` +4. `internal/action/behavior.go` — Add `AssignTask`, `SkipTask`, `ExtendTask`, `ReputationCost` to `NodeAction` +5. `internal/game/action_talk.go` — Handle new `NodeAction` fields in `applyNodeAction()` +6. `internal/combat/state.go` — Add `DamageWarningShown` to `State` (optional) + +### New YAML Files +1. `data/mobs/slug.yaml` +2. `data/mobs/drone.yaml` +3. `data/mobs/crawler.yaml` +4. `data/mobs/phantom.yaml` +5. `data/mobs/client.yaml` +6. `data/items/salt.yaml` +7. `data/items/acid_vial.yaml` +8. `data/items/insulated_gloves.yaml` +9. `data/items/spectral_visor.yaml` +10. `data/items/slug_mucus.yaml` +11. `data/items/circuit_board.yaml` +12. `data/items/chitin_plate.yaml` +13. `data/items/ectoplasm.yaml` +14. `data/behaviors/client_talk.yaml` +15. `data/help/task.yaml` +16. `data/help/assassin.yaml` +17. `data/rooms/50.yaml` +18. `data/rooms/51.yaml` +19. `data/rooms/52.yaml` +20. `data/rooms/53.yaml` +21. `data/rooms/54.yaml` + +### Modified YAML Files +1. An existing room YAML (e.g. room 22 or room 31) — add exit to room 50 + +--- + +## Implementation Order + +1. **MobDef extensions** (`internal/world/mob.go`) — Add fields, update `SeedMobs()` +2. **NodeAction extensions** (`internal/action/behavior.go`) — Add action fields +3. **Flag helpers** (`internal/game/assassin.go`) — `getPlayerFlagInt`, `getPlayerFlagString`, `setPlayerFlag` +4. **Task table and assignment** (`internal/game/assassin.go`) — `assassinTaskTable`, `assignAssassinTask()` +5. **On-kill processing** (`internal/game/assassin.go`) — `onAssassinKill()`, `streakBonus()` +6. **Skip/extend** (`internal/game/assassin.go`) — `skipAssassinTask()`, `extendAssassinTask()` +7. **Task command** (`internal/game/cmd_task.go`) — `doTask()` +8. **Register command** (`internal/game/game.go`) — `classifyCommand()`, `executeCommand()` +9. **Attack modifications** (`internal/game/cmd_attack.go`) — Level check, HP floor, damage amp, on-kill call +10. **Finishing blow via use** (`internal/game/cmd_attack.go` or `assassin.go`) — `tryFinishingBlow()`, `doFinishingBlow()` +11. **Talk action extensions** (`internal/game/action_talk.go`) — Handle `assign_task`, `skip_task`, `extend_task`, `reputation_cost` +12. **All YAML data files** — Mobs, items, behaviors, rooms, help +13. **Connect rooms** — Update existing room YAML to link to room 50 +14. **Test** — `make test`, `make vet`, manual play testing + +--- + +## Testing Checklist + +- [ ] `make build` succeeds +- [ ] `make vet` passes +- [ ] `make test` passes +- [ ] Can talk to The Client and receive a task +- [ ] `task` command shows current task info +- [ ] Killing on-task mobs awards Assassin XP and decrements remaining count +- [ ] Killing off-task mobs does NOT award Assassin XP +- [ ] Task completion awards reputation and increments streak +- [ ] Streak bonuses at 10th, 50th milestones +- [ ] Skip task costs 30 rep and resets streak +- [ ] Extend task costs 30 rep and adds kills +- [ ] Cannot attack mobs with `assassin_level` higher than player's level +- [ ] Slug stays at 1 HP from normal attacks +- [ ] `use salt on slug` kills the slug +- [ ] Salt is consumed on finishing blow (unless auto-finish unlocked) +- [ ] Drone deals 1.5x damage without insulated gloves equipped +- [ ] Drone deals normal damage with insulated gloves equipped +- [ ] Reputation shop purchases set correct player flags +- [ ] Auto-finish unlock prevents salt/acid consumption +- [ ] Items sold by Client via talk dialog work correctly +- [ ] Help topics display correctly +- [ ] New rooms are navigable and mobs spawn correctly +- [ ] Mob remains items drop correctly +- [ ] Backward compatibility — existing mobs with no assassin fields work normally -- cgit v1.2.3