aboutsummaryrefslogtreecommitdiff
path: root/skill_plans/assassin.md
diff options
context:
space:
mode:
Diffstat (limited to 'skill_plans/assassin.md')
-rw-r--r--skill_plans/assassin.md1895
1 files changed, 1895 insertions, 0 deletions
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_<perk>` | `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 <item> on <mob>` 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 <item> on <target>` 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 <item> on <mob>` Kill (in `doUse` or new handler)
+
+The `doUse()` function in `internal/game/cmd_use.go` parses `use <item> on <target>`. 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