diff options
| author | historia <[not public]> | 2026-06-19 18:33:43 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-19 18:33:43 -0400 |
| commit | 2bec24859af8f03c80a0a58e3240519942450167 (patch) | |
| tree | dcb8baa5591fdeca51e179a63a08d46b014cb85c /skill_plans/assassin.md | |
| parent | 0ea4eec5dd2122c6704216971eb1249276297867 (diff) | |
| download | thehouseoficarus-2bec24859af8f03c80a0a58e3240519942450167.tar.gz | |
readme
Diffstat (limited to 'skill_plans/assassin.md')
| -rw-r--r-- | skill_plans/assassin.md | 1895 |
1 files changed, 0 insertions, 1895 deletions
diff --git a/skill_plans/assassin.md b/skill_plans/assassin.md deleted file mode 100644 index 31ea301..0000000 --- a/skill_plans/assassin.md +++ /dev/null @@ -1,1895 +0,0 @@ -# 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 |
